/// 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 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; } }