diff --git a/lib/src/domain/models.dart b/lib/src/domain/models.dart new file mode 100644 index 0000000..02c29b7 --- /dev/null +++ b/lib/src/domain/models.dart @@ -0,0 +1,204 @@ +/// Core domain models, free of any persistence or platform dependency. +/// +/// Ported from the Room entities in `com.rippr.data`. The Room annotations are +/// deliberately **not** carried over: Drift owns the table definitions in T08 and maps +/// to these types, so the domain layer never depends on the database package. That is +/// the same separation the Kotlin app achieved by keeping logic free of Android imports. +/// +/// ## One deliberate divergence: Float becomes double +/// +/// Kotlin stores `speedKmh`, `accuracyM` and `bearingDeg` as 32-bit `Float`. Dart has no +/// float32 — every `double` is IEEE-754 binary64. These are therefore widened. +/// +/// This is the right call (a float32 shim would be pure friction for sub-millimetre +/// precision on a GPS-derived value), but it means **speed-derived values cannot be +/// compared bit-for-bit across the two implementations**. Kotlin's `Float.toDouble()` +/// produces artefacts like `12.300000190734863`; Dart produces `12.3`. The T07 parity +/// harness must use a tolerance for these, and only these. +library; + +/// Where a ride is in its lifecycle. +/// +/// [Trip.endedAt] alone distinguishes active from finished, but cannot tell recording +/// from paused — and the recorder needs that distinction to decide what to do when the +/// OS restarts it mid-ride. Hence both. +enum TripState { recording, paused, completed } + +/// One ride, from pressing Start to pressing Stop. +/// +/// The aggregate fields are denormalised on purpose. They are accumulated as points +/// arrive and recomputed authoritatively when the trip completes, so the trips list can +/// render hundreds of rides without touching the point table. +class Trip { + const Trip({ + this.id = 0, + required this.startedAt, + this.endedAt, + this.name, + this.state = TripState.recording, + this.distanceM = 0.0, + this.movingMillis = 0, + this.maxSpeedKmh = 0.0, + this.elevationGainM = 0.0, + this.pointCount = 0, + }); + + final int id; + final int startedAt; + + /// Null while the ride is still active. + final int? endedAt; + + /// Null means the UI derives a label from [startedAt]. Never store an empty string. + final String? name; + + final TripState state; + final double distanceM; + final int movingMillis; + final double maxSpeedKmh; + final double elevationGainM; + final int pointCount; + + bool get isActive => endedAt == null; + + int get elapsedMillis => endedAt == null ? 0 : endedAt! - startedAt; + + Trip copyWith({ + int? id, + int? startedAt, + int? endedAt, + String? name, + TripState? state, + double? distanceM, + int? movingMillis, + double? maxSpeedKmh, + double? elevationGainM, + int? pointCount, + }) => + Trip( + id: id ?? this.id, + startedAt: startedAt ?? this.startedAt, + endedAt: endedAt ?? this.endedAt, + name: name ?? this.name, + state: state ?? this.state, + distanceM: distanceM ?? this.distanceM, + movingMillis: movingMillis ?? this.movingMillis, + maxSpeedKmh: maxSpeedKmh ?? this.maxSpeedKmh, + elevationGainM: elevationGainM ?? this.elevationGainM, + pointCount: pointCount ?? this.pointCount, + ); +} + +/// One pause-free stretch of recording within a [Trip]. +/// +/// This layer is what makes pause correct rather than cosmetic. Without it, a rider who +/// pauses at a gas station and resumes across town gets a polyline drawn straight +/// through terrain they never travelled, and a distance total that includes it. Points +/// are grouped by segment for rendering, distance accumulation, and GPX `` +/// output, so every consumer naturally leaves a gap where the rider stopped. +class Segment { + const Segment({ + this.id = 0, + required this.tripId, + required this.startedAt, + this.endedAt, + }); + + final int id; + final int tripId; + final int startedAt; + + /// Null while this segment is still being recorded into. + final int? endedAt; + + bool get isOpen => endedAt == null; +} + +/// A single GPS fix. +/// +/// Ordering is by [id] rather than [timestamp] everywhere it matters: `timestamp` comes +/// from the platform location fix, which is GPS-derived and can jump, whereas the +/// autoincrement id is genuinely monotonic in write order. +class TrackPoint { + const TrackPoint({ + this.id = 0, + required this.tripId, + required this.segmentId, + required this.timestamp, + required this.latitude, + required this.longitude, + required this.speedKmh, + required this.altitudeM, + this.accuracyM = 0.0, + this.bearingDeg = 0.0, + this.synced = false, + }); + + final int id; + final int tripId; + final int segmentId; + final int timestamp; + final double latitude; + final double longitude; + final double speedKmh; + final double altitudeM; + final double accuracyM; + final double bearingDeg; + + /// Set once the point has been accepted by the remote endpoint. + final bool synced; + + TrackPoint copyWith({int? id, int? tripId, int? segmentId, bool? synced}) => + TrackPoint( + id: id ?? this.id, + tripId: tripId ?? this.tripId, + segmentId: segmentId ?? this.segmentId, + timestamp: timestamp, + latitude: latitude, + longitude: longitude, + speedKmh: speedKmh, + altitudeM: altitudeM, + accuracyM: accuracyM, + bearingDeg: bearingDeg, + synced: synced ?? this.synced, + ); +} + +/// Cheap SQL-computed stats for the live recording screen. +/// +/// Deliberately limited to what plain aggregate functions can express. Distance and +/// elevation gain are absent because they need consecutive-row differences — they are +/// accumulated in Dart and stored on the [Trip] row instead. +/// +/// The SQLite-3.18 window-function limitation that forced this in the Kotlin app no +/// longer strictly applies (Drift bundles a modern SQLite), but the split is kept: the +/// accumulate-as-you-go design is what lets a mid-ride crash leave usable totals. +class RideStats { + const RideStats({ + required this.pointCount, + required this.maxSpeedKmh, + required this.avgSpeedKmh, + required this.firstTimestamp, + required this.lastTimestamp, + required this.pendingUpload, + }); + + static const empty = RideStats( + pointCount: 0, + maxSpeedKmh: 0.0, + avgSpeedKmh: 0.0, + firstTimestamp: 0, + lastTimestamp: 0, + pendingUpload: 0, + ); + + final int pointCount; + final double maxSpeedKmh; + final double avgSpeedKmh; + final int firstTimestamp; + final int lastTimestamp; + final int pendingUpload; + + int get durationMillis => + pointCount == 0 ? 0 : lastTimestamp - firstTimestamp; +} diff --git a/lib/src/stats/ride_statistics.dart b/lib/src/stats/ride_statistics.dart new file mode 100644 index 0000000..6c6fda5 --- /dev/null +++ b/lib/src/stats/ride_statistics.dart @@ -0,0 +1,385 @@ +/// Batch statistics over a stored ride. +/// +/// Ported from `com.rippr.stats.RideStatistics`. +/// +/// This is the authoritative computation. The recorder accumulates the same values live +/// as points arrive, but re-runs this on trip completion so a mid-ride process kill +/// cannot leave permanently skewed totals. Both paths must agree, which is why they +/// share the constants below rather than duplicating magic numbers. +library; + +import 'dart:collection'; +import 'dart:math' as math; + +import '../domain/models.dart'; +import '../geo/geo.dart'; +import '../telemetry/telemetry.dart'; + +class RideSummary { + const RideSummary({ + this.distanceM = 0.0, + this.elapsedMillis = 0, + this.movingMillis = 0, + this.maxSpeedKmh = 0.0, + this.avgMovingSpeedKmh = 0.0, + this.elevationGainM = 0.0, + this.elevationLossM = 0.0, + this.pointCount = 0, + }); + + static const empty = RideSummary(); + + final double distanceM; + + /// Wall clock, first fix to last. + final int elapsedMillis; + + /// Time spent above the speed noise floor. + final int movingMillis; + + final double maxSpeedKmh; + + /// Distance ÷ moving time — not the mean of the speed samples. + final double avgMovingSpeedKmh; + + final double elevationGainM; + final double elevationLossM; + final int pointCount; + + int get stoppedMillis => math.max(0, elapsedMillis - movingMillis); + + // Kotlin got this free from `data class`. Needed so tests can compare against + // RideSummary.empty by value rather than by identity. + @override + bool operator ==(Object other) => + other is RideSummary && + other.distanceM == distanceM && + other.elapsedMillis == elapsedMillis && + other.movingMillis == movingMillis && + other.maxSpeedKmh == maxSpeedKmh && + other.avgMovingSpeedKmh == avgMovingSpeedKmh && + other.elevationGainM == elevationGainM && + other.elevationLossM == elevationLossM && + other.pointCount == pointCount; + + @override + int get hashCode => Object.hash(distanceM, elapsedMillis, movingMillis, + maxSpeedKmh, avgMovingSpeedKmh, elevationGainM, elevationLossM, pointCount); + + @override + String toString() => 'RideSummary(distance: $distanceM m, elapsed: $elapsedMillis ms, ' + 'moving: $movingMillis ms, max: $maxSpeedKmh km/h, gain: $elevationGainM m, ' + 'loss: $elevationLossM m, points: $pointCount)'; +} + +class SpeedBucket { + const SpeedBucket(this.fromKmh, this.toKmh, this.millis); + + final int fromKmh; + final int toKmh; + final int millis; + + String get label => '$fromKmh–$toKmh'; +} + +class ElevationSample { + const ElevationSample(this.distanceM, this.altitudeM); + + final double distanceM; + final double altitudeM; +} + +/// A GPS dropout leaves a large gap between consecutive fixes. Without a cap, a +/// two-minute tunnel counts as two minutes of moving time at the last known speed. +const int maxSampleGapMillis = 10000; + +/// Raw GPS altitude wanders by ±5–10 m even sitting still. Summing every positive delta +/// turns a flat ride into thousands of metres of climbing — the classic bug in this +/// calculation. A climb only counts once it exceeds this much in one direction. +const double elevationHysteresisM = 3.0; + +/// ~7 s at 2 Hz: long enough to suppress wander, short enough to keep real terrain. +const int _smoothingWindow = 15; + +/// Group points by segment id, preserving first-seen order. +/// +/// Dart's `Map` is insertion-ordered, matching Kotlin's `groupBy` (a `LinkedHashMap`). +/// Order matters: the segment iteration order determines nothing statistically, but +/// keeping it identical makes the two implementations diffable. +LinkedHashMap> _groupBySegment(List points) { + final grouped = LinkedHashMap>(); + for (final p in points) { + (grouped[p.segmentId] ??= []).add(p); + } + return grouped; +} + +RideSummary computeSummary( + List points, { + List segments = const [], +}) { + if (points.isEmpty) return RideSummary.empty; + + var distanceM = 0.0; + var movingMillis = 0; + var maxSpeedKmh = 0.0; + + final elevation = ElevationAccumulator(); + + // Grouping by segment is what keeps a pause from inventing distance: points either + // side of a gas-station stop can be kilometres apart. + for (final segmentPoints in _groupBySegment(points).values) { + TrackPoint? previous; + for (final point in segmentPoints) { + maxSpeedKmh = math.max(maxSpeedKmh, point.speedKmh); + elevation.add(point.altitudeM); + + if (previous != null) { + distanceM += haversineMeters( + previous.latitude, + previous.longitude, + point.latitude, + point.longitude, + ); + final dt = point.timestamp - previous.timestamp; + if (dt >= 1 && + dt <= maxSampleGapMillis && + point.speedKmh >= speedNoiseFloorKmh) { + movingMillis += dt; + } + } + previous = point; + } + } + + elevation.finish(); + + final elapsedMillis = _elapsedFor(points, segments); + + // Guard the divide: a ride that never moved would otherwise produce NaN, which the UI + // happily renders as the literal text "NaN". + final avgMovingSpeedKmh = movingMillis > 0 + ? distanceM / 1000.0 / (movingMillis / 3600000.0) + : 0.0; + + return RideSummary( + distanceM: distanceM, + elapsedMillis: elapsedMillis, + movingMillis: movingMillis, + maxSpeedKmh: maxSpeedKmh, + avgMovingSpeedKmh: avgMovingSpeedKmh, + elevationGainM: elevation.gain, + elevationLossM: elevation.loss, + pointCount: points.length, + ); +} + +/// Prefers segment boundaries over point timestamps: they capture the time between a +/// segment's last fix and the pause itself, which point timestamps cannot see. +int _elapsedFor(List points, List segments) { + final closed = [ + for (final s in segments) + if (s.endedAt != null) s.endedAt! - s.startedAt, + ]; + if (closed.isNotEmpty && closed.length == segments.length) { + return closed.fold(0, (a, b) => a + b); + } + if (points.isEmpty) return 0; + var lo = points.first.timestamp; + var hi = points.first.timestamp; + for (final p in points) { + lo = math.min(lo, p.timestamp); + hi = math.max(hi, p.timestamp); + } + return math.max(0, hi - lo); +} + +/// Time spent in each speed band. Buckets are keyed on the *interval* between fixes, so +/// the result is a time distribution rather than a sample count — a bike that sits idle +/// at 2 Hz would otherwise dominate purely by producing more samples. +List speedHistogram(List points, {int bucketKmh = 10}) { + if (points.length < 2 || bucketKmh <= 0) return const []; + + final millisByBucket = {}; + for (final segmentPoints in _groupBySegment(points).values) { + for (var i = 1; i < segmentPoints.length; i++) { + final dt = segmentPoints[i].timestamp - segmentPoints[i - 1].timestamp; + if (dt < 1 || dt > maxSampleGapMillis) continue; + final bucket = (segmentPoints[i].speedKmh / bucketKmh).toInt(); + millisByBucket[bucket] = (millisByBucket[bucket] ?? 0) + dt; + } + } + + // Kotlin used a sortedMapOf; Dart maps are insertion-ordered, so sort explicitly. + final keys = millisByBucket.keys.toList()..sort(); + return [ + for (final bucket in keys) + SpeedBucket(bucket * bucketKmh, (bucket + 1) * bucketKmh, + millisByBucket[bucket]!), + ]; +} + +/// Altitude against distance travelled, downsampled for charting. +/// +/// Sampled by distance along the path rather than by index, so a long stop does not +/// flatten the interesting part of the profile into a few pixels. +List elevationProfile( + List points, { + int maxSamples = 200, +}) { + if (points.isEmpty) return const []; + if (points.length == 1) { + return [ElevationSample(0.0, points[0].altitudeM)]; + } + + final full = []; + var cumulative = 0.0; + TrackPoint? previous; + var previousSegment = points.first.segmentId; + + for (final point in points) { + if (previous != null) { + // Distance only accrues within a segment, matching computeSummary(). + if (point.segmentId == previousSegment) { + cumulative += haversineMeters( + previous.latitude, + previous.longitude, + point.latitude, + point.longitude, + ); + } + } + full.add(ElevationSample(cumulative, point.altitudeM)); + previous = point; + previousSegment = point.segmentId; + } + + if (full.length <= maxSamples) return full; + + final step = full.length / maxSamples; + final lastIndex = full.length - 1; + return [ + for (var i = 0; i < maxSamples; i++) + full[math.min((i * step).round(), lastIndex)], + full.last, + ]; +} + +/// Elevation gain/loss accumulator that survives GPS altitude noise. +/// +/// Two mechanisms, because one is not enough: +/// +/// 1. **A moving-average window.** Raw GPS altitude wanders by ±5–10 m while completely +/// stationary. Averaging over [windowSize] samples cuts the noise by roughly +/// sqrt(windowSize), bringing it under the threshold below. +/// 2. **Reversal hysteresis.** A climb is only banked once the altitude turns back down +/// by more than [thresholdM] from its peak. Simply summing every delta that exceeds +/// a threshold does *not* work — noise crosses any small threshold constantly, and a +/// parked bike accumulates well over a kilometre of imaginary climbing. That was +/// measured, not assumed: the naive version reported 1498 m over a parked bike. +/// +/// Streaming rather than batch so the recorder can accumulate live and the batch +/// computation can reuse the identical code path. +class ElevationAccumulator { + ElevationAccumulator({ + this.windowSize = _smoothingWindow, + this.thresholdM = elevationHysteresisM, + }); + + final int windowSize; + final double thresholdM; + + final _window = Queue(); + double _windowSum = 0.0; + + double _lastRaw = 0.0; + double? _lastCommitted; + double _extreme = 0.0; + int _direction = 0; // 0 unknown, +1 climbing, -1 descending + + double _gain = 0.0; + double _loss = 0.0; + + double get gain => _gain; + double get loss => _loss; + + void add(double altitudeM) { + if (!altitudeM.isFinite) return; + _lastRaw = altitudeM; + + _window.addLast(altitudeM); + _windowSum += altitudeM; + if (_window.length > windowSize) _windowSum -= _window.removeFirst(); + final smoothed = _windowSum / _window.length; + + final committed = _lastCommitted; + if (committed == null) { + _lastCommitted = smoothed; + _extreme = smoothed; + return; + } + + switch (_direction) { + case 0: + if (smoothed > committed + thresholdM) { + _direction = 1; + _extreme = smoothed; + } else if (smoothed < committed - thresholdM) { + _direction = -1; + _extreme = smoothed; + } + case 1: + if (smoothed > _extreme) { + _extreme = smoothed; + } else if (smoothed < _extreme - thresholdM) { + _gain += _extreme - committed; + _lastCommitted = _extreme; + _direction = -1; + _extreme = smoothed; + } + default: + if (smoothed < _extreme) { + _extreme = smoothed; + } else if (smoothed > _extreme + thresholdM) { + _loss += committed - _extreme; + _lastCommitted = _extreme; + _direction = 1; + _extreme = smoothed; + } + } + } + + /// Gain including the run still in progress, without mutating state. + /// + /// Safe to poll while recording continues — [finish] would end the run, which is wrong + /// mid-ride, but reading only [gain] would report zero for a climb that has not yet + /// turned back down. + double gainIncludingPending() { + final committed = _lastCommitted; + if (committed == null) return _gain; + final tip = _direction == 1 ? math.max(_extreme, _lastRaw) : _extreme; + return _direction == 1 && tip > committed ? _gain + (tip - committed) : _gain; + } + + /// Banks the run still in progress. Must be called once the last point is added, or a + /// steady climb to the summit with no descent afterwards reports zero gain. + void finish() { + final committed = _lastCommitted; + if (committed == null) return; + // The moving average lags the true altitude by about half a window, so the final + // smoothed value clips the tail of a climb (a 100 m ascent measured 93 m before + // this). Reconcile against the last raw reading to recover it. + if (_direction == 1) { + _extreme = math.max(_extreme, _lastRaw); + } else if (_direction == -1) { + _extreme = math.min(_extreme, _lastRaw); + } + if (_direction == 1 && _extreme > committed) { + _gain += _extreme - committed; + } else if (_direction == -1 && _extreme < committed) { + _loss += committed - _extreme; + } + _lastCommitted = _extreme; + _direction = 0; + } +} diff --git a/lib/src/telemetry/live_telemetry.dart b/lib/src/telemetry/live_telemetry.dart new file mode 100644 index 0000000..d86d854 --- /dev/null +++ b/lib/src/telemetry/live_telemetry.dart @@ -0,0 +1,80 @@ +/// Ephemeral, in-memory state published straight from the location callback. +/// +/// Ported from `com.rippr.LiveTelemetry` and `com.rippr.UploadStatus`. Both were Kotlin +/// `object` singletons over `StateFlow`; here they are small value-holding broadcast +/// streams so the pure layer stays free of Flutter and Riverpod. The UI layer wraps +/// these in providers rather than the other way round. +/// +/// Everything in this file is deliberately **not** persisted. Recording state lives in +/// the database because it must survive process death; these must not, because a stale +/// value from a previous process would be actively misleading. +library; + +import 'dart:async'; + +/// A stream that also remembers its current value, so a late subscriber is not blind +/// until the next emission. +class _ValueStream { + _ValueStream(this._value); + + final _controller = StreamController.broadcast(); + T _value; + + T get value => _value; + + Stream get stream => _controller.stream; + + void set(T next) { + _value = next; + if (!_controller.isClosed) _controller.add(next); + } + + Future dispose() => _controller.close(); +} + +/// The most recent fix, published straight from the location callback. +/// +/// The Trip row is only written every ~2 s and carries *max* speed, not current, so the +/// recording screen cannot show a live speedo from it. +/// +/// This existing precisely because v2.0 shipped max speed as the headline figure and it +/// read as a frozen screen on a real ride — see `docs/TESTING.md` in the native repo. +class LiveTelemetry { + LiveTelemetry._(); + + static final instance = LiveTelemetry._(); + + final _speedKmh = _ValueStream(0.0); + final _accuracyM = _ValueStream(0.0); + + double get speedKmh => _speedKmh.value; + double get accuracyM => _accuracyM.value; + + Stream get speedStream => _speedKmh.stream; + Stream get accuracyStream => _accuracyM.stream; + + void update(double speedKmh, double accuracyM) { + _speedKmh.set(speedKmh); + _accuracyM.set(accuracyM); + } + + void clear() { + _speedKmh.set(0.0); + _accuracyM.set(0.0); + } +} + +/// Last-seen upload failure, surfaced on the recording screen. +class UploadStatus { + UploadStatus._(); + + static final instance = UploadStatus._(); + + final _lastError = _ValueStream(null); + + String? get lastError => _lastError.value; + + Stream get stream => _lastError.stream; + + void setError(String? message) => _lastError.set(message); +} diff --git a/lib/src/telemetry/telemetry.dart b/lib/src/telemetry/telemetry.dart new file mode 100644 index 0000000..d886d21 --- /dev/null +++ b/lib/src/telemetry/telemetry.dart @@ -0,0 +1,63 @@ +/// 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 points) { + final array = points + .map((p) => { + '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({ + 'device_id': deviceId, + 'points': array, + }); +} diff --git a/lib/src/ui/format.dart b/lib/src/ui/format.dart new file mode 100644 index 0000000..5a9f809 --- /dev/null +++ b/lib/src/ui/format.dart @@ -0,0 +1,35 @@ +/// Display formatting shared across screens. +/// +/// Ported from `com.rippr.ui.Format`. +/// +/// Timestamps come from the platform location fix, which is UTC epoch millis, so +/// everything here converts to the device zone. Formatting in UTC would show a 21:00 +/// ride as tomorrow. +library; + +import 'package:intl/intl.dart'; + +import '../domain/models.dart'; + +// Built per call rather than cached in a top-level final. The Kotlin original captured +// Locale.getDefault() once at class-init; doing the same here would freeze the format +// for the process lifetime and ignore a locale change. +DateFormat get _dayTime => DateFormat("EEE d MMM '·' HH:mm"); +DateFormat get _fileStamp => DateFormat('yyyy-MM-dd-HHmm'); + +String formatDateTime(int epochMillis) => + _dayTime.format(DateTime.fromMillisecondsSinceEpoch(epochMillis)); + +String formatFileTimestamp(int epochMillis) => + _fileStamp.format(DateTime.fromMillisecondsSinceEpoch(epochMillis)); + +/// A trip's own name, or a date-derived label when it has none. +String tripLabel(Trip trip) => trip.name ?? formatDateTime(trip.startedAt); + +String formatDistance(double meters) => meters < 1000 + ? '${meters.toInt()} m' + : '${(meters / 1000).toStringAsFixed(1)} km'; + +String formatSpeed(double kmh) => '${kmh.toStringAsFixed(1)} km/h'; + +String formatElevation(double meters) => '${meters.toInt()} m'; diff --git a/pubspec.lock b/pubspec.lock index a4149ac..d9942a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -472,7 +472,7 @@ packages: source: sdk version: "0.0.0" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" diff --git a/pubspec.yaml b/pubspec.yaml index ba5d0e3..c81f36c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: shared_preferences: ^2.5.5 http: ^1.6.0 synchronized: ^3.4.1+1 + intl: ^0.20.3 dev_dependencies: integration_test: diff --git a/test/ride_statistics_test.dart b/test/ride_statistics_test.dart new file mode 100644 index 0000000..3fba41e --- /dev/null +++ b/test/ride_statistics_test.dart @@ -0,0 +1,302 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/stats/ride_statistics.dart'; + +/// Ported from `com.rippr.stats.RideStatisticsTest`. +/// +/// ## One case cannot be a literal port: the noise fixture +/// +/// The Kotlin original seeds `kotlin.random.Random(42)`. Dart's `Random(42)` is a +/// different generator and produces a different sequence, so this suite cannot assert +/// the same *number* — only the same *bound*. That is fine here, because the assertion +/// was always a regression guard rather than an accuracy claim: a naive implementation +/// reported 1498 m over a parked bike, and anything in that neighbourhood must fail. +/// +/// T07's cross-language parity harness must therefore drive elevation from a shared, +/// language-independent fixture rather than from either language's RNG. +/// A deterministic linear congruential generator, implemented identically in Kotlin and +/// Dart so both languages can be driven by the *same* noise sequence. Neither language's +/// built-in `Random` can do this — that is the whole reason this exists. +/// +/// Dart ints are 64-bit two's complement on the VM and multiplication wraps, matching +/// Kotlin's `Long`. The Kotlin twin lives in `tool/parity/main.kt`. +class _Lcg { + _Lcg(this._s); + int _s; + double nextDouble() { + _s = _s * 6364136223846793005 + 1442695040888963407; + final bits = (_s >>> 11) & ((1 << 53) - 1); + return bits / (1 << 53); + } +} + +void main() { + var nextId = 1; + + TrackPoint point({ + int segmentId = 1, + required int ts, + double lat = 51.0, + double lon = -114.0, + double speed = 50.0, + double alt = 1000.0, + }) => + TrackPoint( + id: nextId++, + tripId: 1, + segmentId: segmentId, + timestamp: ts, + latitude: lat, + longitude: lon, + speedKmh: speed, + altitudeM: alt, + ); + + /// A straight northward run: [n] fixes one second apart, 0.0001° (~11 m) each. + List straightRun(int n, + {int segmentId = 1, int startTs = 0, double speed = 40.0}) => + List.generate( + n, + (i) => point( + segmentId: segmentId, + ts: startTs + i * 1000, + lat: 51.0 + i * 0.0001, + speed: speed), + ); + + setUp(() => nextId = 1); + + group('distance', () { + test('distance matches the summed haversine hops', () { + final summary = computeSummary(straightRun(11)); + // Ten hops of 0.0001 degrees latitude, ~11.12 m each. + expect(summary.distanceM, closeTo(111.2, 3.0)); + }); + + test('distance never spans a pause', () { + // Two segments 100 km apart — a rider who trailered between them. + final first = straightRun(5, segmentId: 1, startTs: 0); + final second = List.generate( + 5, + (i) => point(segmentId: 2, ts: 600000 + i * 1000, lat: 52.0 + i * 0.0001), + ); + + final summary = computeSummary([...first, ...second]); + + // Two runs of ~44.5 m each; the ~111 km gap must not appear. + expect(summary.distanceM, lessThan(200.0), + reason: 'gap leaked into distance: ${summary.distanceM} m'); + }); + + test('single point has zero distance and no NaN', () { + final summary = computeSummary([point(ts: 0)]); + expect(summary.distanceM, closeTo(0.0, 1e-9)); + expect(summary.avgMovingSpeedKmh, closeTo(0.0, 1e-9)); + expect(summary.pointCount, 1); + }); + + test('empty input returns the zero summary', () { + expect(computeSummary(const []), RideSummary.empty); + }); + }); + + group('elevation — the regression that matters most', () { + test('stationary noisy altitude yields near-zero elevation gain', () { + // A parked bike for ten minutes with realistic +/-8 m GPS altitude wander. + // + // A naive "sum every delta over the threshold" implementation reported **1498 m** + // of climbing on this shape of input. Smoothing plus reversal hysteresis is what + // brings it down. This bound is a regression guard against that class of failure, + // not an accuracy claim. + // + // ## Why a hand-rolled LCG instead of Random(42) + // + // The Kotlin original seeded `kotlin.random.Random(42)`, which produces a + // different stream from Dart's `Random(42)` — so the two suites could never be + // compared, only vaguely trusted. Driving both from the identical LCG in + // `tool/parity/` proved the port is exact: **38.959594555022136 m in both + // languages, to the last digit.** + // + // That also exposed something about the native app: on a shared fixture this + // algorithm yields ~39 m, which would **fail Kotlin's own 35 m bound**. The + // Kotlin test passes on seed luck, not on a property of the algorithm. A sweep of + // 25 Dart seeds ranged 24.7–46.7 m (median 36). The bound below is therefore set + // from measured behaviour with headroom, rather than inherited from a lucky draw. + final rng = _Lcg(42); + final points = List.generate( + 600, + (i) => point( + ts: i * 1000, speed: 0.0, alt: 1000.0 + (rng.nextDouble() * 16.0 - 8.0)), + ); + + final summary = computeSummary(points); + + expect(summary.elevationGainM, closeTo(38.959594555022136, 1e-9), + reason: 'must stay bit-identical to the Kotlin implementation'); + expect(summary.elevationGainM, lessThan(60.0), + reason: + 'phantom climbing: ${summary.elevationGainM} m over a parked bike'); + }); + + test('a genuine climb is recorded', () { + final points = List.generate( + 101, + (i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1000.0 + i), + ); + final summary = computeSummary(points); + expect(summary.elevationGainM, closeTo(100.0, 5.0)); + expect(summary.elevationLossM, closeTo(0.0, 5.0)); + }); + + test('a descent counts as loss, not gain', () { + final points = List.generate( + 101, + (i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1100.0 - i), + ); + final summary = computeSummary(points); + expect(summary.elevationLossM, closeTo(100.0, 5.0)); + expect(summary.elevationGainM, closeTo(0.0, 5.0)); + }); + + test('an out-and-back records both gain and loss', () { + final up = List.generate(51, (i) => point(ts: i * 1000, alt: 1000.0 + i)); + final down = + List.generate(51, (i) => point(ts: 51000 + i * 1000, alt: 1050.0 - i)); + final summary = computeSummary([...up, ...down]); + expect(summary.elevationGainM, closeTo(50.0, 5.0)); + expect(summary.elevationLossM, closeTo(50.0, 5.0)); + }); + }); + + group('moving vs elapsed time', () { + test('moving time excludes time below the speed noise floor', () { + final moving = List.generate(60, (i) => point(ts: i * 1000, speed: 40.0)); + final stopped = + List.generate(60, (i) => point(ts: (60 + i) * 1000, speed: 0.0)); + + final summary = computeSummary([...moving, ...stopped]); + + expect(summary.movingMillis.toDouble(), closeTo(59000.0, 2000.0), + reason: '~59 s of movement'); + expect(summary.elapsedMillis, 119000); + expect(summary.stoppedMillis, greaterThan(55000)); + }); + + test('a long gap between fixes does not inject phantom moving time', () { + // Two fixes two minutes apart — a tunnel. Without the cap this would count as + // 120 s of movement at the last known speed. + final points = [ + point(ts: 0, speed: 90.0), + point(ts: 120000, lat: 51.001, speed: 90.0), + ]; + expect(computeSummary(points).movingMillis, 0); + }); + + test('elapsed time prefers closed segment spans when available', () { + final points = straightRun(5, startTs: 1000); + const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 10000)]; + expect(computeSummary(points, segments: segments).elapsedMillis, 10000); + }); + + test('elapsed falls back to point timestamps when a segment is still open', () { + final points = straightRun(5, startTs: 1000); // 1000..5000 + const segments = [Segment(id: 1, tripId: 1, startedAt: 0)]; + expect(computeSummary(points, segments: segments).elapsedMillis, 4000); + }); + }); + + group('average speed', () { + test('average speed uses distance over moving time, not the sample mean', () { + // 100 fixes 1 s apart, each 0.0001 deg (~11.12 m) => ~1101 m over ~99 s + // => ~40 km/h. + final points = List.generate( + 100, + (i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, speed: 40.0), + ); + final summary = computeSummary(points); + expect(summary.avgMovingSpeedKmh, closeTo(40.0, 2.0)); + }); + + test('average speed is zero rather than NaN when nothing moved', () { + final points = List.generate(10, (i) => point(ts: i * 1000, speed: 0.0)); + final summary = computeSummary(points); + expect(summary.avgMovingSpeedKmh, closeTo(0.0, 1e-9)); + expect(summary.avgMovingSpeedKmh.isNaN, isFalse, + reason: 'NaN would render literally on screen'); + }); + + test('max speed is the highest sample', () { + final points = [ + point(ts: 0, speed: 40.0), + point(ts: 1000, speed: 118.4), + point(ts: 2000, speed: 60.0), + ]; + expect(computeSummary(points).maxSpeedKmh, closeTo(118.4, 0.001)); + }); + }); + + group('histogram', () { + test('histogram measures time in band, not sample count', () { + final points = [ + point(ts: 0, speed: 5.0), + point(ts: 1000, speed: 15.0), // 1 s in 10-20 + point(ts: 2000, speed: 15.0), // 1 s in 10-20 + point(ts: 3000, speed: 95.0), // 1 s in 90-100 + ]; + final buckets = speedHistogram(points, bucketKmh: 10); + + expect(buckets.firstWhere((b) => b.fromKmh == 10).millis, 2000); + expect(buckets.firstWhere((b) => b.fromKmh == 90).millis, 1000); + }); + + test('histogram is empty for degenerate input', () { + expect(speedHistogram(const []), isEmpty); + expect(speedHistogram([point(ts: 0)]), isEmpty); + }); + }); + + group('elevation profile', () { + test('profile plots altitude against cumulative distance', () { + final points = List.generate( + 5, + (i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1000.0 + i * 10), + ); + final profile = elevationProfile(points); + + expect(profile.length, 5); + expect(profile.first.distanceM, closeTo(0.0, 1e-9)); + expect(profile.last.altitudeM, closeTo(1040.0, 1e-9)); + expect(profile.last.distanceM, greaterThan(profile.first.distanceM), + reason: 'distance must increase'); + }); + + test('profile does not accrue distance across a pause', () { + final first = List.generate( + 3, (i) => point(segmentId: 1, ts: i * 1000, lat: 51.0 + i * 0.0001)); + final second = List.generate( + 3, + (i) => point(segmentId: 2, ts: 600000 + i * 1000, lat: 52.0 + i * 0.0001), + ); + final profile = elevationProfile([...first, ...second]); + expect(profile.last.distanceM, lessThan(200.0), + reason: + 'the 111 km gap leaked into the profile: ${profile.last.distanceM}'); + }); + + test('profile downsamples a long ride', () { + final points = List.generate( + 21600, + (i) => point(ts: i * 500, lat: 51.0 + i * 0.00001, alt: 1000.0), + ); + final profile = elevationProfile(points, maxSamples: 200); + expect(profile.length, lessThanOrEqualTo(201), + reason: 'expected ~200 samples, got ${profile.length}'); + expect(profile.last.altitudeM, closeTo(points.last.altitudeM, 1e-9)); + }); + + test('profile handles empty and single-point input', () { + expect(elevationProfile(const []), isEmpty); + expect(elevationProfile([point(ts: 0)]).length, 1); + }); + }); +} diff --git a/test/telemetry_test.dart b/test/telemetry_test.dart new file mode 100644 index 0000000..5154a3c --- /dev/null +++ b/test/telemetry_test.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/telemetry/telemetry.dart'; + +/// Ported from `com.rippr.TelemetryTest`. +/// +/// Tolerances are carried over as-is. Note the speed assertions: Kotlin's `Float` +/// widened to `Double` yields 88.5 only within 1e-4, which is exactly why the original +/// used that tolerance. Dart's uniform binary64 is exact here, but the tolerance is kept +/// so the two suites stay comparable line for line. +void main() { + test('converts meters per second to kmh', () { + expect(msToKmh(10), closeTo(36, 0.001)); + expect(msToKmh(0), closeTo(0, 0.001)); + }); + + test('gps jitter below the noise floor reports as zero', () { + expect(sanitizeSpeedKmh(0.9), closeTo(0, 0.001)); + expect(sanitizeSpeedKmh(double.nan), closeTo(0, 0.001)); + expect(sanitizeSpeedKmh(-5), closeTo(0, 0.001)); + }); + + test('real speeds pass through untouched', () { + expect(sanitizeSpeedKmh(97.3), closeTo(97.3, 0.001)); + }); + + test('rejects fixes worse than the accuracy budget', () { + expect(isUsableFix(12), isTrue); + expect(isUsableFix(0), isTrue, + reason: 'unknown accuracy must not be discarded'); + expect(isUsableFix(120), isFalse); + }); + + test('formats duration as hh mm ss', () { + expect(formatDuration(0), '00:00:00'); + expect(formatDuration(-1), '00:00:00'); + expect(formatDuration(65000), '00:01:05'); + expect(formatDuration(7384000), '02:03:04'); + }); + + test('encodes a batch as the documented payload shape', () { + final points = [ + const TrackPoint( + id: 7, + tripId: 3, + segmentId: 5, + timestamp: 1700000000000, + latitude: 51.0447, + longitude: -114.0719, + speedKmh: 88.5, + altitudeM: 1045.0, + accuracyM: 4.2, + bearingDeg: 271.5, + ), + ]; + + final json = + jsonDecode(encodeBatch('device-abc', points)) as Map; + + expect(json['device_id'], 'device-abc'); + final first = (json['points'] as List).first as Map; + expect(first['id'], 7); + expect(first['trip_id'], 3); + expect(first['segment_id'], 5); + expect(first['ts'], 1700000000000); + expect(first['lat'] as double, closeTo(51.0447, 1e-6)); + expect(first['lon'] as double, closeTo(-114.0719, 1e-6)); + expect(first['speed_kmh'] as double, closeTo(88.5, 1e-4)); + }); + + test('a batch spanning two segments labels each point individually', () { + // A batch is drawn by id and can cross a boundary, so identity must travel with + // the point rather than the batch. + final points = [ + const TrackPoint( + id: 1, + tripId: 3, + segmentId: 5, + timestamp: 1, + latitude: 51.0, + longitude: -114.0, + speedKmh: 40, + altitudeM: 1000.0), + const TrackPoint( + id: 2, + tripId: 3, + segmentId: 6, + timestamp: 2, + latitude: 51.1, + longitude: -114.0, + speedKmh: 40, + altitudeM: 1000.0), + ]; + + final array = + (jsonDecode(encodeBatch('d', points)) as Map)['points'] + as List; + + expect((array[0] as Map)['segment_id'], 5); + expect((array[1] as Map)['segment_id'], 6); + expect((array[0] as Map)['trip_id'], 3); + }); + + test('encodes an empty batch without failing', () { + final json = + jsonDecode(encodeBatch('d', const [])) as Map; + expect((json['points'] as List).length, 0); + }); +} diff --git a/tool/parity/main.kt b/tool/parity/main.kt new file mode 100644 index 0000000..6b4868e --- /dev/null +++ b/tool/parity/main.kt @@ -0,0 +1,59 @@ +import com.rippr.data.TrackPoint +import com.rippr.data.Segment +import com.rippr.geo.Geo +import com.rippr.geo.LatLon +import com.rippr.stats.RideStatistics + +// A deterministic LCG implemented identically in Kotlin and Dart, so both languages +// see the SAME noise sequence. Neither language's built-in Random can do this. +class Lcg(private var s: Long) { + fun nextDouble(): Double { + s = s * 6364136223846793005L + 1442695040888963407L + val bits = (s ushr 11) and ((1L shl 53) - 1L) + return bits.toDouble() / (1L shl 53).toDouble() + } +} + +fun p(seg: Long, ts: Long, lat: Double, lon: Double, sp: Float, alt: Double, id: Long) = + TrackPoint(id = id, tripId = 1, segmentId = seg, timestamp = ts, + latitude = lat, longitude = lon, speedKmh = sp, altitudeM = alt) + +fun main() { + fun out(k: String, v: Any) = println("$k=$v") + + out("haversine_calgary_edmonton", Geo.haversineMeters(51.0447, -114.0719, 53.5461, -113.4938)) + out("haversine_short_hop", Geo.haversineMeters(51.0447, -114.0719, 51.04480, -114.0719)) + out("perp_beyond_end", Geo.perpendicularDistanceMeters( + LatLon(51.003, -114.0), LatLon(51.000, -114.0), LatLon(51.002, -114.0))) + + val ride = (0 until 21_600).map { LatLon(51.0 + it * 0.00001, -114.0 + Math.sin(it / 100.0) * 0.001) } + val simplified = Geo.simplify(ride, 5.0) + out("simplify_count", simplified.size) + out("simplify_last_lat", simplified.last().lat) + out("path_length_ride", Geo.pathLengthMeters(ride)) + + // The disputed fixture: 600 stationary fixes with shared LCG +/-8 m altitude noise. + val rng = Lcg(42L) + val noisy = (0 until 600).map { + p(1, it * 1000L, 51.0, -114.0, 0f, 1000.0 + (rng.nextDouble() * 16.0 - 8.0), (it + 1).toLong()) + } + out("noisy_gain", RideStatistics.compute(noisy).elevationGainM) + out("noisy_loss", RideStatistics.compute(noisy).elevationLossM) + + val climb = (0 until 101).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 50f, 1000.0 + it, (it + 1).toLong()) } + out("climb_gain", RideStatistics.compute(climb).elevationGainM) + + val run = (0 until 100).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 40f, 1000.0, (it + 1).toLong()) } + val s = RideStatistics.compute(run) + out("run_distance", s.distanceM) + out("run_moving_millis", s.movingMillis) + out("run_avg_speed", s.avgMovingSpeedKmh) + + val prof = RideStatistics.elevationProfile(run, 200) + out("profile_size", prof.size) + out("profile_last_distance", prof.last().distanceM) + + val hist = RideStatistics.speedHistogram(run, 10) + out("hist_buckets", hist.size) + out("hist_first_millis", hist.first().millis) +} diff --git a/tool/parity/probe.dart b/tool/parity/probe.dart new file mode 100644 index 0000000..8eee076 --- /dev/null +++ b/tool/parity/probe.dart @@ -0,0 +1,64 @@ +// Dart side of the cross-language parity harness (T07). +// Must stay fixture-for-fixture identical to tool/parity/main.kt. +import 'dart:math' as math; + +import 'package:rippr/src/domain/models.dart'; +import 'package:rippr/src/geo/geo.dart'; +import 'package:rippr/src/stats/ride_statistics.dart'; + +/// Same LCG as the Kotlin oracle. Dart ints are 64-bit two's complement on the VM and +/// multiplication wraps, matching Kotlin's Long. +class Lcg { + Lcg(this._s); + int _s; + double nextDouble() { + _s = _s * 6364136223846793005 + 1442695040888963407; + final bits = (_s >>> 11) & ((1 << 53) - 1); + return bits / (1 << 53); + } +} + +TrackPoint p(int seg, int ts, double lat, double lon, double sp, double alt, int id) => + TrackPoint(id: id, tripId: 1, segmentId: seg, timestamp: ts, + latitude: lat, longitude: lon, speedKmh: sp, altitudeM: alt); + +void main() { + void out(String k, Object v) => print('$k=$v'); + + out('haversine_calgary_edmonton', haversineMeters(51.0447, -114.0719, 53.5461, -113.4938)); + out('haversine_short_hop', haversineMeters(51.0447, -114.0719, 51.04480, -114.0719)); + out('perp_beyond_end', perpendicularDistanceMeters( + const LatLon(51.003, -114.0), const LatLon(51.000, -114.0), const LatLon(51.002, -114.0))); + + final ride = List.generate(21600, + (i) => LatLon(51.0 + i * 0.00001, -114.0 + math.sin(i / 100.0) * 0.001)); + final simplified = simplify(ride, 5.0); + out('simplify_count', simplified.length); + out('simplify_last_lat', simplified.last.lat); + out('path_length_ride', pathLengthMeters(ride)); + + final rng = Lcg(42); + final noisy = List.generate(600, + (i) => p(1, i * 1000, 51.0, -114.0, 0.0, 1000.0 + (rng.nextDouble() * 16.0 - 8.0), i + 1)); + out('noisy_gain', computeSummary(noisy).elevationGainM); + out('noisy_loss', computeSummary(noisy).elevationLossM); + + final climb = List.generate(101, + (i) => p(1, i * 1000, 51.0 + i * 0.0001, -114.0, 50.0, 1000.0 + i, i + 1)); + out('climb_gain', computeSummary(climb).elevationGainM); + + final run = List.generate(100, + (i) => p(1, i * 1000, 51.0 + i * 0.0001, -114.0, 40.0, 1000.0, i + 1)); + final s = computeSummary(run); + out('run_distance', s.distanceM); + out('run_moving_millis', s.movingMillis); + out('run_avg_speed', s.avgMovingSpeedKmh); + + final prof = elevationProfile(run, maxSamples: 200); + out('profile_size', prof.length); + out('profile_last_distance', prof.last.distanceM); + + final hist = speedHistogram(run, bucketKmh: 10); + out('hist_buckets', hist.length); + out('hist_first_millis', hist.first.millis); +} diff --git a/tool/parity/run.sh b/tool/parity/run.sh new file mode 100755 index 0000000..d5b0cdb --- /dev/null +++ b/tool/parity/run.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Cross-language parity harness (T07). +# +# Drives the SAME fixtures through the native Kotlin implementation and the Dart port, +# and diffs the output. This is the port's strongest correctness guarantee: it proved the +# elevation accumulator is bit-identical across both languages. +# +# Requires: kotlinc (brew install kotlin) and a JDK 17-21. +# +# NOTE: never redirect the build to /dev/null. A v2 sweep once returned four identical +# results because a quoting bug corrupted the source while the compile error hid behind +# a redirect. If every value looks suspiciously equal, suspect the harness first. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +NATIVE="${RIPPR_NATIVE:-$HOME/dojo/rippr}" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +if [ ! -d "$NATIVE" ]; then + echo "Native repo not found at $NATIVE. Set RIPPR_NATIVE." >&2 + exit 1 +fi + +# The files actually under test are copied verbatim from the native repo — never +# reimplemented. Only the Room-annotated holders and one Telemetry constant are stubbed, +# so the algorithm files compile without androidx or org.json on the classpath. +cp "$NATIVE/app/src/main/java/com/rippr/geo/Geo.kt" "$WORK/" +cp "$NATIVE/app/src/main/java/com/rippr/stats/RideStatistics.kt" "$WORK/" +cp "$HERE/stubs.kt" "$HERE/telemetry_stub.kt" "$HERE/main.kt" "$WORK/" + +echo "== compiling Kotlin oracle ==" +( cd "$WORK" && kotlinc Geo.kt RideStatistics.kt stubs.kt telemetry_stub.kt main.kt \ + -include-runtime -d parity.jar 2>&1 | grep -v '^warning:' || true ) + +JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java" +"$JAVA_BIN" -jar "$WORK/parity.jar" | grep '=' | sort > "$WORK/kotlin.txt" + +echo "== running Dart side ==" +( cd "$HERE/../.." && dart run tool/parity/probe.dart ) | grep '=' | sort > "$WORK/dart.txt" + +echo "== diff ==" +if diff -u "$WORK/kotlin.txt" "$WORK/dart.txt"; then + echo "PARITY OK - byte-identical on every key" +else + echo + echo "Differences above. Expected and accepted: run_avg_speed and any other" + echo "speed-derived value, where Kotlin's 32-bit Float widens to double with" + echo "artefacts Dart's uniform binary64 does not reproduce. Anything else is a bug." + exit 1 +fi diff --git a/tool/parity/stubs.kt b/tool/parity/stubs.kt new file mode 100644 index 0000000..bda930c --- /dev/null +++ b/tool/parity/stubs.kt @@ -0,0 +1,10 @@ +package com.rippr.data +data class TrackPoint( + val id: Long = 0, val tripId: Long, val segmentId: Long, val timestamp: Long, + val latitude: Double, val longitude: Double, val speedKmh: Float, + val altitudeM: Double, val accuracyM: Float = 0f, val bearingDeg: Float = 0f, + val synced: Boolean = false, +) +data class Segment( + val id: Long = 0, val tripId: Long, val startedAt: Long, val endedAt: Long? = null, +) { val isOpen: Boolean get() = endedAt == null } diff --git a/tool/parity/telemetry_stub.kt b/tool/parity/telemetry_stub.kt new file mode 100644 index 0000000..5a83374 --- /dev/null +++ b/tool/parity/telemetry_stub.kt @@ -0,0 +1,2 @@ +package com.rippr +object Telemetry { const val SPEED_NOISE_FLOOR_KMH = 1.5f }