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