feat(telemetry): implement Phase 8 validation layer, confidence scoring, motorcycle calibration, and deterministic regression suite
This commit is contained in:
parent
98f3ea9f35
commit
2d8c2e2625
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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`.
|
||||||
|
|
@ -68,6 +68,8 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
||||||
|
|
||||||
void _onTelemetryUpdate(TelemetrySample sample) {
|
void _onTelemetryUpdate(TelemetrySample sample) {
|
||||||
if (sample.speed == null) return;
|
if (sample.speed == null) return;
|
||||||
|
// Reject corrupted or impossible samples
|
||||||
|
if (sample.quality != null && sample.quality!.isRejected) return;
|
||||||
|
|
||||||
final speedMph = sample.speed!;
|
final speedMph = sample.speed!;
|
||||||
|
|
||||||
|
|
@ -114,19 +116,26 @@ class PerformanceTimerService extends StateNotifier<PerformanceStateData> {
|
||||||
final preciseElapsedMs = endTime.difference(_startTime!).inMilliseconds;
|
final preciseElapsedMs = endTime.difference(_startTime!).inMilliseconds;
|
||||||
final preciseElapsedSec = preciseElapsedMs / 1000.0;
|
final preciseElapsedSec = preciseElapsedMs / 1000.0;
|
||||||
|
|
||||||
final result = PerformanceRunResult(
|
// Physical plausibility sanity check: 0-60 in >= 1.5s, 0-100 in >= 3.0s
|
||||||
target: state.target,
|
final minTime = state.target == PerformanceTarget.sixty ? 1.5 : 3.0;
|
||||||
timeSeconds: preciseElapsedSec,
|
if (preciseElapsedSec >= minTime) {
|
||||||
topSpeedReachedMph: _maxSpeedMph,
|
final result = PerformanceRunResult(
|
||||||
);
|
target: state.target,
|
||||||
|
timeSeconds: preciseElapsedSec,
|
||||||
|
topSpeedReachedMph: _maxSpeedMph,
|
||||||
|
);
|
||||||
|
|
||||||
_saveRun(result, preciseElapsedMs);
|
_saveRun(result, preciseElapsedMs);
|
||||||
|
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
state: PerformanceState.completed,
|
state: PerformanceState.completed,
|
||||||
currentElapsedSeconds: preciseElapsedSec,
|
currentElapsedSeconds: preciseElapsedSec,
|
||||||
lastResult: result,
|
lastResult: result,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// Flagged as impossible GPS artifact
|
||||||
|
state = state.copyWith(state: PerformanceState.aborted, currentElapsedSeconds: preciseElapsedSec);
|
||||||
|
}
|
||||||
} else if (speedMph <= _abortThresholdMph && elapsedSec > 2.0) {
|
} else if (speedMph <= _abortThresholdMph && elapsedSec > 2.0) {
|
||||||
// Aborted (came to a stop without reaching target speed)
|
// Aborted (came to a stop without reaching target speed)
|
||||||
state = state.copyWith(state: PerformanceState.aborted, currentElapsedSeconds: elapsedSec);
|
state = state.copyWith(state: PerformanceState.aborted, currentElapsedSeconds: elapsedSec);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
bool hasNewRecord = false;
|
||||||
var current = state;
|
var current = state;
|
||||||
|
|
||||||
void updateIfHigher(String key, double newValue, double currentValue, String name) {
|
void updateIfHigher(String key, double newValue, double currentValue, String name, double maxSanityLimit) {
|
||||||
if (newValue > currentValue) {
|
if (newValue > currentValue && newValue <= maxSanityLimit) {
|
||||||
_prefs.setDouble(key, newValue);
|
_prefs.setDouble(key, newValue);
|
||||||
onNewRecord?.call(name, newValue);
|
onNewRecord?.call(name, newValue);
|
||||||
hasNewRecord = true;
|
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
|
// 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);
|
_prefs.setDouble(key, newValue);
|
||||||
onNewRecord?.call(name, newValue);
|
onNewRecord?.call(name, newValue);
|
||||||
hasNewRecord = true;
|
hasNewRecord = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateIfHigher('topSpeedMph', newStats.topSpeedMph, current.topSpeedMph, 'TOP SPEED');
|
// Sanity-checked record updates (prevents impossible GPS multipath jumps from corrupting records)
|
||||||
updateIfHigher('maxLeanLeft', newStats.maxLeanLeft, current.maxLeanLeft, 'LEFT LEAN');
|
updateIfHigher('topSpeedMph', newStats.topSpeedMph, current.topSpeedMph, 'TOP SPEED', 220.0);
|
||||||
updateIfHigher('maxLeanRight', newStats.maxLeanRight, current.maxLeanRight, 'RIGHT LEAN');
|
updateIfHigher('maxLeanLeft', newStats.maxLeanLeft, current.maxLeanLeft, 'LEFT LEAN', 65.0);
|
||||||
updateIfHigher('maxAccelG', newStats.maxAccelG, current.maxAccelG, 'ACCEL G');
|
updateIfHigher('maxLeanRight', newStats.maxLeanRight, current.maxLeanRight, 'RIGHT LEAN', 65.0);
|
||||||
updateIfHigher('maxBrakingG', newStats.maxBrakingG, current.maxBrakingG, 'BRAKING G');
|
updateIfHigher('maxAccelG', newStats.maxAccelG, current.maxAccelG, 'ACCEL G', 2.5);
|
||||||
updateIfHigher('longestRideMiles', newStats.longestRideMiles, current.longestRideMiles, 'LONGEST RIDE');
|
updateIfHigher('maxBrakingG', newStats.maxBrakingG, current.maxBrakingG, 'BRAKING G', 2.5);
|
||||||
updateIfHigher('greatestElevationGain', newStats.greatestElevationGain, current.greatestElevationGain, 'ELEVATION GAIN');
|
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');
|
// Timing sanity: 0-30 >= 0.8s, 0-60 >= 1.8s, 0-100 >= 3.5s
|
||||||
updateIfLower('fastest0To60', newStats.fastest0To60, current.fastest0To60, '0-60 MPH');
|
updateIfLower('fastest0To30', newStats.fastest0To30, current.fastest0To30, '0-30 MPH', 0.8);
|
||||||
updateIfLower('fastest0To100', newStats.fastest0To100, current.fastest0To100, '0-100 MPH');
|
updateIfLower('fastest0To60', newStats.fastest0To60, current.fastest0To60, '0-60 MPH', 1.8);
|
||||||
updateIfLower('fastest60To100', newStats.fastest60To100, current.fastest60To100, '60-100 MPH');
|
updateIfLower('fastest0To100', newStats.fastest0To100, current.fastest0To100, '0-100 MPH', 3.5);
|
||||||
|
updateIfLower('fastest60To100', newStats.fastest60To100, current.fastest60To100, '60-100 MPH', 1.2);
|
||||||
|
|
||||||
if (hasNewRecord) {
|
if (hasNewRecord) {
|
||||||
_loadRecords();
|
_loadRecords();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import '../../services/audio_alert_service.dart';
|
||||||
import '../records/records_service.dart';
|
import '../records/records_service.dart';
|
||||||
import '../garage/garage_providers.dart';
|
import '../garage/garage_providers.dart';
|
||||||
import '../../services/field_test/field_test_logger.dart';
|
import '../../services/field_test/field_test_logger.dart';
|
||||||
|
import '../../services/field_test/field_test_models.dart';
|
||||||
|
|
||||||
enum RideState { idle, ready, riding, paused, finished }
|
enum RideState { idle, ready, riding, paused, finished }
|
||||||
|
|
||||||
|
|
@ -266,9 +267,23 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
_processStats(sample);
|
_processStats(sample);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const int _maxBatchCapacity = 1000;
|
||||||
|
|
||||||
void _queueForBatch(TelemetrySample sample) {
|
void _queueForBatch(TelemetrySample sample) {
|
||||||
if (state.dbId == null) return;
|
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(
|
_telemetryBatch.add(
|
||||||
TelemetrySamplesCompanion(
|
TelemetrySamplesCompanion(
|
||||||
rideId: drift.Value(state.dbId!),
|
rideId: drift.Value(state.dbId!),
|
||||||
|
|
@ -293,11 +308,22 @@ class RideStateMachine extends StateNotifier<RideSession> {
|
||||||
Future<void> _flushBatch() async {
|
Future<void> _flushBatch() async {
|
||||||
if (_telemetryBatch.isNotEmpty) {
|
if (_telemetryBatch.isNotEmpty) {
|
||||||
final batchToInsert = List<TelemetrySamplesCompanion>.from(_telemetryBatch);
|
final batchToInsert = List<TelemetrySamplesCompanion>.from(_telemetryBatch);
|
||||||
_telemetryBatch.clear();
|
try {
|
||||||
|
await db.batch((batch) {
|
||||||
await db.batch((batch) {
|
batch.insertAll(db.telemetrySamples, batchToInsert);
|
||||||
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
|
// Incrementally persist ongoing ride summary stats to SQLite so crashes/terminations never lose stats
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../services/validation/telemetry_validator.dart';
|
||||||
|
|
||||||
class TelemetrySample {
|
class TelemetrySample {
|
||||||
final DateTime timestamp;
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
|
@ -21,6 +23,9 @@ class TelemetrySample {
|
||||||
final double? gyroY;
|
final double? gyroY;
|
||||||
final double? gyroZ;
|
final double? gyroZ;
|
||||||
|
|
||||||
|
// Validation & Quality
|
||||||
|
final TelemetryQuality? quality;
|
||||||
|
|
||||||
TelemetrySample({
|
TelemetrySample({
|
||||||
required this.timestamp,
|
required this.timestamp,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
|
|
@ -39,6 +44,7 @@ class TelemetrySample {
|
||||||
this.gyroX,
|
this.gyroX,
|
||||||
this.gyroY,
|
this.gyroY,
|
||||||
this.gyroZ,
|
this.gyroZ,
|
||||||
|
this.quality,
|
||||||
});
|
});
|
||||||
|
|
||||||
TelemetrySample copyWith({
|
TelemetrySample copyWith({
|
||||||
|
|
@ -59,6 +65,7 @@ class TelemetrySample {
|
||||||
double? gyroX,
|
double? gyroX,
|
||||||
double? gyroY,
|
double? gyroY,
|
||||||
double? gyroZ,
|
double? gyroZ,
|
||||||
|
TelemetryQuality? quality,
|
||||||
}) {
|
}) {
|
||||||
return TelemetrySample(
|
return TelemetrySample(
|
||||||
timestamp: timestamp ?? this.timestamp,
|
timestamp: timestamp ?? this.timestamp,
|
||||||
|
|
@ -78,6 +85,7 @@ class TelemetrySample {
|
||||||
gyroX: gyroX ?? this.gyroX,
|
gyroX: gyroX ?? this.gyroX,
|
||||||
gyroY: gyroY ?? this.gyroY,
|
gyroY: gyroY ?? this.gyroY,
|
||||||
gyroZ: gyroZ ?? this.gyroZ,
|
gyroZ: gyroZ ?? this.gyroZ,
|
||||||
|
quality: quality ?? this.quality,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,26 @@ class CalibrationService {
|
||||||
static const _pitchKey = 'calibration_pitch';
|
static const _pitchKey = 'calibration_pitch';
|
||||||
static const _rollKey = 'calibration_roll';
|
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();
|
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(_pitchKey, pitch);
|
||||||
await prefs.setDouble(_rollKey, roll);
|
await prefs.setDouble(_rollKey, roll);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, double>> loadCalibrationOffsets() async {
|
Future<Map<String, double>> loadCalibrationOffsets({int? motorcycleId}) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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 pitch = prefs.getDouble(_pitchKey) ?? 0.0;
|
||||||
final roll = prefs.getDouble(_rollKey) ?? 0.0;
|
final roll = prefs.getDouble(_rollKey) ?? 0.0;
|
||||||
return {'pitch': pitch, 'roll': roll};
|
return {'pitch': pitch, 'roll': roll};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
_rollOffset = _currentRoll;
|
||||||
_pitchOffset = _currentPitch;
|
_pitchOffset = _currentPitch;
|
||||||
_calibrationService.saveCalibrationOffsets(_pitchOffset, _rollOffset);
|
_calibrationService.saveCalibrationOffsets(_pitchOffset, _rollOffset, motorcycleId: motorcycleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,17 @@ import 'location_service.dart';
|
||||||
import 'sensor_fusion_service.dart';
|
import 'sensor_fusion_service.dart';
|
||||||
import 'compass_service.dart';
|
import 'compass_service.dart';
|
||||||
import 'field_test/field_test_logger.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 telemetryServiceProvider = Provider<TelemetryService>((ref) {
|
||||||
final locationService = ref.watch(locationServiceProvider);
|
final locationService = ref.watch(locationServiceProvider);
|
||||||
final sensorFusionService = ref.watch(sensorFusionServiceProvider);
|
final sensorFusionService = ref.watch(sensorFusionServiceProvider);
|
||||||
final compassService = ref.watch(compassServiceProvider);
|
final compassService = ref.watch(compassServiceProvider);
|
||||||
final fieldTestLogger = ref.watch(fieldTestLoggerProvider);
|
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());
|
ref.onDispose(() => service.dispose());
|
||||||
return service;
|
return service;
|
||||||
});
|
});
|
||||||
|
|
@ -27,6 +30,7 @@ class TelemetryService {
|
||||||
final SensorFusionService _sensorFusionService;
|
final SensorFusionService _sensorFusionService;
|
||||||
final CompassService _compassService;
|
final CompassService _compassService;
|
||||||
final FieldTestLogger _fieldTestLogger;
|
final FieldTestLogger _fieldTestLogger;
|
||||||
|
final TelemetryValidator _validator;
|
||||||
|
|
||||||
final _telemetryController = StreamController<TelemetrySample>.broadcast();
|
final _telemetryController = StreamController<TelemetrySample>.broadcast();
|
||||||
Stream<TelemetrySample> get telemetryStream => _telemetryController.stream;
|
Stream<TelemetrySample> get telemetryStream => _telemetryController.stream;
|
||||||
|
|
@ -51,6 +55,7 @@ class TelemetryService {
|
||||||
this._sensorFusionService,
|
this._sensorFusionService,
|
||||||
this._compassService,
|
this._compassService,
|
||||||
this._fieldTestLogger,
|
this._fieldTestLogger,
|
||||||
|
this._validator,
|
||||||
) {
|
) {
|
||||||
_init();
|
_init();
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +130,7 @@ class TelemetryService {
|
||||||
finalHeading = _latestPosition!.heading;
|
finalHeading = _latestPosition!.heading;
|
||||||
}
|
}
|
||||||
|
|
||||||
final sample = TelemetrySample(
|
final rawSample = TelemetrySample(
|
||||||
timestamp: DateTime.now(),
|
timestamp: DateTime.now(),
|
||||||
latitude: _latestPosition?.latitude,
|
latitude: _latestPosition?.latitude,
|
||||||
longitude: _latestPosition?.longitude,
|
longitude: _latestPosition?.longitude,
|
||||||
|
|
@ -142,7 +147,20 @@ class TelemetryService {
|
||||||
lateralG: _latestGForce?.lateral,
|
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() {
|
void dispose() {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue