fix(telemetry): address GNSS freshness review findings

This commit is contained in:
root 2026-09-19 16:32:45 +00:00
parent 22930e7e3a
commit 6d4c5daa0b
5 changed files with 177 additions and 6 deletions

View File

@ -37,7 +37,7 @@
6. **UI Display (`lib/features/dashboard/widgets/speed_readout.dart`)** 6. **UI Display (`lib/features/dashboard/widgets/speed_readout.dart`)**
- Rebuilding the entire dashboard at 20Hz would cause severe UI lag. - Rebuilding the entire dashboard at 20Hz would cause severe UI lag.
- `ResponsiveDashboardLayout` isolates updates using the `SpeedReadout` leaf widget. - `ResponsiveDashboardLayout` isolates updates using the `SpeedReadout` leaf widget.
- `SpeedReadout` watches `telemetryStreamProvider.select((s) => s.value?.speed ?? 0.0)`. - `SpeedReadout` selects freshness-aware `sample.freshSpeed` from `telemetryStreamProvider`; unavailable/stale speed renders as `--`.
- Clamps the value to `>= 0.0`, converts to an integer string (`toStringAsFixed(0)`), and renders. - Clamps the value to `>= 0.0`, converts to an integer string (`toStringAsFixed(0)`), and renders.
## C. Discoveries (Current implementation speeds) ## C. Discoveries (Current implementation speeds)

View File

