fix(telemetry): stabilize 20Hz clock driver, fix moving time accumulator & prevent derivative spikes

This commit is contained in:
Developer 2026-08-28 17:14:31 -04:00
parent 64dc635d68
commit 38ae22e19e
12 changed files with 513190 additions and 59 deletions

BIN
field_test_export.zip Normal file

Binary file not shown.

View File

@ -0,0 +1,29 @@
=====================================================
MOTORCYCLE DASH — FORENSIC FIELD TEST SESSION REPORT
=====================================================
Application Version: 1.0.0
Build Number: 100
Git Revision: e700ca64fdb6365d30a76d81236c912a730bfad2
Git Branch: master
Release Channel: release
Telemetry Schema Version: 5
Export Format Version: 1
Build Timestamp: 2026-08-28T05:00:00Z
Field Test Session / Ride ID: 2
Session Start: 2026-08-28T14:30:56.000
Session End: 2026-08-28T14:38:04.000
Export Timestamp: 2026-08-28T17:01:07.396703
Summary Result: POOR
Ride Quality Score: 51 / 100
Total Telemetry Samples: 34524
Total Forensic Events: 5482
Major Warnings Count: 5471
Major Errors Count: 3
=====================================================
Files Included:
- field_test_session.json : Full structured session dump with software identity
- field_test_events.csv : Standard 15-column forensic timeline
- telemetry.csv : 20Hz sensor sample trace
- telemetry.gpx : GPS trace with lean/speed extensions
- ride_summary.json : High-level ride performance stats
- README.txt : This audit header

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"rideId": 2,
"startTime": "2026-08-28T14:30:56.000",
"endTime": "2026-08-28T14:38:04.000",
"distanceMiles": 8.731763512731801,
"topSpeedMph": 96.60996890916071,
"maxLeanLeft": 12.298980710271698,
"maxLeanRight": 29.149395821947344,
"maxAccelG": 0.5371292156727665,
"maxBrakingG": 1.04340023436231,
"averageSpeed": 0.0,
"movingTime": 0,
"stoppedTime": 0,
"qualityScore": 51,
"qualityRating": "POOR",
"software": {
"version": "1.0.0",
"build": 100,
"gitCommit": "e700ca64fdb6365d30a76d81236c912a730bfad2",
"gitBranch": "master",
"releaseChannel": "release",
"telemetrySchemaVersion": 5,
"exportSchemaVersion": 1,
"buildTimestamp": "2026-08-28T05:00:00Z"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -108,6 +108,8 @@ class RideStateMachine extends StateNotifier<RideSession> {
TelemetrySample? _lastSample; TelemetrySample? _lastSample;
double _accumulatedDistance = 0; // meters internally double _accumulatedDistance = 0; // meters internally
double _movingTimeAccumulator = 0.0; // seconds internally
double _stoppedTimeAccumulator = 0.0; // seconds internally
// Batching // Batching
final List<TelemetrySamplesCompanion> _telemetryBatch = []; final List<TelemetrySamplesCompanion> _telemetryBatch = [];
@ -234,6 +236,8 @@ class RideStateMachine extends StateNotifier<RideSession> {
); );
_lastSample = null; _lastSample = null;
_accumulatedDistance = 0; _accumulatedDistance = 0;
_movingTimeAccumulator = 0.0;
_stoppedTimeAccumulator = 0.0;
_lastTelemetryReceivedTime = DateTime.now(); _lastTelemetryReceivedTime = DateTime.now();
try { try {
@ -514,20 +518,17 @@ class RideStateMachine extends StateNotifier<RideSession> {
} }
} }
// Better Time tracking logic (internal doubles) // Continuous sub-second time accumulation across 20Hz frames
double internalMoving = state.movingTimeSeconds.toDouble();
double internalStopped = state.stoppedTimeSeconds.toDouble();
if (_lastSample != null && sample.speed != null) { if (_lastSample != null && sample.speed != null) {
final dt = sample.timestamp.difference(_lastSample!.timestamp).inMilliseconds / 1000.0; final dt = sample.timestamp.difference(_lastSample!.timestamp).inMilliseconds / 1000.0;
if (dt > 0 && dt < 2.0) { if (dt > 0 && dt < 2.0) {
if (sample.speed! > 1.8) { if (sample.speed! > 1.8) {
internalMoving += dt; _movingTimeAccumulator += dt;
} else { } else {
internalStopped += dt; _stoppedTimeAccumulator += dt;
} }
newMovingTime = internalMoving.floor(); newMovingTime = _movingTimeAccumulator.floor();
newStoppedTime = internalStopped.floor(); newStoppedTime = _stoppedTimeAccumulator.floor();
} }
} }

View File

