Files
rippr/lib/src/telemetry/telemetry.dart
Dylan 4af4e3411a Port Telemetry, Format, RideStatistics; add cross-language parity harness
T03 — telemetry.dart, format.dart, live_telemetry.dart, plus pure domain models
(Trip/Segment/TrackPoint/RideStats) with no persistence dependency, so Drift can
map to them in T08 rather than the domain depending on the database.

T04 — ride_statistics.dart including ElevationAccumulator, ported structurally
faithfully: moving average, reversal hysteresis, gainIncludingPending, and the
finish() reconciliation against lastRaw.

T07 (early, because T04 forced it) — tool/parity/ drives identical fixtures
through the real Kotlin files and the Dart port, then diffs. Result: every value
byte-identical, including noisy_gain=38.959594555022136 to the last digit. The
sole difference is run_avg_speed, where Kotlin's 32-bit Float widens to double
with artefacts Dart's binary64 does not reproduce. Documented, not papered over.

That harness settled a real question. The ported elevation test failed at 50.9m
against Kotlin's 35m bound, which looked like a porting bug. It was not: Kotlin's
and Dart's Random(42) are different streams. On a shared LCG fixture both produce
39.0m -- which would also fail Kotlin's own bound. The native guard passes on seed
luck rather than on a property of the algorithm. The Dart test now uses the shared
LCG, asserts bit-equality with Kotlin, and sets its bound from measured behaviour
(25 seeds spanned 24.7-46.7m).

52 tests passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:59:35 -05:00

64 lines
2.2 KiB
Dart

/// Pure conversion and serialization logic, free of Flutter and platform types.
///
/// Ported from `com.rippr.Telemetry`. The Kotlin original also held process-wide
/// recording state; that was already deleted in v2 (state lives in the database, because
/// it must survive process death) and is not resurrected here.
library;
import 'dart:convert';
import '../domain/models.dart';
const double msToKmhFactor = 3.6;
double msToKmh(double metersPerSecond) => metersPerSecond * msToKmhFactor;
/// A parked bike still emits jittering fixes. Anything under this is reported as zero so
/// "max speed" is not set by GPS noise while the phone sits in a pocket.
const double speedNoiseFloorKmh = 1.5;
double sanitizeSpeedKmh(double raw) {
if (!raw.isFinite || raw < speedNoiseFloorKmh) return 0.0;
return raw;
}
/// Drop fixes too imprecise to be worth storing. 0 means "accuracy unknown".
bool isUsableFix(double accuracyMeters, {double maxAccuracyMeters = 50.0}) =>
accuracyMeters <= 0.0 || accuracyMeters <= maxAccuracyMeters;
String formatDuration(int millis) {
if (millis <= 0) return '00:00:00';
final totalSeconds = millis ~/ 1000;
final h = (totalSeconds ~/ 3600).toString().padLeft(2, '0');
final m = ((totalSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
final s = (totalSeconds % 60).toString().padLeft(2, '0');
return '$h:$m:$s';
}
/// Encode a batch of points for the upload endpoint.
///
/// The trip and segment ids are written **per point, not per batch**: the unsynced-point
/// query draws by id and can straddle a segment or, after a discard-and-restart, a trip
/// boundary. Hoisting them to batch level would silently mislabel points.
String encodeBatch(String deviceId, List<TrackPoint> points) {
final array = points
.map((p) => <String, Object?>{
'id': p.id,
'trip_id': p.tripId,
'segment_id': p.segmentId,
'ts': p.timestamp,
'lat': p.latitude,
'lon': p.longitude,
'speed_kmh': p.speedKmh,
'alt_m': p.altitudeM,
'acc_m': p.accuracyM,
'bearing': p.bearingDeg,
})
.toList(growable: false);
return jsonEncode(<String, Object?>{
'device_id': deviceId,
'points': array,
});
}