feat(telemetry): implement Phase 8 validation layer, confidence scoring, motorcycle calibration, and deterministic regression suite

This commit is contained in:
Developer 2026-08-28 03:46:32 -04:00
parent 98f3ea9f35
commit 2d8c2e2625
13 changed files with 828 additions and 39 deletions

40
FIELD_TESTING.md Normal file
View File

@ -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 `<extensions>` 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.

95
TELEMETRY_ARCHITECTURE.md Normal file
View File

@ -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.

51
TELEMETRY_VALIDATION.md Normal file
View File

@ -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`.

View File

@ -68,6 +68,8 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
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,6 +116,9 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
final preciseElapsedMs = endTime.difference(_startTime!).inMilliseconds;
final preciseElapsedSec = preciseElapsedMs / 1000.0;
// 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,
@ -127,6 +132,10 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
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);

View File

@ -80,39 +80,48 @@ class RecordsService extends StateNotifier<PersonalRecords> {
);
}
Future<void> checkAndSaveRecords(PersonalRecords newStats, void Function(String, double)? onNewRecord) async {
Future<void> 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();

View File

@ -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<RideSession> {
_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<RideSession> {
Future<void> _flushBatch() async {
if (_telemetryBatch.isNotEmpty) {
final batchToInsert = List<TelemetrySamplesCompanion>.from(_telemetryBatch);
_telemetryBatch.clear();
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

View File

@ -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,
);
}
}

View File

@ -9,14 +9,26 @@ class CalibrationService {
static const _pitchKey = 'calibration_pitch';
static const _rollKey = 'calibration_roll';
Future<void> saveCalibrationOffsets(double pitch, double roll) async {
Future<void> 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<Map<String, double>> loadCalibrationOffsets() async {
Future<Map<String, double>> 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};

View File

@ -243,10 +243,18 @@ class SensorFusionService with WidgetsBindingObserver {
});
}
void calibrateUpright() {
Future<void> 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() {

View File

@ -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<TelemetryService>((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<TelemetrySample>.broadcast();
Stream<TelemetrySample> 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() {

View File

@ -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<String> 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<TelemetryValidator>((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 = <String>[];
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;
}
}

View File

@ -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);
});
});
}

View File

@ -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));
});
});
}