Files
rippr/lib/src/recording/ride_accumulator.dart
Dylan 398157b1f5 Complete Phase 1: accumulator, exports, and full parity coverage
T05 — ride_accumulator.dart with the cross-batch anchor intact (12 tests).
T06 — ride_export.dart, GPX 1.1 and GeoJSON (15 tests), parsed with real
parsers rather than substring matching.
T07 — parity harness extended to cover export byte output.

GPX and GeoJSON are byte-identical across Kotlin and Dart: same length, same
FNV hash, including the escaped hostile name and all %.7f/%.1f formatting.

Two harness bugs found and fixed while building it. String.hashCode is not
comparable across Java and Dart, so text comparison used FNV-1a instead. And a
missing jar let a failed Kotlin compile pass as a green run -- run.sh now checks
for the artifact and exits non-zero, the same failure mode as the v2 sweep that
hid a compile error behind /dev/null.

Phase 1 done: 79 tests passing, analyze clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:08:30 -05:00

115 lines
3.7 KiB
Dart

/// Running totals for the ride in progress.
///
/// Ported from `com.rippr.Accumulator`.
///
/// Distance and elevation both need consecutive-row differences, so they are folded in
/// here as points are written and persisted onto the `Trip` row once per flush. That
/// lets the recording screen show live numbers without rescanning the point table.
///
/// The values this produces are an *estimate*. They are overwritten by
/// [computeSummary] when the trip completes, which is also why this reuses
/// [ElevationAccumulator] rather than reimplementing hysteresis: a naive version
/// measured 1498 m of phantom climbing on a parked bike.
///
/// Not thread-safe — the writer loop owns it, under the recorder's write lock.
library;
import 'dart:math' as math;
import '../domain/models.dart';
import '../geo/geo.dart';
import '../stats/ride_statistics.dart';
import '../telemetry/telemetry.dart';
class Accumulator {
/// The last point of the *previous* batch.
///
/// Without this, distance restarts at every flush boundary and silently under-reports
/// by roughly one inter-point hop per batch — a few percent, which nobody notices
/// until they compare against an odometer.
TrackPoint? _lastPoint;
ElevationAccumulator _elevation = ElevationAccumulator();
double _restoredElevationM = 0.0;
double _distanceM = 0.0;
int _movingMillis = 0;
double _maxSpeedKmh = 0.0;
int _pointCount = 0;
double get distanceM => _distanceM;
int get movingMillis => _movingMillis;
double get maxSpeedKmh => _maxSpeedKmh;
int get pointCount => _pointCount;
void fold(List<TrackPoint> points) {
for (final point in points) {
_pointCount++;
_maxSpeedKmh = math.max(_maxSpeedKmh, point.speedKmh);
_elevation.add(point.altitudeM);
final prev = _lastPoint;
// Distance and moving time only accrue *within* a segment. Points either side of
// a pause can be kilometres apart and that gap was never ridden.
if (prev != null && prev.segmentId == point.segmentId) {
_distanceM += haversineMeters(
prev.latitude,
prev.longitude,
point.latitude,
point.longitude,
);
final dt = point.timestamp - prev.timestamp;
if (dt >= 1 &&
dt <= maxSampleGapMillis &&
point.speedKmh >= speedNoiseFloorKmh) {
_movingMillis += dt;
}
}
_lastPoint = point;
}
}
/// Elevation gain including the run still in progress.
///
/// Reads a snapshot rather than mutating, so it is safe to call on every flush while
/// recording continues.
double elevationGain() =>
_restoredElevationM + _elevation.gainIncludingPending();
/// Seeds from a trip already in progress after the process is restarted.
void restore({
required double distanceM,
required int movingMillis,
required double maxSpeedKmh,
required double elevationGainM,
required int pointCount,
TrackPoint? lastPoint,
}) {
_distanceM = distanceM;
_movingMillis = movingMillis;
_maxSpeedKmh = maxSpeedKmh;
_pointCount = pointCount;
_lastPoint = lastPoint;
// Elevation cannot be resumed mid-run from a scalar total, so the accumulator
// restarts and only gain from here on is added to the persisted figure. The
// authoritative recomputation at trip completion corrects any drift.
_elevation = ElevationAccumulator();
_restoredElevationM = elevationGainM;
}
void reset() {
_lastPoint = null;
_elevation = ElevationAccumulator();
_distanceM = 0.0;
_movingMillis = 0;
_maxSpeedKmh = 0.0;
_pointCount = 0;
_restoredElevationM = 0.0;
}
/// Clears the cross-segment anchor so a resumed ride does not span the pause.
void onSegmentChanged() {
_lastPoint = null;
}
}