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;
|
||||
}
|
||||
Reference in New Issue
Block a user