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; DateTime? _lastGpsTimestamp; 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; _lastGpsTimestamp = null; _lastSpeedMph = null; _lastLeanAngle = null; _lastLat = null; _lastLng = null; _totalValidated = 0; _validCount = 0; _suspectCount = 0; _rejectedCount = 0; _timestampGapCount = 0; } TelemetryQuality validate(TelemetrySample sample) { _totalValidated++; final reasons = []; var status = ValidationStatus.valid; final currentSpeed = sample.speed ?? 0.0; // 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 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 if (_lastLat != null && _lastLng != null && sample.latitude != null && sample.longitude != null) { final dtSec = (sample.gpsTimestamp != null && _lastGpsTimestamp != null && sample.gpsTimestamp != _lastGpsTimestamp) ? sample.gpsTimestamp!.difference(_lastGpsTimestamp!).inMilliseconds / 1000.0 : (deltaMs > 0 ? deltaMs / 1000.0 : 0.05); if (dtSec > 0.02 && dtSec < 5.0) { 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 / 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)'); } } } // 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 >= 1.5) { final dtSec = deltaMs / 1000.0; final accelMps2 = (deltaSpeedMph / 2.23694) / dtSec; // Realistic motorcycle acceleration ceiling: ~15 m/s² (~1.5g), physical crash/spike ceiling: 30 m/s² if (accelMps2 > 35.0 && dtSec >= 0.1) { status = ValidationStatus.rejected; speedConf = 0.1; reasons.add('Impossible acceleration (${accelMps2.toStringAsFixed(1)} m/s² > 35 m/s²)'); } else if (accelMps2 > 20.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 >= 40 && deltaMs < 1000) { final deltaLean = (currentLean - _lastLeanAngle!).abs(); // Only evaluate roll rate if angular change exceeds engine vibration noise (> 3.0 deg) if (deltaLean > 3.0) { final rollRateDegPerSec = deltaLean / (deltaMs / 1000.0); if (rollRateDegPerSec > 250.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; if (status != ValidationStatus.rejected) { _lastSpeedMph = currentSpeed; _lastLeanAngle = currentLean; if (sample.latitude != null) _lastLat = sample.latitude; if (sample.longitude != null) _lastLng = sample.longitude; if (sample.gpsTimestamp != null) _lastGpsTimestamp = sample.gpsTimestamp; } 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; } } 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; 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, }); static RideQualityReport evaluate({ required int totalSamples, required int validSamples, required int suspectSamples, required int rejectedSamples, required int timestampGaps, required int gpsFixCount, required double totalGpsAccuracy, 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, ); } 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; // Base score calculation (0 - 100) double score = (validRatio * 50.0) + (gpsCoverage * 30.0); if (avgAccuracy <= 5.0) { score += 20.0; } else if (avgAccuracy <= 15.0) { score += 15.0; } else if (avgAccuracy <= 35.0) { score += 8.0; } else { score += 2.0; } if (!isMountCalibrated) score -= 10.0; if (timestampGaps > 5) score -= math.min(15.0, timestampGaps * 2.0); if (rejectedSamples > 0) score -= math.min(20.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, ); } }