MotorSignal-App/lib/services/validation/telemetry_validator.dart

410 lines
14 KiB
Dart

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;
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 = <String>[];
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,
);
}
}