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>
This commit is contained in:
204
lib/src/domain/models.dart
Normal file
204
lib/src/domain/models.dart
Normal file
@@ -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 `<trkseg>`
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
385
lib/src/stats/ride_statistics.dart
Normal file
385
lib/src/stats/ride_statistics.dart
Normal file
@@ -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<int, List<TrackPoint>> _groupBySegment(List<TrackPoint> points) {
|
||||||
|
final grouped = LinkedHashMap<int, List<TrackPoint>>();
|
||||||
|
for (final p in points) {
|
||||||
|
(grouped[p.segmentId] ??= <TrackPoint>[]).add(p);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
RideSummary computeSummary(
|
||||||
|
List<TrackPoint> points, {
|
||||||
|
List<Segment> 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<TrackPoint> points, List<Segment> segments) {
|
||||||
|
final closed = <int>[
|
||||||
|
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<SpeedBucket> speedHistogram(List<TrackPoint> points, {int bucketKmh = 10}) {
|
||||||
|
if (points.length < 2 || bucketKmh <= 0) return const [];
|
||||||
|
|
||||||
|
final millisByBucket = <int, int>{};
|
||||||
|
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<ElevationSample> elevationProfile(
|
||||||
|
List<TrackPoint> points, {
|
||||||
|
int maxSamples = 200,
|
||||||
|
}) {
|
||||||
|
if (points.isEmpty) return const [];
|
||||||
|
if (points.length == 1) {
|
||||||
|
return [ElevationSample(0.0, points[0].altitudeM)];
|
||||||
|
}
|
||||||
|
|
||||||
|
final full = <ElevationSample>[];
|
||||||
|
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>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
lib/src/telemetry/live_telemetry.dart
Normal file
80
lib/src/telemetry/live_telemetry.dart
Normal file
@@ -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<T> {
|
||||||
|
_ValueStream(this._value);
|
||||||
|
|
||||||
|
final _controller = StreamController<T>.broadcast();
|
||||||
|
T _value;
|
||||||
|
|
||||||
|
T get value => _value;
|
||||||
|
|
||||||
|
Stream<T> get stream => _controller.stream;
|
||||||
|
|
||||||
|
void set(T next) {
|
||||||
|
_value = next;
|
||||||
|
if (!_controller.isClosed) _controller.add(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<double>(0.0);
|
||||||
|
final _accuracyM = _ValueStream<double>(0.0);
|
||||||
|
|
||||||
|
double get speedKmh => _speedKmh.value;
|
||||||
|
double get accuracyM => _accuracyM.value;
|
||||||
|
|
||||||
|
Stream<double> get speedStream => _speedKmh.stream;
|
||||||
|
Stream<double> 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<String?>(null);
|
||||||
|
|
||||||
|
String? get lastError => _lastError.value;
|
||||||
|
|
||||||
|
Stream<String?> get stream => _lastError.stream;
|
||||||
|
|
||||||
|
void setError(String? message) => _lastError.set(message);
|
||||||
|
}
|
||||||
63
lib/src/telemetry/telemetry.dart
Normal file
63
lib/src/telemetry/telemetry.dart
Normal file
@@ -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<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,
|
||||||
|
});
|
||||||
|
}
|
||||||
35
lib/src/ui/format.dart
Normal file
35
lib/src/ui/format.dart
Normal file
@@ -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';
|
||||||
@@ -472,7 +472,7 @@ packages:
|
|||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: intl
|
name: intl
|
||||||
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
synchronized: ^3.4.1+1
|
synchronized: ^3.4.1+1
|
||||||
|
intl: ^0.20.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
integration_test:
|
integration_test:
|
||||||
|
|||||||
302
test/ride_statistics_test.dart
Normal file
302
test/ride_statistics_test.dart
Normal file
@@ -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<TrackPoint> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
111
test/telemetry_test.dart
Normal file
111
test/telemetry_test.dart
Normal file
@@ -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<String, dynamic>;
|
||||||
|
|
||||||
|
expect(json['device_id'], 'device-abc');
|
||||||
|
final first = (json['points'] as List).first as Map<String, dynamic>;
|
||||||
|
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<String, dynamic>)['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<String, dynamic>;
|
||||||
|
expect((json['points'] as List).length, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
59
tool/parity/main.kt
Normal file
59
tool/parity/main.kt
Normal file
@@ -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)
|
||||||
|
}
|
||||||
64
tool/parity/probe.dart
Normal file
64
tool/parity/probe.dart
Normal file
@@ -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);
|
||||||
|
}
|
||||||
51
tool/parity/run.sh
Executable file
51
tool/parity/run.sh
Executable file
@@ -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
|
||||||
10
tool/parity/stubs.kt
Normal file
10
tool/parity/stubs.kt
Normal file
@@ -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 }
|
||||||
2
tool/parity/telemetry_stub.kt
Normal file
2
tool/parity/telemetry_stub.kt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
package com.rippr
|
||||||
|
object Telemetry { const val SPEED_NOISE_FLOOR_KMH = 1.5f }
|
||||||
Reference in New Issue
Block a user