@ -77,17 +77,22 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
void _onTelemetryUpdate(TelemetrySample sample) { void _onTelemetryUpdate(TelemetrySample sample) {
if (sample.speed == null) return; if (sample.speed == null) return;
// Reject corrupted or impossible samples
if (sample.quality != null && sample.quality!.isRejected) return;
// Reject stale GPS samples from mutating performance runs or launching // Reject stale GPS samples from mutating performance runs or launching
if (!sample.isSpeedFresh) { if (!sample.isSpeedFresh) {
// A recovered moving fix cannot establish when the launch occurred.
_lastSample = null;
if (state.state == PerformanceState.ready) {
state = state.copyWith(state: PerformanceState.idle);
}
if (state.state == PerformanceState.timing) { if (state.state == PerformanceState.timing) {
state = state.copyWith(state: PerformanceState.aborted); state = state.copyWith(state: PerformanceState.aborted);
} }
return; return;
} }
// Reject corrupted or impossible samples
if (sample.quality != null && sample.quality!.isRejected) return;
final speedMph = sample.speed!; final speedMph = sample.speed!;
if (state.state == PerformanceState.ready || state.state == PerformanceState.idle) { if (state.state == PerformanceState.ready || state.state == PerformanceState.idle) {
@ -95,7 +100,8 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
if (state.state != PerformanceState.ready) { if (state.state != PerformanceState.ready) {
state = state.copyWith(state: PerformanceState.ready); state = state.copyWith(state: PerformanceState.ready);
} }
} else if (speedMph >= _speedThresholdMph) { } else if (speedMph >= _speedThresholdMph &&
state.state == PerformanceState.ready) {
// Start timing immediately when motorcycle launches past 1.0 MPH // Start timing immediately when motorcycle launches past 1.0 MPH
// Interpolate the exact start time between the last frame (stopped) and this frame // Interpolate the exact start time between the last frame (stopped) and this frame
_startTime = _interpolateTime( _startTime = _interpolateTime(

View File

@ -106,7 +106,7 @@ class TelemetrySample {
/// fresh according to the canonical evidence staleness bound (4000 ms). /// fresh according to the canonical evidence staleness bound (4000 ms).
/// ///
/// Returns false when: /// Returns false when:
/// - [latitude] or [longitude] is null (no GPS fix available) /// - [latitude] or [longitude] is absent, non-finite, or out of range
/// - [gpsAgeMs] exceeds [RecordingHealthContract.evidenceStaleAfterMs] (4000 ms) /// - [gpsAgeMs] exceeds [RecordingHealthContract.evidenceStaleAfterMs] (4000 ms)
/// - [gpsTimestamp] age exceeds [RecordingHealthContract.evidenceStaleAfterMs] relative to [timestamp] /// - [gpsTimestamp] age exceeds [RecordingHealthContract.evidenceStaleAfterMs] relative to [timestamp]
/// ///
@ -115,6 +115,11 @@ class TelemetrySample {
/// - Intermediate 10 Hz / 20 Hz sample repetitions within the 4000 ms bound /// - Intermediate 10 Hz / 20 Hz sample repetitions within the 4000 ms bound
/// - Synthetic test samples where GPS metadata was not explicitly attached /// - Synthetic test samples where GPS metadata was not explicitly attached
bool get isGpsFresh { bool get isGpsFresh {
if (latitude == null || longitude == null ||
!latitude!.isFinite || !longitude!.isFinite ||
latitude!.abs() > 90 || longitude!.abs() > 180) {
return false;
}
if (gpsAgeMs != null) { if (gpsAgeMs != null) {
return gpsAgeMs! >= 0 && return gpsAgeMs! >= 0 &&
gpsAgeMs! <= RecordingHealthContract.evidenceStaleAfterMs; gpsAgeMs! <= RecordingHealthContract.evidenceStaleAfterMs;

View File

@ -226,6 +226,8 @@ void main() {
leanAngle: 24.5, leanAngle: 24.5,
longitudinalG: 0.42, longitudinalG: 0.42,
lateralG: 0.35, lateralG: 0.35,
latitude: 37.7749,
longitude: -122.4194,
gpsAccuracy: 9.0, gpsAccuracy: 9.0,
), ),
); );

View File

@ -0,0 +1,158 @@
import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample;
import 'package:motorcycle_dash/features/performance/performance_timer_service.dart';
import 'package:motorcycle_dash/models/telemetry_sample.dart';
void main() {
final base = DateTime.utc(2026, 9, 19);
test(
'normal READY launch retains interpolation fallback and null-speed handling',
() async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final controller = StreamController<TelemetrySample>(sync: true);
final timer = PerformanceTimerService(db, controller.stream);
addTearDown(() async {
timer.dispose();
await controller.close();
await db.close();
});
timer.setTarget(PerformanceTarget.sixty);
for (final age in <int?>[null, 0, 4001]) {
controller.add(TelemetrySample(timestamp: base, gpsAgeMs: age));
expect(timer.state.state, PerformanceState.ready);
}
// No previous speed sample: launch time falls back to this timestamp.
controller.add(TelemetrySample(timestamp: base, speed: 5, gpsAgeMs: 0));
expect(timer.state.state, PerformanceState.timing);
for (final age in <int?>[null, 0, 4001]) {
controller.add(
TelemetrySample(
timestamp: base.add(const Duration(milliseconds: 100)),
gpsAgeMs: age,
),
);
expect(timer.state.state, PerformanceState.timing);
}
controller.add(
TelemetrySample(
timestamp: base.add(const Duration(seconds: 3)),
speed: 60,
gpsAgeMs: 0,
),
);
expect(timer.state.state, PerformanceState.completed);
final runs = await db.select(db.performanceRuns).get();
expect(runs, hasLength(1));
expect(runs.single.timeMs, 3000);
},
);
test('stale READY requires new stationary evidence before launch', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
final controller = StreamController<TelemetrySample>(sync: true);
final timer = PerformanceTimerService(db, controller.stream);
addTearDown(() async {
timer.dispose();
await controller.close();
await db.close();
});
void emit(int ms, double speed, int age) {
controller.add(
TelemetrySample(
timestamp: base.add(Duration(milliseconds: ms)),
speed: speed,
gpsAgeMs: age,
),
);
}
timer.setTarget(PerformanceTarget.sixty);
emit(0, 0, 0);
expect(timer.state.state, PerformanceState.ready);
emit(4000, 0, 4000);
expect(timer.state.state, PerformanceState.ready);
emit(4001, 0, 4001);
expect(timer.state.state, PerformanceState.idle);
emit(5000, 30, 5000);
emit(6000, 40, 0);
expect(timer.state.state, PerformanceState.idle);
emit(9000, 65, 0);
expect(timer.state.state, PerformanceState.idle);
expect(await db.select(db.performanceRuns).get(), isEmpty);
emit(10000, 0, 0);
expect(timer.state.state, PerformanceState.ready);
emit(10200, 5, 0);
expect(timer.state.state, PerformanceState.timing);
emit(13000, 65, 0);
expect(timer.state.state, PerformanceState.completed);
final runs = await db.select(db.performanceRuns).get();
expect(runs, hasLength(1));
expect(runs.single.timeMs, closeTo(2727, 1));
});
for (final coordinates in <(double?, double?)>[
(null, null),
(null, 0),
(0, null),
(double.nan, 0),
(0, double.nan),
(double.infinity, 0),
(0, double.negativeInfinity),
(90.1, 0),
(-90.1, 0),
(0, 180.1),
(0, -180.1),
]) {
for (final metadata in ['absent', 'age', 'timestamp']) {
test('invalid coordinates $coordinates with $metadata are not fresh', () {
expect(
TelemetrySample(
timestamp: base,
latitude: coordinates.$1,
longitude: coordinates.$2,
gpsAgeMs: metadata == 'age' ? 0 : null,
gpsTimestamp: metadata == 'timestamp' ? base : null,
).isGpsFresh,
isFalse,
);
});
}
}
for (final age in [3999, 4000, 4001]) {
for (final useAge in [true, false]) {
test('GPS boundary $age with explicit age $useAge', () {
expect(
TelemetrySample(
timestamp: base,
latitude: 0,
longitude: 0,
gpsAgeMs: useAge ? age : null,
gpsTimestamp: useAge
? null
: base.subtract(Duration(milliseconds: age)),
).isGpsFresh,
age <= 4000,
);
});
}
}
test('valid coordinate limits retain metadata-free compatibility', () {
for (final coordinates in [(90.0, 180.0), (-90.0, -180.0), (0.0, 0.0)]) {
expect(
TelemetrySample(
timestamp: base,
latitude: coordinates.$1,
longitude: coordinates.$2,
).isGpsFresh,
isTrue,
);
}
});
}