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

View File

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

View File

@ -78,7 +78,6 @@ class TelemetryService {
_locationSub = _locationService.getFilteredPositionStream().listen((pos) {
_latestPosition = pos;
_emitSample();
});
_leanSub = _sensorFusionService.leanAngleStream.listen((lean) {
@ -94,19 +93,20 @@ class TelemetryService {
_gForceSub = _sensorFusionService.gForceStream.listen((gForce) {
_latestGForce = gForce;
_latestImuTimestamp = DateTime.now();
_emitSample();
});
_compassSub = _compassService.headingStream.listen((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), (_) {
_emitSample();
});
}
DateTime? _lastSuspectLogTime;
void _emitSample() {
final now = DateTime.now();
final speedMph = _latestPosition != null ? _latestPosition!.speed * 2.23694 : 0.0;
@ -142,16 +142,31 @@ class TelemetryService {
final quality = _validator.validate(rawSample);
final validatedSample = rawSample.copyWith(quality: quality);
// Log diagnostic if sample is suspect or rejected during an active field test
if (_fieldTestLogger.isSessionActive && (quality.isSuspect || quality.isRejected)) {
_fieldTestLogger.log(
eventType: quality.isRejected ? FieldTestEventType.sampleRejected : FieldTestEventType.sampleSuspect,
message: 'Sample validation ${quality.status.name}: ${quality.reasons.join(", ")}',
severity: quality.isRejected ? EventSeverity.critical : EventSeverity.warning,
telemetryTimestamp: rawSample.timestamp,
speed: speedMph,
overallConfidence: quality.overallConfidence,
);
// Log diagnostic if sample is suspect or rejected during an active field test (throttled for suspect)
if (_fieldTestLogger.isSessionActive) {
if (quality.isRejected) {
_fieldTestLogger.log(
eventType: FieldTestEventType.sampleRejected,
message: 'Sample validation rejected: ${quality.reasons.join(", ")}',
severity: EventSeverity.critical,
telemetryTimestamp: rawSample.timestamp,
speed: speedMph,
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);

View File

@ -52,6 +52,7 @@ final telemetryValidatorProvider = Provider<TelemetryValidator>((ref) {
class TelemetryValidator {
DateTime? _lastTimestamp;
DateTime? _lastGpsTimestamp;
double? _lastSpeedMph;
double? _lastLeanAngle;
double? _lastLat;
@ -72,6 +73,7 @@ class TelemetryValidator {
void reset() {
_lastTimestamp = null;
_lastGpsTimestamp = null;
_lastSpeedMph = null;
_lastLeanAngle = null;
_lastLat = null;
@ -150,7 +152,7 @@ class TelemetryValidator {
if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
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);
if (currentSpeed > 10.0 && status == ValidationStatus.valid) {
status = ValidationStatus.suspect;
@ -164,15 +166,21 @@ class TelemetryValidator {
}
// GPS Position Teleportation Check
if (_lastLat != null && _lastLng != null && sample.latitude != null && sample.longitude != null && deltaMs > 0 && deltaMs < 3000) {
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 distMeters = math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
final derivedSpeedMps = distMeters / (deltaMs / 1000.0);
if (derivedSpeedMps > 110.0 && distMeters > 50.0) {
status = ValidationStatus.rejected;
gpsConf = 0.0;
reasons.add('GPS position jump detected (${distMeters.toStringAsFixed(0)}m in ${deltaMs}ms)');
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 lngDiff = (sample.longitude! - _lastLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0));
final distMeters = math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
final derivedSpeedMps = distMeters / dtSec;
if (derivedSpeedMps > 110.0 && distMeters > 50.0) {
status = ValidationStatus.rejected;
gpsConf = 0.0;
reasons.add('GPS position jump detected (${distMeters.toStringAsFixed(0)}m in ${(dtSec * 1000).toStringAsFixed(0)}ms)');
}
}
}
@ -182,20 +190,22 @@ class TelemetryValidator {
status = ValidationStatus.rejected;
speedConf = 0.0;
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 dtSec = deltaMs / 1000.0;
final accelMps2 = (deltaSpeedMph / 2.23694) / dtSec;
if (deltaSpeedMph >= 1.5) {
final dtSec = deltaMs / 1000.0;
final accelMps2 = (deltaSpeedMph / 2.23694) / dtSec;
// Realistic motorcycle acceleration ceiling: ~15 m/s² (~1.5g), spike ceiling: 25 m/s²
if (accelMps2 > 25.0 && dtSec >= 0.1) {
status = ValidationStatus.rejected;
speedConf = 0.1;
reasons.add('Impossible acceleration (${accelMps2.toStringAsFixed(1)} m/s² > 25 m/s²)');
} else if (accelMps2 > 16.0) {
if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
speedConf = math.min(speedConf, 0.5);
reasons.add('Suspicious acceleration spike (${accelMps2.toStringAsFixed(1)} m/s²)');
// Realistic motorcycle acceleration ceiling: ~15 m/s² (~1.5g), physical crash/spike ceiling: 30 m/s²
if (accelMps2 > 35.0 && dtSec >= 0.1) {
status = ValidationStatus.rejected;
speedConf = 0.1;
reasons.add('Impossible acceleration (${accelMps2.toStringAsFixed(1)} m/s² > 35 m/s²)');
} else if (accelMps2 > 20.0) {
if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
speedConf = math.min(speedConf, 0.5);
reasons.add('Suspicious acceleration spike (${accelMps2.toStringAsFixed(1)} m/s²)');
}
}
}
@ -206,14 +216,16 @@ class TelemetryValidator {
status = ValidationStatus.suspect;
leanConf = 0.3;
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 rollRateDegPerSec = deltaLean / (deltaMs / 1000.0);
// Motorcycle flick roll-rate ceiling is ~120 deg/sec
if (rollRateDegPerSec > 180.0) {
if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
leanConf = 0.4;
reasons.add('Excessive roll rate (${rollRateDegPerSec.toStringAsFixed(0)}°/s)');
// Only evaluate roll rate if angular change exceeds engine vibration noise (> 3.0 deg)
if (deltaLean > 3.0) {
final rollRateDegPerSec = deltaLean / (deltaMs / 1000.0);
if (rollRateDegPerSec > 250.0) {
if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
leanConf = 0.5;
reasons.add('Excessive roll rate (${rollRateDegPerSec.toStringAsFixed(0)}°/s)');
}
}
}
@ -254,6 +266,7 @@ class TelemetryValidator {
_lastLeanAngle = currentLean;
if (sample.latitude != null) _lastLat = sample.latitude;
if (sample.longitude != null) _lastLng = sample.longitude;
if (sample.gpsTimestamp != null) _lastGpsTimestamp = sample.gpsTimestamp;
}
return TelemetryQuality(

View File

@ -153,8 +153,8 @@ void main() {
group('Phase 10: Database Persistence & Crash Recovery', () {
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);
logger.startSession(42, timestamp: now.subtract(const Duration(seconds: 1)));
logger.log(
eventType: FieldTestEventType.gpsStale,