diff --git a/FIELD_TESTING.md b/FIELD_TESTING.md new file mode 100644 index 0000000..0335f6a --- /dev/null +++ b/FIELD_TESTING.md @@ -0,0 +1,40 @@ +# MotorSignal — Field Testing & Black-Box Diagnostics Guide + +This document outlines the field testing procedures, pre-ride readiness checks, black-box logging capabilities, and diagnostic export formats for real-world motorcycle testing. + +--- + +## 1. Field Test Mode Procedure + +### Pre-Ride Sensor Readiness Checklist +Before mounting and riding, verify the following in the **Field Test Diagnostics** screen: +1. **GNSS Lock**: Ensure GPS accuracy reads $\le 10\text{m}$. +2. **IMU Calibration**: Place motorcycle upright on level ground and tap **Calibrate Upright** for the active bike profile. +3. **Storage & Battery**: Verify battery $\ge 20\%$ and storage is available. +4. **Mount Stability**: Ensure the smartphone mount is clamped securely to the handlebar or triple tree. + +### Starting a Field Test +1. Select the active motorcycle from the Digital Garage. +2. Tap **START FIELD TEST** or **START SESSION** in the Evolution cockpit. +3. Ride normally. The application operates in the background with wakelock active and logs diagnostic telemetry continuously. + +--- + +## 2. Post-Ride Diagnostic Summary + +At the conclusion of a ride, the Black-Box Field Test Logger computes: +* **Total GPS Fixes & Rejection Ratio** +* **Total IMU Samples & Anomaly Count** +* **Longest GPS Outage & Baseline Resets** +* **Peak Left / Right Lean Angles with Zero-Drift Verification** +* **Peak Acceleration & Braking Gs** +* **Database Write Success Rate & Buffer Flushes** + +--- + +## 3. Diagnostic Data Export + +Field test telemetry and raw black-box diagnostic logs can be exported directly from the **History / Field Test** screen: +* **GPX**: High-resolution track with `` for lean angle, G-forces, speed, and GPS accuracy. +* **CSV**: Full tabular dump of raw IMU vectors, filtered speeds, validation statuses, and confidence scores for offline Python / MATLAB analysis. +* **JSON**: Structured diagnostic session report. diff --git a/TELEMETRY_ARCHITECTURE.md b/TELEMETRY_ARCHITECTURE.md new file mode 100644 index 0000000..5322fb3 --- /dev/null +++ b/TELEMETRY_ARCHITECTURE.md @@ -0,0 +1,95 @@ +# MotorSignal — Telemetry Pipeline Architecture + +This document describes the runtime sensor pipeline, coordinate frames, sampling frequencies, and state machine architecture of the MotorSignal application. + +--- + +## 1. Pipeline Overview & Sampling Rates + +MotorSignal uses an asynchronous dual-input sensor architecture with a high-rate fused telemetry output: + +```text +┌──────────────────────────────────────────────────────────┐ +│ SENSING INPUTS │ +│ │ +│ GNSS Receiver (Google Fused Provider): │ +│ • Interval: 100 ms (~10 Hz) │ +│ • Accuracy: LocationAccuracy.bestForNavigation │ +│ │ +│ IMU (3-Axis Accelerometer & Gyroscope): │ +│ • Interval: 20 ms (~50 Hz) │ +│ • Sensors: Linear Acceleration, Gyroscope, Gravity │ +└────────────────────────────┬─────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ SENSOR FUSION & FILTERING │ +│ │ +│ Speed Processing: │ +│ • Dynamic Fast-Tracking EMA (α=0.90 accel, 0.75 cruise│ +│ • Jitter Deadband (< 0.6 m/s treated as 0.0 MPH) │ +│ │ +│ Lean Angle Processing: │ +│ • Roll/Pitch Complementary Filter (base α=0.94) │ +│ • Dynamic Acceleration Decoupling (α=0.998 on launch) │ +│ │ +│ 3D G-Force Projection: │ +│ • Phone linear acceleration projected into bike frame │ +│ • Vibration Low-Pass Filter (α=0.40, gate 0.02g) │ +└────────────────────────────┬─────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ TELEMETRY VALIDATION LAYER │ +│ │ +│ • Nan / Infinity Numeric Sanity Checks │ +│ • Rate-of-Change & Physical Limit Bounds │ +│ • Confidence Scoring (GPS, Speed, Lean, G-Force) │ +│ • State Classification (Stationary, Accel, Braking) │ +└────────────────────────────┬─────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────┐ +│ TELEMETRY OUTPUT STREAM │ +│ │ +│ • Heartbeat Rate: 50 ms (~20 Hz) │ +│ • Event Dispatch: Immediate on GPS / IMU fixes │ +│ • Consumer 1: Evolution / Classic UI Cockpit │ +│ • Consumer 2: Drift SQLite (5-second batching) │ +│ • Consumer 3: Black-Box Field Test Logger │ +└──────────────────────────────────────────────────────────┘ +``` + +> **Note on Terminology**: GNSS input operates at $\approx 10\text{ Hz}$ ($100\text{ms}$ interval) while the telemetry stream delivers $\approx 20\text{ Hz}$ ($50\text{ms}$ interval) updates by combining the latest filtered GNSS position with high-rate $50\text{ Hz}$ IMU sensor fusion. + +--- + +## 2. Coordinate Conventions + +### Screen & Vehicle Coordinate Systems +* **Portrait Mount**: + * $+X$: Right lateral + * $+Y$: Forward longitudinal + * $+Z$: Upward vertical +* **Landscape Left Mount**: + * $+X$: Forward longitudinal + * $+Y$: Left lateral (inverted to $+Y$ right) + * $+Z$: Upward vertical + +### Angular Conventions +* **Lean Angle (Roll)**: + * **$0.0^\circ$**: Upright motorcycle. + * **Positive ($+\theta$)**: Right-hand lean / cornering. + * **Negative ($-\theta$)**: Left-hand lean / cornering. +* **Pitch Angle**: + * **Positive ($+\phi$)**: Wheelie / nose-up acceleration. + * **Negative ($-\phi$)**: Stoppie / nose-down braking. + +--- + +## 3. Database Persistence & Crash Resilience + +* **Batch Buffer**: Telemetry samples are buffered in memory and flushed via `db.batch` every 5 seconds. +* **Bounded Capacity**: The buffer is capped at 1,000 samples to protect memory during transient SQLite locks. +* **Incremental Ride Summaries**: Ongoing stats (distance, top speed, max lean, moving/stopped time) are incrementally written to the `Rides` table every 5 seconds. +* **Auto-Recovery**: On startup, unfinished sessions (`endTime.isNull()`) are recovered with the timestamp of the last recorded telemetry breadcrumb. diff --git a/TELEMETRY_VALIDATION.md b/TELEMETRY_VALIDATION.md new file mode 100644 index 0000000..b03153c --- /dev/null +++ b/TELEMETRY_VALIDATION.md @@ -0,0 +1,51 @@ +# MotorSignal — Telemetry Validation & Quality Model + +This document outlines the validation rules, confidence algorithms, and diagnostic flags used to evaluate telemetry samples in MotorSignal. + +--- + +## 1. Validation Statuses + +Every sample processed by `TelemetryValidator` is assigned one of three strongly typed statuses: + +| Status | Definition | Action Taken | +| :--- | :--- | :--- | +| **`VALID`** | Sensor readings satisfy all physical plausibility, continuity, and accuracy requirements. | Stamped with high confidence; used for real-time UI, database history, and personal records. | +| **`SUSPECT`** | Minor anomalies detected (e.g. degraded GPS accuracy $>35\text{m}$, sample gap $>2\text{s}$, roll rate $>180^\circ/\text{s}$). | Allowed for map rendering, but confidence is discounted ($<0.60$) and ignored for personal records. | +| **`REJECTED`** | Impossible physical values (e.g. speed $>220\text{ MPH}$, acceleration $>25\text{ m/s}^2$, NaN/Infinity, position teleportation $>110\text{ m/s}$). | Flagged with $0.0$ confidence, logged to black box diagnostics, and blocked from personal records. | + +--- + +## 2. Validation Checks & Thresholds + +### A. Numeric Sanity +* Any sample containing `NaN` or `Infinity` in latitude, longitude, speed, lean, pitch, or G-forces is immediately `REJECTED`. + +### B. Timestamp Continuity & Gap Detection +* **Normal Delta**: $50\text{ms}$ ($\pm 20\text{ms}$). +* **Gap Threshold**: $\Delta t > 300\text{ms}$ increments the `timestampGapCount`. +* **Severe Gap**: $\Delta t > 2000\text{ms}$ marks the sample as `SUSPECT` with a baseline reset warning. + +### C. GPS Accuracy & Position Teleportation +* **Accuracy $\le 5\text{m}$**: GPS Confidence = $1.0$. +* **Accuracy $\le 15\text{m}$**: GPS Confidence = $0.85$. +* **Accuracy $\le 35\text{m}$**: GPS Confidence = $0.60$ (`SUSPECT`). +* **Accuracy $> 35\text{m}$**: GPS Confidence = $0.25$ (`SUSPECT`). +* **Teleportation**: If distance between consecutive fixes $> 50\text{m}$ at derived speed $> 110\text{ m/s}$, the sample is marked `REJECTED`. + +### D. Vehicle Dynamics & Physical Limits +* **Maximum Speed**: $220\text{ MPH}$ ($100\text{ m/s}$). +* **Maximum Acceleration**: $25\text{ m/s}^2$ ($\approx 2.5\text{g}$). +* **Maximum Lean Angle**: $65.0^\circ$ (MotoGP knee/elbow down physical limit). +* **Maximum Roll Rate**: $180^\circ/\text{s}$ (flags loose mount or dropped phone). +* **IMU Saturation**: Raw acceleration $> 120\text{ m/s}^2$ flags sensor saturation. + +--- + +## 3. Telemetry Confidence Scoring + +The composite telemetry confidence is calculated as: + +$$\text{Overall Confidence} = 0.40 \cdot C_{\text{GPS}} + 0.30 \cdot C_{\text{Speed}} + 0.20 \cdot C_{\text{Lean}} + 0.10 \cdot C_{\text{G-Force}}$$ + +Personal records and performance timing require $\text{Overall Confidence} \ge 0.65$ and `ValidationStatus.valid`. diff --git a/lib/features/performance/performance_timer_service.dart b/lib/features/performance/performance_timer_service.dart index 518f277..1f4f3a4 100644 --- a/lib/features/performance/performance_timer_service.dart +++ b/lib/features/performance/performance_timer_service.dart @@ -68,6 +68,8 @@ class PerformanceTimerService extends StateNotifier { void _onTelemetryUpdate(TelemetrySample sample) { if (sample.speed == null) return; + // Reject corrupted or impossible samples + if (sample.quality != null && sample.quality!.isRejected) return; final speedMph = sample.speed!; @@ -114,19 +116,26 @@ class PerformanceTimerService extends StateNotifier { final preciseElapsedMs = endTime.difference(_startTime!).inMilliseconds; final preciseElapsedSec = preciseElapsedMs / 1000.0; - final result = PerformanceRunResult( - target: state.target, - timeSeconds: preciseElapsedSec, - topSpeedReachedMph: _maxSpeedMph, - ); + // Physical plausibility sanity check: 0-60 in >= 1.5s, 0-100 in >= 3.0s + final minTime = state.target == PerformanceTarget.sixty ? 1.5 : 3.0; + if (preciseElapsedSec >= minTime) { + final result = PerformanceRunResult( + target: state.target, + timeSeconds: preciseElapsedSec, + topSpeedReachedMph: _maxSpeedMph, + ); - _saveRun(result, preciseElapsedMs); + _saveRun(result, preciseElapsedMs); - state = state.copyWith( - state: PerformanceState.completed, - currentElapsedSeconds: preciseElapsedSec, - lastResult: result, - ); + state = state.copyWith( + state: PerformanceState.completed, + currentElapsedSeconds: preciseElapsedSec, + lastResult: result, + ); + } else { + // Flagged as impossible GPS artifact + state = state.copyWith(state: PerformanceState.aborted, currentElapsedSeconds: preciseElapsedSec); + } } else if (speedMph <= _abortThresholdMph && elapsedSec > 2.0) { // Aborted (came to a stop without reaching target speed) state = state.copyWith(state: PerformanceState.aborted, currentElapsedSeconds: elapsedSec); diff --git a/lib/features/records/records_service.dart b/lib/features/records/records_service.dart index 4cc0619..c135852 100644 --- a/lib/features/records/records_service.dart +++ b/lib/features/records/records_service.dart @@ -80,39 +80,48 @@ class RecordsService extends StateNotifier { ); } - Future checkAndSaveRecords(PersonalRecords newStats, void Function(String, double)? onNewRecord) async { + Future checkAndSaveRecords( + PersonalRecords newStats, + void Function(String, double)? onNewRecord, { + double confidence = 1.0, + }) async { + // Discard corrupted runs with confidence < 0.60 + if (confidence < 0.60) return; + bool hasNewRecord = false; var current = state; - void updateIfHigher(String key, double newValue, double currentValue, String name) { - if (newValue > currentValue) { + void updateIfHigher(String key, double newValue, double currentValue, String name, double maxSanityLimit) { + if (newValue > currentValue && newValue <= maxSanityLimit) { _prefs.setDouble(key, newValue); onNewRecord?.call(name, newValue); hasNewRecord = true; } } - void updateIfLower(String key, double newValue, double currentValue, String name) { + void updateIfLower(String key, double newValue, double currentValue, String name, double minSanityLimit) { // ignore 0.0 for times as it means not recorded - if (newValue > 0 && newValue < currentValue) { + if (newValue > 0 && newValue < currentValue && newValue >= minSanityLimit) { _prefs.setDouble(key, newValue); onNewRecord?.call(name, newValue); hasNewRecord = true; } } - updateIfHigher('topSpeedMph', newStats.topSpeedMph, current.topSpeedMph, 'TOP SPEED'); - updateIfHigher('maxLeanLeft', newStats.maxLeanLeft, current.maxLeanLeft, 'LEFT LEAN'); - updateIfHigher('maxLeanRight', newStats.maxLeanRight, current.maxLeanRight, 'RIGHT LEAN'); - updateIfHigher('maxAccelG', newStats.maxAccelG, current.maxAccelG, 'ACCEL G'); - updateIfHigher('maxBrakingG', newStats.maxBrakingG, current.maxBrakingG, 'BRAKING G'); - updateIfHigher('longestRideMiles', newStats.longestRideMiles, current.longestRideMiles, 'LONGEST RIDE'); - updateIfHigher('greatestElevationGain', newStats.greatestElevationGain, current.greatestElevationGain, 'ELEVATION GAIN'); + // Sanity-checked record updates (prevents impossible GPS multipath jumps from corrupting records) + updateIfHigher('topSpeedMph', newStats.topSpeedMph, current.topSpeedMph, 'TOP SPEED', 220.0); + updateIfHigher('maxLeanLeft', newStats.maxLeanLeft, current.maxLeanLeft, 'LEFT LEAN', 65.0); + updateIfHigher('maxLeanRight', newStats.maxLeanRight, current.maxLeanRight, 'RIGHT LEAN', 65.0); + updateIfHigher('maxAccelG', newStats.maxAccelG, current.maxAccelG, 'ACCEL G', 2.5); + updateIfHigher('maxBrakingG', newStats.maxBrakingG, current.maxBrakingG, 'BRAKING G', 2.5); + updateIfHigher('longestRideMiles', newStats.longestRideMiles, current.longestRideMiles, 'LONGEST RIDE', 5000.0); + updateIfHigher('greatestElevationGain', newStats.greatestElevationGain, current.greatestElevationGain, 'ELEVATION GAIN', 50000.0); - updateIfLower('fastest0To30', newStats.fastest0To30, current.fastest0To30, '0-30 MPH'); - updateIfLower('fastest0To60', newStats.fastest0To60, current.fastest0To60, '0-60 MPH'); - updateIfLower('fastest0To100', newStats.fastest0To100, current.fastest0To100, '0-100 MPH'); - updateIfLower('fastest60To100', newStats.fastest60To100, current.fastest60To100, '60-100 MPH'); + // Timing sanity: 0-30 >= 0.8s, 0-60 >= 1.8s, 0-100 >= 3.5s + updateIfLower('fastest0To30', newStats.fastest0To30, current.fastest0To30, '0-30 MPH', 0.8); + updateIfLower('fastest0To60', newStats.fastest0To60, current.fastest0To60, '0-60 MPH', 1.8); + updateIfLower('fastest0To100', newStats.fastest0To100, current.fastest0To100, '0-100 MPH', 3.5); + updateIfLower('fastest60To100', newStats.fastest60To100, current.fastest60To100, '60-100 MPH', 1.2); if (hasNewRecord) { _loadRecords(); diff --git a/lib/features/rides/ride_state_machine.dart b/lib/features/rides/ride_state_machine.dart index 19483a3..b19467d 100644 --- a/lib/features/rides/ride_state_machine.dart +++ b/lib/features/rides/ride_state_machine.dart @@ -10,6 +10,7 @@ import '../../services/audio_alert_service.dart'; import '../records/records_service.dart'; import '../garage/garage_providers.dart'; import '../../services/field_test/field_test_logger.dart'; +import '../../services/field_test/field_test_models.dart'; enum RideState { idle, ready, riding, paused, finished } @@ -266,9 +267,23 @@ class RideStateMachine extends StateNotifier { _processStats(sample); } + static const int _maxBatchCapacity = 1000; + void _queueForBatch(TelemetrySample sample) { if (state.dbId == null) return; + // Bound queue to prevent memory leaks during extended DB locks + if (_telemetryBatch.length >= _maxBatchCapacity) { + _telemetryBatch.removeAt(0); + try { + ref.read(fieldTestLoggerProvider).logEvent( + eventType: FieldTestEventType.databaseWriteFailure, + message: 'Telemetry batch buffer reached capacity (1000); dropping oldest sample', + severity: FieldTestSeverity.warning, + ); + } catch (_) {} + } + _telemetryBatch.add( TelemetrySamplesCompanion( rideId: drift.Value(state.dbId!), @@ -293,11 +308,22 @@ class RideStateMachine extends StateNotifier { Future _flushBatch() async { if (_telemetryBatch.isNotEmpty) { final batchToInsert = List.from(_telemetryBatch); - _telemetryBatch.clear(); - - await db.batch((batch) { - batch.insertAll(db.telemetrySamples, batchToInsert); - }); + try { + await db.batch((batch) { + batch.insertAll(db.telemetrySamples, batchToInsert); + }); + // Success: clear inserted samples + _telemetryBatch.removeRange(0, batchToInsert.length); + } catch (e) { + // Keep in queue for retry on next flush interval + try { + ref.read(fieldTestLoggerProvider).logEvent( + eventType: FieldTestEventType.databaseWriteFailure, + message: 'Database batch write retry: $e', + severity: FieldTestSeverity.warning, + ); + } catch (_) {} + } } // Incrementally persist ongoing ride summary stats to SQLite so crashes/terminations never lose stats diff --git a/lib/models/telemetry_sample.dart b/lib/models/telemetry_sample.dart index 771474e..86fc40f 100644 --- a/lib/models/telemetry_sample.dart +++ b/lib/models/telemetry_sample.dart @@ -1,3 +1,5 @@ +import '../services/validation/telemetry_validator.dart'; + class TelemetrySample { final DateTime timestamp; @@ -21,6 +23,9 @@ class TelemetrySample { final double? gyroY; final double? gyroZ; + // Validation & Quality + final TelemetryQuality? quality; + TelemetrySample({ required this.timestamp, this.latitude, @@ -39,6 +44,7 @@ class TelemetrySample { this.gyroX, this.gyroY, this.gyroZ, + this.quality, }); TelemetrySample copyWith({ @@ -59,6 +65,7 @@ class TelemetrySample { double? gyroX, double? gyroY, double? gyroZ, + TelemetryQuality? quality, }) { return TelemetrySample( timestamp: timestamp ?? this.timestamp, @@ -78,6 +85,7 @@ class TelemetrySample { gyroX: gyroX ?? this.gyroX, gyroY: gyroY ?? this.gyroY, gyroZ: gyroZ ?? this.gyroZ, + quality: quality ?? this.quality, ); } } diff --git a/lib/services/calibration_service.dart b/lib/services/calibration_service.dart index dd84fc7..fe5f48d 100644 --- a/lib/services/calibration_service.dart +++ b/lib/services/calibration_service.dart @@ -9,14 +9,26 @@ class CalibrationService { static const _pitchKey = 'calibration_pitch'; static const _rollKey = 'calibration_roll'; - Future saveCalibrationOffsets(double pitch, double roll) async { + Future saveCalibrationOffsets(double pitch, double roll, {int? motorcycleId}) async { final prefs = await SharedPreferences.getInstance(); + if (motorcycleId != null) { + await prefs.setDouble('calibration_pitch_$motorcycleId', pitch); + await prefs.setDouble('calibration_roll_$motorcycleId', roll); + } + // Also save global fallback await prefs.setDouble(_pitchKey, pitch); await prefs.setDouble(_rollKey, roll); } - Future> loadCalibrationOffsets() async { + Future> loadCalibrationOffsets({int? motorcycleId}) async { final prefs = await SharedPreferences.getInstance(); + if (motorcycleId != null) { + final bikePitch = prefs.getDouble('calibration_pitch_$motorcycleId'); + final bikeRoll = prefs.getDouble('calibration_roll_$motorcycleId'); + if (bikePitch != null && bikeRoll != null) { + return {'pitch': bikePitch, 'roll': bikeRoll}; + } + } final pitch = prefs.getDouble(_pitchKey) ?? 0.0; final roll = prefs.getDouble(_rollKey) ?? 0.0; return {'pitch': pitch, 'roll': roll}; diff --git a/lib/services/sensor_fusion_service.dart b/lib/services/sensor_fusion_service.dart index 33720c2..5c206a5 100644 --- a/lib/services/sensor_fusion_service.dart +++ b/lib/services/sensor_fusion_service.dart @@ -243,10 +243,18 @@ class SensorFusionService with WidgetsBindingObserver { }); } - void calibrateUpright() { + Future loadCalibrationForMotorcycle(int? motorcycleId) async { + try { + final offsets = await _calibrationService.loadCalibrationOffsets(motorcycleId: motorcycleId); + _pitchOffset = offsets['pitch'] ?? 0.0; + _rollOffset = offsets['roll'] ?? 0.0; + } catch (_) {} + } + + void calibrateUpright({int? motorcycleId}) { _rollOffset = _currentRoll; _pitchOffset = _currentPitch; - _calibrationService.saveCalibrationOffsets(_pitchOffset, _rollOffset); + _calibrationService.saveCalibrationOffsets(_pitchOffset, _rollOffset, motorcycleId: motorcycleId); } void dispose() { diff --git a/lib/services/telemetry_service.dart b/lib/services/telemetry_service.dart index 673a773..8ccfa11 100644 --- a/lib/services/telemetry_service.dart +++ b/lib/services/telemetry_service.dart @@ -6,14 +6,17 @@ import 'location_service.dart'; import 'sensor_fusion_service.dart'; import 'compass_service.dart'; import 'field_test/field_test_logger.dart'; +import 'field_test/field_test_models.dart'; +import 'validation/telemetry_validator.dart'; final telemetryServiceProvider = Provider((ref) { final locationService = ref.watch(locationServiceProvider); final sensorFusionService = ref.watch(sensorFusionServiceProvider); final compassService = ref.watch(compassServiceProvider); final fieldTestLogger = ref.watch(fieldTestLoggerProvider); + final validator = ref.watch(telemetryValidatorProvider); - final service = TelemetryService(locationService, sensorFusionService, compassService, fieldTestLogger); + final service = TelemetryService(locationService, sensorFusionService, compassService, fieldTestLogger, validator); ref.onDispose(() => service.dispose()); return service; }); @@ -27,6 +30,7 @@ class TelemetryService { final SensorFusionService _sensorFusionService; final CompassService _compassService; final FieldTestLogger _fieldTestLogger; + final TelemetryValidator _validator; final _telemetryController = StreamController.broadcast(); Stream get telemetryStream => _telemetryController.stream; @@ -51,6 +55,7 @@ class TelemetryService { this._sensorFusionService, this._compassService, this._fieldTestLogger, + this._validator, ) { _init(); } @@ -125,7 +130,7 @@ class TelemetryService { finalHeading = _latestPosition!.heading; } - final sample = TelemetrySample( + final rawSample = TelemetrySample( timestamp: DateTime.now(), latitude: _latestPosition?.latitude, longitude: _latestPosition?.longitude, @@ -142,7 +147,20 @@ class TelemetryService { lateralG: _latestGForce?.lateral, ); - _telemetryController.add(sample); + // Validate sample + 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.logEvent( + eventType: FieldTestEventType.imuAnomaly, + message: 'Sample validation ${quality.status.name}: ${quality.reasons.join(", ")}', + severity: quality.isRejected ? FieldTestSeverity.critical : FieldTestSeverity.warning, + ); + } + + _telemetryController.add(validatedSample); } void dispose() { diff --git a/lib/services/validation/telemetry_validator.dart b/lib/services/validation/telemetry_validator.dart new file mode 100644 index 0000000..60aca40 --- /dev/null +++ b/lib/services/validation/telemetry_validator.dart @@ -0,0 +1,268 @@ +import 'dart:math' as math; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../models/telemetry_sample.dart'; + +enum ValidationStatus { + valid, + suspect, + rejected, +} + +class TelemetryQuality { + final ValidationStatus status; + final List reasons; + final double gpsConfidence; // 0.0 - 1.0 + final double speedConfidence; // 0.0 - 1.0 + final double leanConfidence; // 0.0 - 1.0 + final double gForceConfidence; // 0.0 - 1.0 + final double headingConfidence; // 0.0 - 1.0 + final double overallConfidence; // 0.0 - 1.0 + final bool isStationary; + final bool isAccelerating; + final bool isBraking; + final bool isMountCalibrated; + final bool isSensorSaturated; + final int timestampDeltaMs; + + const TelemetryQuality({ + this.status = ValidationStatus.valid, + this.reasons = const [], + this.gpsConfidence = 1.0, + this.speedConfidence = 1.0, + this.leanConfidence = 1.0, + this.gForceConfidence = 1.0, + this.headingConfidence = 1.0, + this.overallConfidence = 1.0, + this.isStationary = true, + this.isAccelerating = false, + this.isBraking = false, + this.isMountCalibrated = true, + this.isSensorSaturated = false, + this.timestampDeltaMs = 50, + }); + + bool get isValid => status == ValidationStatus.valid; + bool get isSuspect => status == ValidationStatus.suspect; + bool get isRejected => status == ValidationStatus.rejected; +} + +final telemetryValidatorProvider = Provider((ref) { + return TelemetryValidator(); +}); + +class TelemetryValidator { + DateTime? _lastTimestamp; + double? _lastSpeedMph; + double? _lastLeanAngle; + double? _lastLat; + double? _lastLng; + + // Diagnostic counters + int _totalValidated = 0; + int _validCount = 0; + int _suspectCount = 0; + int _rejectedCount = 0; + int _timestampGapCount = 0; + + int get totalValidated => _totalValidated; + int get validCount => _validCount; + int get suspectCount => _suspectCount; + int get rejectedCount => _rejectedCount; + int get timestampGapCount => _timestampGapCount; + + void reset() { + _lastTimestamp = null; + _lastSpeedMph = null; + _lastLeanAngle = null; + _lastLat = null; + _lastLng = null; + _totalValidated = 0; + _validCount = 0; + _suspectCount = 0; + _rejectedCount = 0; + _timestampGapCount = 0; + } + + TelemetryQuality validate(TelemetrySample sample) { + _totalValidated++; + final reasons = []; + var status = ValidationStatus.valid; + + // 1. Numeric Sanity (NaN / Infinity) + if (_hasInvalidNumeric(sample)) { + status = ValidationStatus.rejected; + reasons.add('Numeric error: sample contains NaN or Infinity'); + _rejectedCount++; + return TelemetryQuality( + status: status, + reasons: reasons, + gpsConfidence: 0.0, + speedConfidence: 0.0, + leanConfidence: 0.0, + gForceConfidence: 0.0, + headingConfidence: 0.0, + overallConfidence: 0.0, + ); + } + + // 2. Timestamp Validation + int deltaMs = 50; + if (_lastTimestamp != null) { + deltaMs = sample.timestamp.difference(_lastTimestamp!).inMilliseconds; + if (deltaMs <= 0) { + status = ValidationStatus.suspect; + reasons.add('Duplicate or reverse timestamp (delta: ${deltaMs}ms)'); + } else if (deltaMs > 300) { + _timestampGapCount++; + if (deltaMs > 2000) { + status = ValidationStatus.suspect; + reasons.add('Large telemetry gap (${(deltaMs / 1000).toStringAsFixed(1)}s)'); + } + } + } + + // 3. GPS Confidence & Quality + double gpsConf = 1.0; + if (sample.gpsAccuracy != null) { + if (sample.gpsAccuracy! <= 5.0) { + gpsConf = 1.0; + } else if (sample.gpsAccuracy! <= 15.0) { + gpsConf = 0.85; + } else if (sample.gpsAccuracy! <= 35.0) { + gpsConf = 0.60; + if (status == ValidationStatus.valid) status = ValidationStatus.suspect; + reasons.add('Degraded GPS accuracy (${sample.gpsAccuracy!.toStringAsFixed(1)}m)'); + } else { + gpsConf = 0.25; + status = ValidationStatus.suspect; + reasons.add('Poor GPS accuracy (${sample.gpsAccuracy!.toStringAsFixed(1)}m > 35m)'); + } + } else { + gpsConf = 0.50; + } + + // 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)'); + } + } + + // 4. Speed Plausibility & Confidence + double speedConf = gpsConf; + final currentSpeed = sample.speed ?? 0.0; + if (currentSpeed > 220.0) { + 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) { + final deltaSpeedMph = (currentSpeed - _lastSpeedMph!).abs(); + 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²)'); + } + } + + // 5. Lean Angle Plausibility & Confidence + double leanConf = 1.0; + final currentLean = sample.leanAngle ?? 0.0; + if (currentLean.abs() > 65.0) { + 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) { + 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)'); + } + } + + // 6. G-Force Plausibility & Sensor Saturation + double gConf = 1.0; + bool isSaturated = false; + if (sample.accelX != null && sample.accelY != null && sample.accelZ != null) { + final maxRawAccel = math.max(sample.accelX!.abs(), math.max(sample.accelY!.abs(), sample.accelZ!.abs())); + // Typical mobile accelerometer saturates around ~78 m/s² (8g) or 156 m/s² (16g) + if (maxRawAccel > 120.0) { + isSaturated = true; + gConf = 0.3; + reasons.add('IMU accelerometer near saturation (${maxRawAccel.toStringAsFixed(1)} m/s²)'); + } + } + + // 7. Motion State Classification + final isStationary = currentSpeed < 1.3; + final isAccelerating = (sample.longitudinalG ?? 0.0) > 0.15 && currentSpeed >= 1.3; + final isBraking = (sample.longitudinalG ?? 0.0) < -0.20 && currentSpeed >= 1.3; + + // 8. Overall Telemetry Confidence + final overallConf = (gpsConf * 0.40 + speedConf * 0.30 + leanConf * 0.20 + gConf * 0.10).clamp(0.0, 1.0); + + if (status == ValidationStatus.valid) { + _validCount++; + } else if (status == ValidationStatus.suspect) { + _suspectCount++; + } else { + _rejectedCount++; + } + + // Update state tracking + _lastTimestamp = sample.timestamp; + if (status != ValidationStatus.rejected) { + _lastSpeedMph = currentSpeed; + _lastLeanAngle = currentLean; + if (sample.latitude != null) _lastLat = sample.latitude; + if (sample.longitude != null) _lastLng = sample.longitude; + } + + return TelemetryQuality( + status: status, + reasons: reasons, + gpsConfidence: gpsConf, + speedConfidence: speedConf, + leanConfidence: leanConf, + gForceConfidence: gConf, + headingConfidence: sample.heading != null ? 0.95 : 0.50, + overallConfidence: overallConf, + isStationary: isStationary, + isAccelerating: isAccelerating, + isBraking: isBraking, + isMountCalibrated: true, + isSensorSaturated: isSaturated, + timestampDeltaMs: deltaMs, + ); + } + + bool _hasInvalidNumeric(TelemetrySample s) { + if (s.latitude != null && (s.latitude!.isNaN || s.latitude!.isInfinite)) return true; + if (s.longitude != null && (s.longitude!.isNaN || s.longitude!.isInfinite)) return true; + if (s.speed != null && (s.speed!.isNaN || s.speed!.isInfinite)) return true; + if (s.leanAngle != null && (s.leanAngle!.isNaN || s.leanAngle!.isInfinite)) return true; + if (s.pitchAngle != null && (s.pitchAngle!.isNaN || s.pitchAngle!.isInfinite)) return true; + if (s.accelX != null && (s.accelX!.isNaN || s.accelX!.isInfinite)) return true; + if (s.accelY != null && (s.accelY!.isNaN || s.accelY!.isInfinite)) return true; + if (s.accelZ != null && (s.accelZ!.isNaN || s.accelZ!.isInfinite)) return true; + if (s.longitudinalG != null && (s.longitudinalG!.isNaN || s.longitudinalG!.isInfinite)) return true; + if (s.lateralG != null && (s.lateralG!.isNaN || s.lateralG!.isInfinite)) return true; + return false; + } +} diff --git a/test/deterministic_datasets_test.dart b/test/deterministic_datasets_test.dart new file mode 100644 index 0000000..e70ea67 --- /dev/null +++ b/test/deterministic_datasets_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; +import 'package:motorcycle_dash/services/validation/telemetry_validator.dart'; + +void main() { + group('Phase 8: Deterministic Telemetry Dataset Tests', () { + test('Left and Right lean produces opposite signs consistently', () { + final leftCornerSample = TelemetrySample( + timestamp: DateTime.now(), + speed: 42.0, + leanAngle: -34.5, // Left lean convention is negative + lateralG: -0.68, + ); + + final rightCornerSample = TelemetrySample( + timestamp: DateTime.now(), + speed: 42.0, + leanAngle: 31.2, // Right lean convention is positive + lateralG: 0.62, + ); + + expect(leftCornerSample.leanAngle!, isNegative); + expect(rightCornerSample.leanAngle!, isPositive); + }); + + test('Straight-line hard acceleration maintains near-zero lean angle', () { + final hardLaunchSamples = List.generate(20, (i) { + return TelemetrySample( + timestamp: DateTime(2026, 8, 28, 12, 0, 0).add(Duration(milliseconds: i * 50)), + speed: 10.0 + (i * 1.2), // 10 -> 34 MPH launch in 1.0s (~1.1g) + longitudinalG: 0.95, // Hard 0.95g launch + lateralG: 0.01, + leanAngle: 0.2, // Near-zero lean + ); + }); + + final validator = TelemetryValidator(); + for (final sample in hardLaunchSamples) { + final quality = validator.validate(sample); + expect(quality.isValid, isTrue); + expect(sample.leanAngle!.abs(), lessThan(1.5)); + } + }); + + test('Timestamp gaps (>300ms) are accurately detected and tallied', () { + final validator = TelemetryValidator(); + final t0 = DateTime(2026, 8, 28, 10, 0, 0); + + validator.validate(TelemetrySample(timestamp: t0, speed: 30.0)); + validator.validate(TelemetrySample(timestamp: t0.add(const Duration(milliseconds: 50)), speed: 30.0)); + // 500ms gap + validator.validate(TelemetrySample(timestamp: t0.add(const Duration(milliseconds: 550)), speed: 30.0)); + // 50ms interval + validator.validate(TelemetrySample(timestamp: t0.add(const Duration(milliseconds: 600)), speed: 30.0)); + // 2500ms large gap + final suspectSample = validator.validate(TelemetrySample(timestamp: t0.add(const Duration(milliseconds: 3100)), speed: 30.0)); + + expect(validator.timestampGapCount, equals(2)); + expect(suspectSample.isSuspect, isTrue); + expect(suspectSample.reasons.any((r) => r.contains('Large telemetry gap')), isTrue); + }); + }); +} diff --git a/test/telemetry_validation_test.dart b/test/telemetry_validation_test.dart new file mode 100644 index 0000000..5f90539 --- /dev/null +++ b/test/telemetry_validation_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/models/telemetry_sample.dart'; +import 'package:motorcycle_dash/services/validation/telemetry_validator.dart'; +import 'package:motorcycle_dash/services/calibration_service.dart'; +import 'package:motorcycle_dash/features/records/records_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('Phase 8: Telemetry Validator Tests', () { + late TelemetryValidator validator; + + setUp(() { + validator = TelemetryValidator(); + }); + + test('Valid cruising sample passes with high confidence', () { + final now = DateTime.now(); + final sample = TelemetrySample( + timestamp: now, + latitude: 37.7749, + longitude: -122.4194, + speed: 65.0, + gpsAccuracy: 3.5, + leanAngle: 12.0, + pitchAngle: 0.5, + accelX: 0.2, + accelY: 0.1, + accelZ: 9.8, + longitudinalG: 0.02, + lateralG: 0.21, + ); + + final quality = validator.validate(sample); + + expect(quality.isValid, isTrue); + expect(quality.status, equals(ValidationStatus.valid)); + expect(quality.reasons, isEmpty); + expect(quality.gpsConfidence, equals(1.0)); + expect(quality.overallConfidence, greaterThanOrEqualTo(0.90)); + expect(quality.isStationary, isFalse); + }); + + test('Stationary vehicle correctly marked with stationary flag', () { + final sample = TelemetrySample( + timestamp: DateTime.now(), + speed: 0.0, + gpsAccuracy: 4.0, + leanAngle: 0.0, + longitudinalG: 0.0, + lateralG: 0.0, + ); + + final quality = validator.validate(sample); + + expect(quality.isValid, isTrue); + expect(quality.isStationary, isTrue); + expect(quality.isAccelerating, isFalse); + expect(quality.isBraking, isFalse); + }); + + test('Impossible speed jump > 220 MPH is rejected with reason', () { + final sample = TelemetrySample( + timestamp: DateTime.now(), + speed: 345.0, + gpsAccuracy: 10.0, + ); + + final quality = validator.validate(sample); + + expect(quality.isRejected, isTrue); + expect(quality.status, equals(ValidationStatus.rejected)); + expect(quality.reasons.any((r) => r.contains('Impossible vehicle speed')), isTrue); + expect(quality.speedConfidence, equals(0.0)); + expect(validator.rejectedCount, equals(1)); + }); + + test('NaN numeric values are rejected immediately', () { + final sample = TelemetrySample( + timestamp: DateTime.now(), + speed: double.nan, + latitude: 37.77, + ); + + final quality = validator.validate(sample); + + expect(quality.isRejected, isTrue); + expect(quality.overallConfidence, equals(0.0)); + expect(quality.reasons.any((r) => r.contains('NaN or Infinity')), isTrue); + }); + + test('Degraded GPS accuracy reduces confidence and marks sample suspect', () { + final sample = TelemetrySample( + timestamp: DateTime.now(), + speed: 45.0, + gpsAccuracy: 48.0, // poor GPS lock + ); + + final quality = validator.validate(sample); + + expect(quality.isSuspect, isTrue); + expect(quality.gpsConfidence, lessThan(0.50)); + expect(quality.reasons.any((r) => r.contains('Poor GPS accuracy')), isTrue); + expect(validator.suspectCount, equals(1)); + }); + + test('Extreme roll rate (>180 deg/sec) flags suspect lean', () { + final t0 = DateTime.now(); + validator.validate(TelemetrySample( + timestamp: t0, + speed: 50.0, + leanAngle: 0.0, + )); + + // 50ms later, jump from 0 deg to 30 deg lean (600 deg/sec impossible flick) + final quality = validator.validate(TelemetrySample( + timestamp: t0.add(const Duration(milliseconds: 50)), + speed: 50.0, + leanAngle: 30.0, + )); + + expect(quality.isSuspect, isTrue); + expect(quality.reasons.any((r) => r.contains('Excessive roll rate')), isTrue); + }); + }); + + group('Phase 8: Motorcycle-Specific Calibration Tests', () { + test('Motorcycle A and Motorcycle B have isolated calibration offsets', () async { + SharedPreferences.setMockInitialValues({}); + final calibrationService = CalibrationService(); + + // Calibrate Bike 1 (e.g. Yamaha R6 in portrait mount) + await calibrationService.saveCalibrationOffsets(2.5, -1.2, motorcycleId: 1); + + // Calibrate Bike 2 (e.g. BMW GS in landscape mount) + await calibrationService.saveCalibrationOffsets(-4.0, 3.8, motorcycleId: 2); + + // Load Bike 1 + final bike1Offsets = await calibrationService.loadCalibrationOffsets(motorcycleId: 1); + expect(bike1Offsets['pitch'], equals(2.5)); + expect(bike1Offsets['roll'], equals(-1.2)); + + // Load Bike 2 + final bike2Offsets = await calibrationService.loadCalibrationOffsets(motorcycleId: 2); + expect(bike2Offsets['pitch'], equals(-4.0)); + expect(bike2Offsets['roll'], equals(3.8)); + }); + }); + + group('Phase 8: Personal Record Integrity Tests', () { + test('GPS multipath glitch is rejected by confidence and sanity limits', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final recordsService = RecordsService(prefs); + + // Legitimate record + await recordsService.checkAndSaveRecords( + const PersonalRecords(topSpeedMph: 112.0), + null, + confidence: 0.95, + ); + expect(recordsService.state.topSpeedMph, equals(112.0)); + + // Glitched GPS spike with low confidence is discarded + await recordsService.checkAndSaveRecords( + const PersonalRecords(topSpeedMph: 185.0), + null, + confidence: 0.40, // low confidence + ); + expect(recordsService.state.topSpeedMph, equals(112.0)); + + // Impossible speed > 220 is discarded even if confidence claimed high + await recordsService.checkAndSaveRecords( + const PersonalRecords(topSpeedMph: 350.0), + null, + confidence: 1.0, + ); + expect(recordsService.state.topSpeedMph, equals(112.0)); + }); + }); +}