import 'dart:math' as math; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/telemetry_sample.dart'; enum ValidationStatus { valid, suspect, rejected, } class TelemetryQuality { final ValidationStatus status; final List reasons; final double gpsConfidence; // 0.0 - 1.0 final double speedConfidence; // 0.0 - 1.0 final double leanConfidence; // 0.0 - 1.0 final double gForceConfidence; // 0.0 - 1.0 final double headingConfidence; // 0.0 - 1.0 final double overallConfidence; // 0.0 - 1.0 final bool isStationary; final bool isAccelerating; final bool isBraking; final bool isMountCalibrated; final bool isSensorSaturated; final int timestampDeltaMs; const TelemetryQuality({ this.status = ValidationStatus.valid, this.reasons = const [], this.gpsConfidence = 1.0, this.speedConfidence = 1.0, this.leanConfidence = 1.0, this.gForceConfidence = 1.0, this.headingConfidence = 1.0, this.overallConfidence = 1.0, this.isStationary = true, this.isAccelerating = false, this.isBraking = false, this.isMountCalibrated = true, this.isSensorSaturated = false, this.timestampDeltaMs = 50, }); bool get isValid => status == ValidationStatus.valid; bool get isSuspect => status == ValidationStatus.suspect; bool get isRejected => status == ValidationStatus.rejected; } final telemetryValidatorProvider = Provider((ref) { return TelemetryValidator(); }); class TelemetryValidator { DateTime? _lastTimestamp; int? _lastMonotonicMs; DateTime? _lastGpsLocationTime; double? _lastGpsLat; double? _lastGpsLng; double? _lastSpeedMph; double? _lastLeanAngle; // 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; _lastMonotonicMs = null; _lastGpsLocationTime = null; _lastGpsLat = null; _lastGpsLng = null; _lastSpeedMph = null; _lastLeanAngle = null; _totalValidated = 0; _validCount = 0; _suspectCount = 0; _rejectedCount = 0; _timestampGapCount = 0; } TelemetryQuality validate(TelemetrySample sample, {bool? isMountCalibrated}) { _totalValidated++; ValidationStatus status = ValidationStatus.valid; final List reasons = []; final currentSpeed = sample.speed ?? 0.0; final effectiveIsMountCalibrated = isMountCalibrated ?? sample.quality?.isMountCalibrated ?? true; // 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 & Monotonicity Validation int deltaMs = 50; if (sample.monotonicTimestampMs != null && _lastMonotonicMs != null) { deltaMs = sample.monotonicTimestampMs! - _lastMonotonicMs!; if (deltaMs <= 0) { status = ValidationStatus.suspect; reasons.add('Reverse or duplicate monotonic 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)'); } } } else 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 Staleness / Age evaluation if (sample.gpsAgeMs != null) { if (sample.gpsAgeMs! > 2000) { gpsConf = math.min(gpsConf, 0.15); if (currentSpeed > 3.0) { if (status == ValidationStatus.valid) status = ValidationStatus.suspect; reasons.add('Stale GPS fix (${(sample.gpsAgeMs! / 1000).toStringAsFixed(1)}s old)'); } } else if (sample.gpsAgeMs! > 1200) { gpsConf = math.min(gpsConf, 0.40); if (currentSpeed > 10.0 && status == ValidationStatus.valid) { status = ValidationStatus.suspect; reasons.add('Degraded GPS freshness (${sample.gpsAgeMs}ms age)'); } } else if (sample.gpsAgeMs! > 500) { gpsConf = math.min(gpsConf, 0.65); } else if (sample.gpsAgeMs! > 200) { gpsConf = math.min(gpsConf, 0.85); } } // GPS Position Teleportation Check across distinct location updates if (_lastGpsLat != null && _lastGpsLng != null && sample.latitude != null && sample.longitude != null) { if (sample.latitude != _lastGpsLat || sample.longitude != _lastGpsLng) { final dtSec = _lastGpsLocationTime != null ? math.max(0.05, sample.timestamp.difference(_lastGpsLocationTime!).inMilliseconds / 1000.0) : 1.0; final latDiff = (sample.latitude! - _lastGpsLat!).abs() * 111139.0; final lngDiff = (sample.longitude! - _lastGpsLng!).abs() * 111139.0 * math.cos(sample.latitude! * (math.pi / 180.0)); final distMeters = math.sqrt(latDiff * latDiff + lngDiff * lngDiff); final derivedSpeedMps = distMeters / dtSec; if (derivedSpeedMps > 110.0 && distMeters > 50.0) { status = ValidationStatus.rejected; gpsConf = 0.0; reasons.add('GPS position jump detected (${distMeters.toStringAsFixed(0)}m in ${(dtSec * 1000).toStringAsFixed(0)}ms)'); } _lastGpsLat = sample.latitude; _lastGpsLng = sample.longitude; _lastGpsLocationTime = sample.timestamp; } } else if (sample.latitude != null && sample.longitude != null) { _lastGpsLat = sample.latitude; _lastGpsLng = sample.longitude; _lastGpsLocationTime = sample.timestamp; } // 4. Speed Plausibility & Confidence double speedConf = gpsConf; 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 >= 40 && deltaMs < 2000) { final deltaSpeedMph = (currentSpeed - _lastSpeedMph!).abs(); if (deltaSpeedMph >= 3.0) { final dtSec = deltaMs / 1000.0; final accelMps2 = (deltaSpeedMph / 2.23694) / dtSec; // Realistic superbike acceleration ceiling: ~20 m/s² (~2.0g), discrete GPS epoch step ceiling: 65 m/s² if (accelMps2 > 75.0 && dtSec >= 0.1) { status = ValidationStatus.rejected; speedConf = 0.1; reasons.add('Impossible acceleration (${accelMps2.toStringAsFixed(1)} m/s² > 75 m/s²)'); } else if (accelMps2 > 50.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 (sample.leanAngle != null && !effectiveIsMountCalibrated) { leanConf = 0.5; } else 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 (currentSpeed > 5.0 && _lastLeanAngle != null && deltaMs >= 40 && deltaMs < 1000) { // Gate roll rate evaluation: only evaluate dynamic roll rate when motorcycle is moving (>5 MPH) // to completely eliminate stationary/idle engine vibration on the handlebar mount final deltaLean = (currentLean - _lastLeanAngle!).abs(); if (deltaLean > 4.0) { final rollRateDegPerSec = deltaLean / (deltaMs / 1000.0); if (rollRateDegPerSec > 280.0) { if (status == ValidationStatus.valid) status = ValidationStatus.suspect; leanConf = 0.5; 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; if (status == ValidationStatus.valid) status = ValidationStatus.suspect; 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; _lastMonotonicMs = sample.monotonicTimestampMs; if (status != ValidationStatus.rejected) { _lastSpeedMph = currentSpeed; _lastLeanAngle = currentLean; } 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: effectiveIsMountCalibrated, 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; } } class RideQualityReport { final int overallScore; // 0 - 100 final String rating; // EXCELLENT, GOOD, FAIR, POOR, INVALID final double validSampleRatio; final double gpsCoverageRatio; final double averageGpsAccuracy; final int totalSamples; final int validSamples; final int suspectSamples; final int rejectedSamples; final int timestampGaps; final bool isMountCalibrated; // Categorical Quality Diagnostics (Phase 9) final int timingIntegrityScore; final int gpsQualityScore; final int imuContinuityScore; final int sensorContinuityScore; final int databaseIntegrityScore; final int filterHealthScore; final List diagnosticExplanations; const RideQualityReport({ required this.overallScore, required this.rating, required this.validSampleRatio, required this.gpsCoverageRatio, required this.averageGpsAccuracy, required this.totalSamples, required this.validSamples, required this.suspectSamples, required this.rejectedSamples, required this.timestampGaps, required this.isMountCalibrated, required this.timingIntegrityScore, required this.gpsQualityScore, required this.imuContinuityScore, required this.sensorContinuityScore, required this.databaseIntegrityScore, required this.filterHealthScore, required this.diagnosticExplanations, }); static RideQualityReport evaluate({ required int totalSamples, required int validSamples, required int suspectSamples, required int rejectedSamples, required int timestampGaps, required int gpsFixCount, required double totalGpsAccuracy, int databaseSamplesLost = 0, int imuSaturationCount = 0, bool isMountCalibrated = true, }) { if (totalSamples == 0) { return const RideQualityReport( overallScore: 0, rating: 'INVALID', validSampleRatio: 0.0, gpsCoverageRatio: 0.0, averageGpsAccuracy: 999.0, totalSamples: 0, validSamples: 0, suspectSamples: 0, rejectedSamples: 0, timestampGaps: 0, isMountCalibrated: false, timingIntegrityScore: 0, gpsQualityScore: 0, imuContinuityScore: 0, sensorContinuityScore: 0, databaseIntegrityScore: 100, filterHealthScore: 0, diagnosticExplanations: ['No telemetry samples recorded in session.'], ); } final validRatio = validSamples / totalSamples; final gpsCoverage = gpsFixCount > 0 ? math.min(1.0, (gpsFixCount * 2) / totalSamples) : 0.0; final avgAccuracy = gpsFixCount > 0 ? (totalGpsAccuracy / gpsFixCount) : 999.0; // 1. Timing Integrity: Deduct for timestamp gaps / irregularity int timingScore = 100; if (timestampGaps > 0) { timingScore = math.max(0, 100 - (timestampGaps * 5)); } // 2. GPS Quality: Fix coverage & precision int gpsScore = (gpsCoverage * 60.0).round(); if (avgAccuracy <= 5.0) { gpsScore += 40; } else if (avgAccuracy <= 15.0) { gpsScore += 30; } else if (avgAccuracy <= 35.0) { gpsScore += 15; } else { gpsScore += 5; } gpsScore = gpsScore.clamp(0, 100); // 3. IMU Continuity & Sensor Health int imuScore = 100; if (imuSaturationCount > 0) { imuScore = math.max(50, 100 - (imuSaturationCount * 10)); } // 4. Database Integrity: Written vs Lost int dbScore = 100; if (databaseSamplesLost > 0) { dbScore = math.max(0, 100 - ((databaseSamplesLost / totalSamples) * 100.0).round()); } // 5. Filter Health int filterScore = ((validSamples / totalSamples) * 100.0).round().clamp(0, 100); // Human-readable diagnostic breakdown final List explanations = []; if (timestampGaps == 0) { explanations.add('Timing Integrity: 100% — Zero timestamp gaps or sequence drops.'); } else { explanations.add('Timing Integrity: $timingScore% — $timestampGaps timestamp gap(s) detected.'); } if (gpsScore >= 90) { explanations.add('GPS Quality: $gpsScore% — High-precision lock (Avg: ${avgAccuracy.toStringAsFixed(1)}m).'); } else { explanations.add('GPS Quality: $gpsScore% — Accuracy degraded (Avg: ${avgAccuracy.toStringAsFixed(1)}m).'); } explanations.add('IMU Continuity: $imuScore% — Sensor pipeline stable ($imuSaturationCount saturation events).'); explanations.add('Database Integrity: $dbScore% — $totalSamples samples persisted, $databaseSamplesLost lost.'); explanations.add('Filter Health: $filterScore% — $validSamples valid frames, $suspectSamples suspect frames.'); // Overall Score: weighted average of data quality (ignoring rider riding style) double score = (timingScore * 0.25) + (gpsScore * 0.25) + (imuScore * 0.20) + (dbScore * 0.15) + (filterScore * 0.15); if (!isMountCalibrated) score -= 10.0; if (rejectedSamples > 0) score -= math.min(15.0, (rejectedSamples / totalSamples) * 100.0); final finalScore = score.round().clamp(0, 100); String rating; if (finalScore >= 90) { rating = 'EXCELLENT'; } else if (finalScore >= 75) { rating = 'GOOD'; } else if (finalScore >= 60) { rating = 'FAIR'; } else if (finalScore >= 40) { rating = 'POOR'; } else { rating = 'INVALID'; } return RideQualityReport( overallScore: finalScore, rating: rating, validSampleRatio: validRatio, gpsCoverageRatio: gpsCoverage, averageGpsAccuracy: avgAccuracy, totalSamples: totalSamples, validSamples: validSamples, suspectSamples: suspectSamples, rejectedSamples: rejectedSamples, timestampGaps: timestampGaps, isMountCalibrated: isMountCalibrated, timingIntegrityScore: timingScore, gpsQualityScore: gpsScore, imuContinuityScore: imuScore, sensorContinuityScore: 100, databaseIntegrityScore: dbScore, filterHealthScore: filterScore, diagnosticExplanations: explanations, ); } }