504 lines
18 KiB
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,
|
|
);
|
|
}
|
|
}
|
|
|