fix(telemetry): stabilize 20Hz clock driver, fix moving time accumulator & prevent derivative spikes
This commit is contained in:
parent
64dc635d68
commit
38ae22e19e
Binary file not shown.
|
|
@ -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
|
|
@ -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
|
|
@ -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,21 +518,18 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sample.leanAngle != null && sample.leanAngle!.abs() <= 65.0) {
|
if (sample.leanAngle != null && sample.leanAngle!.abs() <= 65.0) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
_fieldTestLogger.log(
|
if (quality.isRejected) {
|
||||||
eventType: quality.isRejected ? FieldTestEventType.sampleRejected : FieldTestEventType.sampleSuspect,
|
_fieldTestLogger.log(
|
||||||
message: 'Sample validation ${quality.status.name}: ${quality.reasons.join(", ")}',
|
eventType: FieldTestEventType.sampleRejected,
|
||||||
severity: quality.isRejected ? EventSeverity.critical : EventSeverity.warning,
|
message: 'Sample validation rejected: ${quality.reasons.join(", ")}',
|
||||||
telemetryTimestamp: rawSample.timestamp,
|
severity: EventSeverity.critical,
|
||||||
speed: speedMph,
|
telemetryTimestamp: rawSample.timestamp,
|
||||||
overallConfidence: quality.overallConfidence,
|
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);
|
_telemetryController.add(validatedSample);
|
||||||
|
|
|
||||||
|
|
@ -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 latDiff = (sample.latitude! - _lastLat!).abs() * 111139.0;
|
final dtSec = (sample.gpsTimestamp != null && _lastGpsTimestamp != null && sample.gpsTimestamp != _lastGpsTimestamp)
|
||||||
final lngDiff = (sample.longitude! - _lastLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0));
|
? sample.gpsTimestamp!.difference(_lastGpsTimestamp!).inMilliseconds / 1000.0
|
||||||
final distMeters = math.sqrt(latDiff * latDiff + lngDiff * lngDiff);
|
: (deltaMs > 0 ? deltaMs / 1000.0 : 0.05);
|
||||||
final derivedSpeedMps = distMeters / (deltaMs / 1000.0);
|
|
||||||
if (derivedSpeedMps > 110.0 && distMeters > 50.0) {
|
if (dtSec > 0.02 && dtSec < 5.0) {
|
||||||
status = ValidationStatus.rejected;
|
final latDiff = (sample.latitude! - _lastLat!).abs() * 111139.0;
|
||||||
gpsConf = 0.0;
|
final lngDiff = (sample.longitude! - _lastLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0));
|
||||||
reasons.add('GPS position jump detected (${distMeters.toStringAsFixed(0)}m in ${deltaMs}ms)');
|
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;
|
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();
|
||||||
final dtSec = deltaMs / 1000.0;
|
if (deltaSpeedMph >= 1.5) {
|
||||||
final accelMps2 = (deltaSpeedMph / 2.23694) / dtSec;
|
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²
|
// 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²)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,14 +216,16 @@ 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();
|
||||||
final rollRateDegPerSec = deltaLean / (deltaMs / 1000.0);
|
// Only evaluate roll rate if angular change exceeds engine vibration noise (> 3.0 deg)
|
||||||
// Motorcycle flick roll-rate ceiling is ~120 deg/sec
|
if (deltaLean > 3.0) {
|
||||||
if (rollRateDegPerSec > 180.0) {
|
final rollRateDegPerSec = deltaLean / (deltaMs / 1000.0);
|
||||||
if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
|
if (rollRateDegPerSec > 250.0) {
|
||||||
leanConf = 0.4;
|
if (status == ValidationStatus.valid) status = ValidationStatus.suspect;
|
||||||
reasons.add('Excessive roll rate (${rollRateDegPerSec.toStringAsFixed(0)}°/s)');
|
leanConf = 0.5;
|
||||||
|
reasons.add('Excessive roll rate (${rollRateDegPerSec.toStringAsFixed(0)}°/s)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue