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>
This commit is contained in:
114
lib/src/recording/ride_accumulator.dart
Normal file
114
lib/src/recording/ride_accumulator.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user