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

504 lines
18 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;
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) {
_totalValidated++;
ValidationStatus status = ValidationStatus.valid;
final List<String> reasons = [];
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 & 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 (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: 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;
// 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<String> 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<String> 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,
);
}
}