@ -143,9 +143,9 @@ class FieldTestLogger {
AppIdentity? _sessionIdentity; AppIdentity? _sessionIdentity;
AppIdentity get sessionIdentity => _sessionIdentity ?? AppIdentity.current(); AppIdentity get sessionIdentity => _sessionIdentity ?? AppIdentity.current();
void startSession(int rideId, {String? notes}) { void startSession(int rideId, {String? notes, DateTime? timestamp}) {
_activeRideId = rideId; _activeRideId = rideId;
_sessionStartTime = DateTime.now(); _sessionStartTime = timestamp ?? DateTime.now();
_sessionIdentity = AppIdentity.current(); _sessionIdentity = AppIdentity.current();
_timelineBuffer.clear(); _timelineBuffer.clear();
_eventBatch.clear(); _eventBatch.clear();
@ -156,6 +156,7 @@ class FieldTestLogger {
severity: EventSeverity.info, severity: EventSeverity.info,
message: 'Field test session started for ride #$rideId [v${sessionIdentity.displayString}]', message: 'Field test session started for ride #$rideId [v${sessionIdentity.displayString}]',
rideId: rideId, rideId: rideId,
timestamp: _sessionStartTime,
metadata: { metadata: {
'software': sessionIdentity.toJson(), 'software': sessionIdentity.toJson(),
'notes': ?notes, 'notes': ?notes,

View File

@ -78,7 +78,6 @@ class TelemetryService {
_locationSub = _locationService.getFilteredPositionStream().listen((pos) { _locationSub = _locationService.getFilteredPositionStream().listen((pos) {
_latestPosition = pos; _latestPosition = pos;
_emitSample();
}); });
_leanSub = _sensorFusionService.leanAngleStream.listen((lean) { _leanSub = _sensorFusionService.leanAngleStream.listen((lean) {
@ -94,19 +93,20 @@ class TelemetryService {
_gForceSub = _sensorFusionService.gForceStream.listen((gForce) { _gForceSub = _sensorFusionService.gForceStream.listen((gForce) {
_latestGForce = gForce; _latestGForce = gForce;
_latestImuTimestamp = DateTime.now(); _latestImuTimestamp = DateTime.now();
_emitSample();
}); });
_compassSub = _compassService.headingStream.listen((heading) { _compassSub = _compassService.headingStream.listen((heading) {
_latestCompassHeading = heading; _latestCompassHeading = heading;
}); });
// High-rate refresh at 20Hz (50ms) for ultra-fluid digital speedometer // High-rate refresh at strictly 20Hz (50ms) as the single authoritative telemetry clock
_samplingTimer = Timer.periodic(const Duration(milliseconds: 50), (_) { _samplingTimer = Timer.periodic(const Duration(milliseconds: 50), (_) {
_emitSample(); _emitSample();
}); });
} }
DateTime? _lastSuspectLogTime;
void _emitSample() { void _emitSample() {
final now = DateTime.now(); final now = DateTime.now();
final speedMph = _latestPosition != null ? _latestPosition!.speed * 2.23694 : 0.0; final speedMph = _latestPosition != null ? _latestPosition!.speed * 2.23694 : 0.0;
@ -142,16 +142,31 @@ class TelemetryService {
final quality = _validator.validate(rawSample); final quality = _validator.validate(rawSample);
final validatedSample = rawSample.copyWith(quality: quality); final validatedSample = rawSample.copyWith(quality: quality);
// Log diagnostic if sample is suspect or rejected during an active field test // Log diagnostic if sample is suspect or rejected during an active field test (throttled for suspect)
if (_fieldTestLogger.isSessionActive && (quality.isSuspect || quality.isRejected)) { if (_fieldTestLogger.isSessionActive) {
if (quality.isRejected) {
_fieldTestLogger.log( _fieldTestLogger.log(
eventType: quality.isRejected ? FieldTestEventType.sampleRejected : FieldTestEventType.sampleSuspect, eventType: FieldTestEventType.sampleRejected,
message: 'Sample validation ${quality.status.name}: ${quality.reasons.join(", ")}', message: 'Sample validation rejected: ${quality.reasons.join(", ")}',
severity: quality.isRejected ? EventSeverity.critical : EventSeverity.warning, severity: EventSeverity.critical,
telemetryTimestamp: rawSample.timestamp, telemetryTimestamp: rawSample.timestamp,
speed: speedMph, speed: speedMph,
overallConfidence: quality.overallConfidence, overallConfidence: quality.overallConfidence,
); );
} else if (quality.isSuspect) {
final shouldLogSuspect = _lastSuspectLogTime == null || now.difference(_lastSuspectLogTime!).inMilliseconds >= 2000;
if (shouldLogSuspect) {
_lastSuspectLogTime = now;
_fieldTestLogger.log(
eventType: FieldTestEventType.sampleSuspect,
message: 'Sample validation suspect: ${quality.reasons.join(", ")}',
severity: EventSeverity.warning,
telemetryTimestamp: rawSample.timestamp,
speed: speedMph,
overallConfidence: quality.overallConfidence,
);
}
}
} }
_telemetryController.add(validatedSample); _telemetryController.add(validatedSample);

View File

@ -52,6 +52,7 @@ final telemetryValidatorProvider = Provider<TelemetryValidator>((ref) {
class TelemetryValidator { class TelemetryValidator {
DateTime? _lastTimestamp; DateTime? _lastTimestamp;
DateTime? _lastGpsTimestamp;
double? _lastSpeedMph; double? _lastSpeedMph;
double? _lastLeanAngle; double? _lastLeanAngle;
double? _lastLat; double? _lastLat;
@ -72,6 +73,7 @@ class TelemetryValidator {
void reset() { void reset() {
_lastTimestamp = null; _lastTimestamp = null;
_lastGpsTimestamp = null;
_lastSpeedMph = null; _lastSpeedMph = null;
_lastLeanAngle = null; _lastLeanAngle = null;
_lastLat = null; _lastLat = null;
@ -150,7 +152,7 @@ class TelemetryValidator {
if (status == ValidationStatus.valid) status = ValidationStatus.suspect; if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
reasons.add('Stale GPS fix (${(sample.gpsAgeMs! / 1000).toStringAsFixed(1)}s old)'); reasons.add('Stale GPS fix (${(sample.gpsAgeMs! / 1000).toStringAsFixed(1)}s old)');
} }
} else if (sample.gpsAgeMs! > 1000) { } else if (sample.gpsAgeMs! > 1200) {
gpsConf = math.min(gpsConf, 0.40); gpsConf = math.min(gpsConf, 0.40);
if (currentSpeed > 10.0 && status == ValidationStatus.valid) { if (currentSpeed > 10.0 && status == ValidationStatus.valid) {
status = ValidationStatus.suspect; status = ValidationStatus.suspect;
@ -164,15 +166,21 @@ class TelemetryValidator {
} }
// GPS Position Teleportation Check // GPS Position Teleportation Check
if (_lastLat != null && _lastLng != null && sample.latitude != null && sample.longitude != null && deltaMs > 0 && deltaMs < 3000) { if (_lastLat != null && _lastLng != null && sample.latitude != null && sample.longitude != null) {
final dtSec = (sample.gpsTimestamp != null && _lastGpsTimestamp != null && sample.gpsTimestamp != _lastGpsTimestamp)
? sample.gpsTimestamp!.difference(_lastGpsTimestamp!).inMilliseconds / 1000.0
: (deltaMs > 0 ? deltaMs / 1000.0 : 0.05);
if (dtSec > 0.02 && dtSec < 5.0) {
final latDiff = (sample.latitude! - _lastLat!).abs() * 111139.0; final latDiff = (sample.latitude! - _lastLat!).abs() * 111139.0;
final lngDiff = (sample.longitude! - _lastLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0)); final lngDiff = (sample.longitude! - _lastLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0));
final distMeters = math.sqrt(latDiff * latDiff + lngDiff * lngDiff); final distMeters = math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
final derivedSpeedMps = distMeters / (deltaMs / 1000.0); final derivedSpeedMps = distMeters / dtSec;
if (derivedSpeedMps > 110.0 && distMeters > 50.0) { if (derivedSpeedMps > 110.0 && distMeters > 50.0) {
status = ValidationStatus.rejected; status = ValidationStatus.rejected;
gpsConf = 0.0; gpsConf = 0.0;
reasons.add('GPS position jump detected (${distMeters.toStringAsFixed(0)}m in ${deltaMs}ms)'); reasons.add('GPS position jump detected (${distMeters.toStringAsFixed(0)}m in ${(dtSec * 1000).toStringAsFixed(0)}ms)');
}
} }
} }
@ -182,22 +190,24 @@ class TelemetryValidator {
status = ValidationStatus.rejected; status = ValidationStatus.rejected;
speedConf = 0.0; speedConf = 0.0;
reasons.add('Impossible vehicle speed (${currentSpeed.toStringAsFixed(1)} MPH > 220 MPH)'); reasons.add('Impossible vehicle speed (${currentSpeed.toStringAsFixed(1)} MPH > 220 MPH)');
} else if (_lastSpeedMph != null && deltaMs > 0 && deltaMs < 2000) { } else if (_lastSpeedMph != null && deltaMs >= 40 && deltaMs < 2000) {
final deltaSpeedMph = (currentSpeed - _lastSpeedMph!).abs(); final deltaSpeedMph = (currentSpeed - _lastSpeedMph!).abs();
if (deltaSpeedMph >= 1.5) {
final dtSec = deltaMs / 1000.0; final dtSec = deltaMs / 1000.0;
final accelMps2 = (deltaSpeedMph / 2.23694) / dtSec; final accelMps2 = (deltaSpeedMph / 2.23694) / dtSec;
// Realistic motorcycle acceleration ceiling: ~15 m/s² (~1.5g), spike ceiling: 25 m/s² // Realistic motorcycle acceleration ceiling: ~15 m/s² (~1.5g), physical crash/spike ceiling: 30 m/s²
if (accelMps2 > 25.0 && dtSec >= 0.1) { if (accelMps2 > 35.0 && dtSec >= 0.1) {
status = ValidationStatus.rejected; status = ValidationStatus.rejected;
speedConf = 0.1; speedConf = 0.1;
reasons.add('Impossible acceleration (${accelMps2.toStringAsFixed(1)} m/s² > 25 m/s²)'); reasons.add('Impossible acceleration (${accelMps2.toStringAsFixed(1)} m/s² > 35 m/s²)');
} else if (accelMps2 > 16.0) { } else if (accelMps2 > 20.0) {
if (status == ValidationStatus.valid) status = ValidationStatus.suspect; if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
speedConf = math.min(speedConf, 0.5); speedConf = math.min(speedConf, 0.5);
reasons.add('Suspicious acceleration spike (${accelMps2.toStringAsFixed(1)} m/s²)'); reasons.add('Suspicious acceleration spike (${accelMps2.toStringAsFixed(1)} m/s²)');
} }
} }
}
// 5. Lean Angle Plausibility & Confidence // 5. Lean Angle Plausibility & Confidence
double leanConf = 1.0; double leanConf = 1.0;
@ -206,16 +216,18 @@ class TelemetryValidator {
status = ValidationStatus.suspect; status = ValidationStatus.suspect;
leanConf = 0.3; leanConf = 0.3;
reasons.add('Extreme lean angle beyond physical motorcycle limit (${currentLean.toStringAsFixed(1)}° > 65°)'); reasons.add('Extreme lean angle beyond physical motorcycle limit (${currentLean.toStringAsFixed(1)}° > 65°)');
} else if (_lastLeanAngle != null && deltaMs > 0 && deltaMs < 1000) { } else if (_lastLeanAngle != null && deltaMs >= 40 && deltaMs < 1000) {
final deltaLean = (currentLean - _lastLeanAngle!).abs(); final deltaLean = (currentLean - _lastLeanAngle!).abs();
// Only evaluate roll rate if angular change exceeds engine vibration noise (> 3.0 deg)
if (deltaLean > 3.0) {
final rollRateDegPerSec = deltaLean / (deltaMs / 1000.0); final rollRateDegPerSec = deltaLean / (deltaMs / 1000.0);
// Motorcycle flick roll-rate ceiling is ~120 deg/sec if (rollRateDegPerSec > 250.0) {
if (rollRateDegPerSec > 180.0) {
if (status == ValidationStatus.valid) status = ValidationStatus.suspect; if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
leanConf = 0.4; leanConf = 0.5;
reasons.add('Excessive roll rate (${rollRateDegPerSec.toStringAsFixed(0)}°/s)'); reasons.add('Excessive roll rate (${rollRateDegPerSec.toStringAsFixed(0)}°/s)');
} }
} }
}
// 6. G-Force Plausibility & Sensor Saturation // 6. G-Force Plausibility & Sensor Saturation
double gConf = 1.0; double gConf = 1.0;
@ -254,6 +266,7 @@ class TelemetryValidator {
_lastLeanAngle = currentLean; _lastLeanAngle = currentLean;
if (sample.latitude != null) _lastLat = sample.latitude; if (sample.latitude != null) _lastLat = sample.latitude;
if (sample.longitude != null) _lastLng = sample.longitude; if (sample.longitude != null) _lastLng = sample.longitude;
if (sample.gpsTimestamp != null) _lastGpsTimestamp = sample.gpsTimestamp;
} }
return TelemetryQuality( return TelemetryQuality(

View File

@ -153,8 +153,8 @@ void main() {
group('Phase 10: Database Persistence & Crash Recovery', () { group('Phase 10: Database Persistence & Crash Recovery', () {
test('Batch flushes events to SQLite with chronological ordering and ride filtering', () async { test('Batch flushes events to SQLite with chronological ordering and ride filtering', () async {
logger.startSession(42);
final now = DateTime(2026, 8, 28, 14, 0, 0); final now = DateTime(2026, 8, 28, 14, 0, 0);
logger.startSession(42, timestamp: now.subtract(const Duration(seconds: 1)));
logger.log( logger.log(
eventType: FieldTestEventType.gpsStale, eventType: FieldTestEventType.gpsStale,