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;
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue