833 lines
28 KiB
Dart
833 lines
28 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:drift/drift.dart' as drift;
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import '../../core/version/app_identity.dart';
|
|
import '../../database/database.dart' hide FieldTestEvent, FieldTestSummary, TelemetrySample;
|
|
import '../../models/field_test_event.dart';
|
|
import '../../models/field_test_summary.dart';
|
|
import '../../models/telemetry_sample.dart';
|
|
import '../validation/telemetry_validator.dart';
|
|
|
|
final fieldTestModeEnabledProvider = StateNotifierProvider<FieldTestModeNotifier, bool>((ref) {
|
|
return FieldTestModeNotifier();
|
|
});
|
|
|
|
class FieldTestModeNotifier extends StateNotifier<bool> {
|
|
static const _prefKey = 'field_test_mode_enabled';
|
|
|
|
FieldTestModeNotifier() : super(false) {
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
state = prefs.getBool(_prefKey) ?? false;
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> setEnabled(bool enabled) async {
|
|
state = enabled;
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setBool(_prefKey, enabled);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
final fieldTestLoggerProvider = Provider<FieldTestLogger>((ref) {
|
|
final db = ref.watch(databaseProvider);
|
|
final logger = FieldTestLogger(db);
|
|
ref.onDispose(() => logger.dispose());
|
|
return logger;
|
|
});
|
|
|
|
final activeFieldTestSessionProvider = Provider<int?>((ref) {
|
|
return ref.watch(fieldTestLoggerProvider).activeRideId;
|
|
});
|
|
|
|
class FieldTestLogger {
|
|
final AppDatabase _db;
|
|
|
|
int? _activeRideId;
|
|
int? get activeRideId => _activeRideId;
|
|
bool get isSessionActive => _activeRideId != null;
|
|
DateTime? _sessionStartTime;
|
|
DateTime? get sessionStartTime => _sessionStartTime;
|
|
|
|
// In-memory circular buffer for UI timeline (latest 500 events)
|
|
final ListQueue<FieldTestEvent> _timelineBuffer = ListQueue(500);
|
|
List<FieldTestEvent> get timelineEvents => _timelineBuffer.toList();
|
|
|
|
final _timelineStreamController = StreamController<List<FieldTestEvent>>.broadcast();
|
|
Stream<List<FieldTestEvent>> get timelineStream => _timelineStreamController.stream;
|
|
|
|
// Bounded batch queue for database persistence (Capacity: 1,000 events)
|
|
static const int _maxQueueCapacity = 1000;
|
|
final List<FieldTestEventsCompanion> _eventBatch = [];
|
|
Timer? _flushTimer;
|
|
bool _isFlushing = false;
|
|
|
|
// Requirement 6: Memory / Queue / Database Observability Metrics
|
|
int _peakQueueDepth = 0;
|
|
int _totalEnqueued = 0;
|
|
int _totalFlushed = 0;
|
|
int _droppedEventsCount = 0;
|
|
String? _oldestDroppedItem;
|
|
String? _newestDroppedItem;
|
|
int _lastFlushDurationMs = 0;
|
|
int _failedFlushCount = 0;
|
|
|
|
int get currentQueueDepth => _eventBatch.length;
|
|
int get peakQueueDepth => _peakQueueDepth;
|
|
int get queueCapacity => _maxQueueCapacity;
|
|
int get totalEnqueued => _totalEnqueued;
|
|
int get totalFlushed => _totalFlushed;
|
|
int get droppedEventsCount => _droppedEventsCount;
|
|
String? get oldestDroppedItem => _oldestDroppedItem;
|
|
String? get newestDroppedItem => _newestDroppedItem;
|
|
int get lastFlushDurationMs => _lastFlushDurationMs;
|
|
int get failedFlushCount => _failedFlushCount;
|
|
|
|
// Requirement 4 & 5: Live Sensor & Timing Health Metrics
|
|
DateTime? _lastSampleTimestamp;
|
|
DateTime? _lastGpsTimestamp;
|
|
double _recentSampleRateHz = 0.0;
|
|
int _recentSampleCount = 0;
|
|
DateTime? _rateCalcWindowStart;
|
|
double _lastGpsAccuracy = 0.0;
|
|
int _lastGpsAgeMs = 0;
|
|
double _timingJitterSumMs = 0.0;
|
|
int _timingJitterCount = 0;
|
|
double _currentQualityScore = 100.0;
|
|
|
|
double get currentSampleRateHz => _recentSampleRateHz;
|
|
double get lastGpsAccuracy => _lastGpsAccuracy;
|
|
int get lastGpsAgeMs => _lastGpsAgeMs;
|
|
double get currentQualityEstimate => _currentQualityScore;
|
|
double get averageTimingJitterMs => _timingJitterCount > 0 ? _timingJitterSumMs / _timingJitterCount : 0.0;
|
|
|
|
// State-transition tracking for De-duplication
|
|
bool _isGpsStale = false;
|
|
DateTime? _gpsStaleStartTime;
|
|
int _gpsStaleMaxAgeMs = 0;
|
|
double? _gpsStaleMinAccuracy;
|
|
double? _gpsStaleMaxAccuracy;
|
|
double? _gpsStaleSpeed;
|
|
|
|
bool _isGpsAccuracyDegraded = false;
|
|
bool _isImuSaturated = false;
|
|
|
|
bool _isTelemetryGap = false;
|
|
DateTime? _telemetryGapStartTime;
|
|
int _maxTelemetryGapMs = 0;
|
|
|
|
DateTime? _lastTelemetryTime;
|
|
int _telemetryGapCount = 0;
|
|
int _gpsStaleCount = 0;
|
|
int _gpsJumpRejectCount = 0;
|
|
int _imuSaturationCount = 0;
|
|
int _accelerationEventCount = 0;
|
|
int _brakingEventCount = 0;
|
|
|
|
FieldTestLogger(this._db) {
|
|
_flushTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
|
flush();
|
|
});
|
|
}
|
|
|
|
AppIdentity? _sessionIdentity;
|
|
AppIdentity get sessionIdentity => _sessionIdentity ?? AppIdentity.current();
|
|
|
|
void startSession(int rideId, {String? notes, DateTime? timestamp}) {
|
|
_activeRideId = rideId;
|
|
_sessionStartTime = timestamp ?? DateTime.now();
|
|
_sessionIdentity = AppIdentity.current();
|
|
_timelineBuffer.clear();
|
|
_eventBatch.clear();
|
|
_resetStateTrackers();
|
|
|
|
log(
|
|
eventType: FieldTestEventType.rideStarted,
|
|
severity: EventSeverity.info,
|
|
message: 'Field test session started for ride #$rideId [v${sessionIdentity.displayString}]',
|
|
rideId: rideId,
|
|
timestamp: _sessionStartTime,
|
|
metadata: {
|
|
'software': sessionIdentity.toJson(),
|
|
'notes': ?notes,
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<FieldTestSummary> stopSession() async {
|
|
final rideId = _activeRideId;
|
|
if (rideId == null) {
|
|
throw StateError('No active field test session to stop');
|
|
}
|
|
|
|
log(
|
|
eventType: FieldTestEventType.rideStopped,
|
|
severity: EventSeverity.info,
|
|
message: 'Field test session completed for ride #$rideId',
|
|
rideId: rideId,
|
|
);
|
|
|
|
// Final database flush before generating summary
|
|
await flush();
|
|
|
|
final summary = await generateAndSaveSummary(rideId);
|
|
_activeRideId = null;
|
|
_sessionStartTime = null;
|
|
return summary;
|
|
}
|
|
|
|
void _resetStateTrackers() {
|
|
_isGpsStale = false;
|
|
_gpsStaleStartTime = null;
|
|
_gpsStaleMaxAgeMs = 0;
|
|
_gpsStaleMinAccuracy = null;
|
|
_gpsStaleMaxAccuracy = null;
|
|
_gpsStaleSpeed = null;
|
|
|
|
_isGpsAccuracyDegraded = false;
|
|
_isImuSaturated = false;
|
|
_isTelemetryGap = false;
|
|
_telemetryGapStartTime = null;
|
|
_maxTelemetryGapMs = 0;
|
|
|
|
_lastTelemetryTime = null;
|
|
_lastSampleTimestamp = null;
|
|
_lastGpsTimestamp = null;
|
|
_telemetryGapCount = 0;
|
|
_gpsStaleCount = 0;
|
|
_gpsJumpRejectCount = 0;
|
|
_imuSaturationCount = 0;
|
|
_accelerationEventCount = 0;
|
|
_brakingEventCount = 0;
|
|
|
|
_peakQueueDepth = 0;
|
|
_totalEnqueued = 0;
|
|
_totalFlushed = 0;
|
|
_droppedEventsCount = 0;
|
|
_oldestDroppedItem = null;
|
|
_newestDroppedItem = null;
|
|
_failedFlushCount = 0;
|
|
|
|
_recentSampleRateHz = 0.0;
|
|
_recentSampleCount = 0;
|
|
_rateCalcWindowStart = null;
|
|
_timingJitterSumMs = 0.0;
|
|
_timingJitterCount = 0;
|
|
_currentQualityScore = 100.0;
|
|
}
|
|
|
|
/// Non-blocking centralized logging entrypoint
|
|
void log({
|
|
required FieldTestEventType eventType,
|
|
required EventSeverity severity,
|
|
required String message,
|
|
int? rideId,
|
|
DateTime? timestamp,
|
|
DateTime? telemetryTimestamp,
|
|
int? gpsAgeMs,
|
|
double? speed,
|
|
double? latitude,
|
|
double? longitude,
|
|
double? gpsAccuracy,
|
|
double? leanAngle,
|
|
double? longitudinalG,
|
|
double? lateralG,
|
|
double? overallConfidence,
|
|
Map<String, dynamic>? metadata,
|
|
}) {
|
|
final effectiveRideId = rideId ?? _activeRideId;
|
|
final now = timestamp ?? DateTime.now();
|
|
|
|
final event = FieldTestEvent(
|
|
timestamp: now,
|
|
eventType: eventType,
|
|
rideId: effectiveRideId,
|
|
severity: severity,
|
|
message: message,
|
|
telemetryTimestamp: telemetryTimestamp,
|
|
gpsAgeMs: gpsAgeMs,
|
|
speed: speed,
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
gpsAccuracy: gpsAccuracy,
|
|
leanAngle: leanAngle,
|
|
longitudinalG: longitudinalG,
|
|
lateralG: lateralG,
|
|
overallConfidence: overallConfidence,
|
|
metadata: metadata,
|
|
);
|
|
|
|
// 1. Add to live in-memory timeline buffer
|
|
if (_timelineBuffer.length >= 500) {
|
|
_timelineBuffer.removeFirst();
|
|
}
|
|
_timelineBuffer.add(event);
|
|
if (!_timelineStreamController.isClosed) {
|
|
_timelineStreamController.add(_timelineBuffer.toList());
|
|
}
|
|
|
|
// 2. Queue for asynchronous batch persistence
|
|
_enqueueEvent(event);
|
|
}
|
|
|
|
void _enqueueEvent(FieldTestEvent event) {
|
|
_totalEnqueued++;
|
|
|
|
if (_eventBatch.length >= _maxQueueCapacity) {
|
|
// Prioritized Drop Policy: Drop lowest priority info events first
|
|
final dropIdx = _eventBatch.indexWhere((e) => e.severity.value == EventSeverity.info.displayName);
|
|
if (dropIdx != -1) {
|
|
final dropped = _eventBatch.removeAt(dropIdx);
|
|
_recordDrop(dropped.eventType.value);
|
|
} else if (event.severity == EventSeverity.critical) {
|
|
final dropped = _eventBatch.removeAt(0);
|
|
_recordDrop(dropped.eventType.value);
|
|
} else {
|
|
_recordDrop(event.eventType.displayName);
|
|
return;
|
|
}
|
|
}
|
|
|
|
_eventBatch.add(FieldTestEventsCompanion(
|
|
rideId: drift.Value(event.rideId),
|
|
timestamp: drift.Value(event.timestamp),
|
|
eventType: drift.Value(event.eventType.displayName),
|
|
severity: drift.Value(event.severity.displayName),
|
|
message: drift.Value(event.message),
|
|
telemetryTimestamp: drift.Value(event.telemetryTimestamp),
|
|
gpsAgeMs: drift.Value(event.gpsAgeMs),
|
|
speed: drift.Value(event.speed),
|
|
latitude: drift.Value(event.latitude),
|
|
longitude: drift.Value(event.longitude),
|
|
gpsAccuracy: drift.Value(event.gpsAccuracy),
|
|
leanAngle: drift.Value(event.leanAngle),
|
|
longitudinalG: drift.Value(event.longitudinalG),
|
|
lateralG: drift.Value(event.lateralG),
|
|
overallConfidence: drift.Value(event.overallConfidence),
|
|
metadata: drift.Value(event.metadata != null ? jsonEncode(event.metadata) : null),
|
|
));
|
|
|
|
if (_eventBatch.length > _peakQueueDepth) {
|
|
_peakQueueDepth = _eventBatch.length;
|
|
}
|
|
}
|
|
|
|
void _recordDrop(String eventType) {
|
|
_droppedEventsCount++;
|
|
_oldestDroppedItem ??= eventType;
|
|
_newestDroppedItem = eventType;
|
|
}
|
|
|
|
/// Process live telemetry sample and perform de-duplicated event generation
|
|
void processTelemetrySample(TelemetrySample sample, TelemetryQuality quality) {
|
|
final now = sample.timestamp;
|
|
|
|
// Requirement 4 & 5: Live sample rate & jitter calculation
|
|
_recentSampleCount++;
|
|
if (_rateCalcWindowStart == null) {
|
|
_rateCalcWindowStart = now;
|
|
} else {
|
|
final windowSec = now.difference(_rateCalcWindowStart!).inMilliseconds / 1000.0;
|
|
if (windowSec >= 1.0) {
|
|
_recentSampleRateHz = _recentSampleCount / windowSec;
|
|
_recentSampleCount = 0;
|
|
_rateCalcWindowStart = now;
|
|
}
|
|
}
|
|
|
|
if (sample.gpsAccuracy != null) _lastGpsAccuracy = sample.gpsAccuracy!;
|
|
if (sample.gpsAgeMs != null) _lastGpsAgeMs = sample.gpsAgeMs!;
|
|
_currentQualityScore = quality.overallConfidence * 100.0;
|
|
|
|
// 1. Timing Forensics: Check for timestamp regressions
|
|
if (_lastSampleTimestamp != null && now.isBefore(_lastSampleTimestamp!)) {
|
|
log(
|
|
eventType: FieldTestEventType.timestampError,
|
|
severity: EventSeverity.error,
|
|
message: 'Sample timestamp regression detected ($now < $_lastSampleTimestamp)',
|
|
telemetryTimestamp: now,
|
|
);
|
|
}
|
|
if (sample.gpsTimestamp != null && _lastGpsTimestamp != null && sample.gpsTimestamp!.isBefore(_lastGpsTimestamp!)) {
|
|
log(
|
|
eventType: FieldTestEventType.timestampError,
|
|
severity: EventSeverity.warning,
|
|
message: 'GPS fix timestamp regression (${sample.gpsTimestamp} < $_lastGpsTimestamp)',
|
|
telemetryTimestamp: now,
|
|
);
|
|
}
|
|
if (sample.gpsTimestamp != null) _lastGpsTimestamp = sample.gpsTimestamp;
|
|
|
|
// 2. Timing Jitter tracking (nominal interval 50ms)
|
|
if (_lastSampleTimestamp != null) {
|
|
final dtMs = now.difference(_lastSampleTimestamp!).inMilliseconds;
|
|
if (dtMs > 0 && dtMs < 500) {
|
|
final jitter = (dtMs - 50).abs().toDouble();
|
|
_timingJitterSumMs += jitter;
|
|
_timingJitterCount++;
|
|
}
|
|
}
|
|
_lastSampleTimestamp = now;
|
|
|
|
// 3. Telemetry Gap Detection & Recovery
|
|
if (_lastTelemetryTime != null) {
|
|
final deltaMs = now.difference(_lastTelemetryTime!).inMilliseconds;
|
|
if (deltaMs > 2000) {
|
|
_telemetryGapCount++;
|
|
if (deltaMs > _maxTelemetryGapMs) _maxTelemetryGapMs = deltaMs;
|
|
if (!_isTelemetryGap) {
|
|
_isTelemetryGap = true;
|
|
_telemetryGapStartTime = _lastTelemetryTime;
|
|
log(
|
|
eventType: FieldTestEventType.telemetryGap,
|
|
severity: EventSeverity.warning,
|
|
message: 'Telemetry stream gap detected (${deltaMs}ms)',
|
|
telemetryTimestamp: sample.timestamp,
|
|
speed: sample.speed,
|
|
metadata: {'gapMs': deltaMs},
|
|
);
|
|
}
|
|
} else if (_isTelemetryGap && deltaMs < 200) {
|
|
_isTelemetryGap = false;
|
|
final gapDuration = _telemetryGapStartTime != null
|
|
? now.difference(_telemetryGapStartTime!).inMilliseconds / 1000.0
|
|
: 0.0;
|
|
log(
|
|
eventType: FieldTestEventType.telemetryRecovered,
|
|
severity: EventSeverity.info,
|
|
message: 'Telemetry stream recovered after ${gapDuration.toStringAsFixed(1)}s',
|
|
telemetryTimestamp: sample.timestamp,
|
|
speed: sample.speed,
|
|
metadata: {'gapDurationSeconds': gapDuration},
|
|
);
|
|
}
|
|
}
|
|
_lastTelemetryTime = now;
|
|
|
|
// 4. GPS Freshness / Stale De-duplication
|
|
final gpsAge = sample.gpsAgeMs ?? 0;
|
|
final speedMph = sample.speed ?? 0.0;
|
|
if (gpsAge > 2000 && speedMph > 3.0) {
|
|
if (!_isGpsStale) {
|
|
_isGpsStale = true;
|
|
_gpsStaleStartTime = now;
|
|
_gpsStaleMaxAgeMs = gpsAge;
|
|
_gpsStaleMinAccuracy = sample.gpsAccuracy;
|
|
_gpsStaleMaxAccuracy = sample.gpsAccuracy;
|
|
_gpsStaleSpeed = speedMph;
|
|
_gpsStaleCount++;
|
|
|
|
log(
|
|
eventType: FieldTestEventType.gpsStale,
|
|
severity: EventSeverity.warning,
|
|
message: 'GPS fix became stale (${gpsAge}ms age at ${speedMph.toStringAsFixed(1)} MPH)',
|
|
telemetryTimestamp: sample.timestamp,
|
|
gpsAgeMs: gpsAge,
|
|
speed: speedMph,
|
|
gpsAccuracy: sample.gpsAccuracy,
|
|
latitude: sample.latitude,
|
|
longitude: sample.longitude,
|
|
);
|
|
} else {
|
|
if (gpsAge > _gpsStaleMaxAgeMs) _gpsStaleMaxAgeMs = gpsAge;
|
|
if (sample.gpsAccuracy != null) {
|
|
if (_gpsStaleMinAccuracy == null || sample.gpsAccuracy! < _gpsStaleMinAccuracy!) {
|
|
_gpsStaleMinAccuracy = sample.gpsAccuracy;
|
|
}
|
|
if (_gpsStaleMaxAccuracy == null || sample.gpsAccuracy! > _gpsStaleMaxAccuracy!) {
|
|
_gpsStaleMaxAccuracy = sample.gpsAccuracy;
|
|
}
|
|
}
|
|
}
|
|
} else if (_isGpsStale && gpsAge <= 500) {
|
|
_isGpsStale = false;
|
|
final staleDuration = _gpsStaleStartTime != null
|
|
? now.difference(_gpsStaleStartTime!).inMilliseconds / 1000.0
|
|
: 0.0;
|
|
|
|
log(
|
|
eventType: FieldTestEventType.gpsRecovered,
|
|
severity: EventSeverity.info,
|
|
message: 'GPS fix recovered after ${staleDuration.toStringAsFixed(1)}s (max age ${_gpsStaleMaxAgeMs}ms)',
|
|
telemetryTimestamp: sample.timestamp,
|
|
gpsAgeMs: gpsAge,
|
|
speed: speedMph,
|
|
gpsAccuracy: sample.gpsAccuracy,
|
|
latitude: sample.latitude,
|
|
longitude: sample.longitude,
|
|
metadata: {
|
|
'staleStartTime': _gpsStaleStartTime?.toIso8601String(),
|
|
'staleDurationSeconds': staleDuration,
|
|
'maxGpsAgeMs': _gpsStaleMaxAgeMs,
|
|
'minAccuracy': _gpsStaleMinAccuracy,
|
|
'maxAccuracy': _gpsStaleMaxAccuracy,
|
|
'speedDuringOutage': _gpsStaleSpeed,
|
|
},
|
|
);
|
|
}
|
|
|
|
// 5. GPS Accuracy Degradation De-duplication
|
|
final accuracy = sample.gpsAccuracy ?? 0.0;
|
|
if (accuracy > 35.0) {
|
|
if (!_isGpsAccuracyDegraded) {
|
|
_isGpsAccuracyDegraded = true;
|
|
log(
|
|
eventType: FieldTestEventType.gpsAccuracyDegraded,
|
|
severity: EventSeverity.warning,
|
|
message: 'GPS accuracy degraded to ${accuracy.toStringAsFixed(1)}m',
|
|
telemetryTimestamp: sample.timestamp,
|
|
gpsAccuracy: accuracy,
|
|
speed: speedMph,
|
|
);
|
|
}
|
|
} else if (_isGpsAccuracyDegraded && accuracy <= 15.0) {
|
|
_isGpsAccuracyDegraded = false;
|
|
log(
|
|
eventType: FieldTestEventType.gpsRecovered,
|
|
severity: EventSeverity.info,
|
|
message: 'GPS accuracy restored to precision ${accuracy.toStringAsFixed(1)}m',
|
|
telemetryTimestamp: sample.timestamp,
|
|
gpsAccuracy: accuracy,
|
|
speed: speedMph,
|
|
);
|
|
}
|
|
|
|
// 6. IMU Saturation De-duplication
|
|
if (quality.isSensorSaturated) {
|
|
_imuSaturationCount++;
|
|
if (!_isImuSaturated) {
|
|
_isImuSaturated = true;
|
|
log(
|
|
eventType: FieldTestEventType.imuSaturation,
|
|
severity: EventSeverity.error,
|
|
message: 'IMU accelerometer saturation limit exceeded (>120 m/s²)',
|
|
telemetryTimestamp: sample.timestamp,
|
|
longitudinalG: sample.longitudinalG,
|
|
lateralG: sample.lateralG,
|
|
);
|
|
}
|
|
} else if (_isImuSaturated) {
|
|
_isImuSaturated = false;
|
|
log(
|
|
eventType: FieldTestEventType.imuRecovered,
|
|
severity: EventSeverity.info,
|
|
message: 'IMU accelerometer returned to normal operating range',
|
|
telemetryTimestamp: sample.timestamp,
|
|
);
|
|
}
|
|
|
|
// 7. GPS Jump Rejection
|
|
if (quality.reasons.any((r) => r.contains('jump') || r.contains('Teleportation'))) {
|
|
_gpsJumpRejectCount++;
|
|
log(
|
|
eventType: FieldTestEventType.gpsJumpRejected,
|
|
severity: EventSeverity.error,
|
|
message: 'Impossible GPS velocity/position jump rejected',
|
|
telemetryTimestamp: sample.timestamp,
|
|
speed: speedMph,
|
|
latitude: sample.latitude,
|
|
longitude: sample.longitude,
|
|
);
|
|
}
|
|
|
|
// 8. Significant Acceleration / Braking Events
|
|
final longG = sample.longitudinalG ?? 0.0;
|
|
if (longG > 0.60 && speedMph > 5.0) {
|
|
_accelerationEventCount++;
|
|
log(
|
|
eventType: FieldTestEventType.accelerationEvent,
|
|
severity: EventSeverity.info,
|
|
message: 'Hard Acceleration Event: ${longG.toStringAsFixed(2)}g at ${speedMph.toStringAsFixed(1)} MPH',
|
|
telemetryTimestamp: sample.timestamp,
|
|
speed: speedMph,
|
|
longitudinalG: longG,
|
|
lateralG: sample.lateralG,
|
|
);
|
|
} else if (longG < -0.80 && speedMph > 5.0) {
|
|
_brakingEventCount++;
|
|
log(
|
|
eventType: FieldTestEventType.brakingEvent,
|
|
severity: EventSeverity.info,
|
|
message: 'Hard Braking Event: ${longG.toStringAsFixed(2)}g at ${speedMph.toStringAsFixed(1)} MPH',
|
|
telemetryTimestamp: sample.timestamp,
|
|
speed: speedMph,
|
|
longitudinalG: longG,
|
|
lateralG: sample.lateralG,
|
|
);
|
|
}
|
|
|
|
// 9. Sample Validation Status
|
|
if (quality.status == ValidationStatus.rejected) {
|
|
log(
|
|
eventType: FieldTestEventType.sampleRejected,
|
|
severity: EventSeverity.error,
|
|
message: 'Sample rejected: ${quality.reasons.join(", ")}',
|
|
telemetryTimestamp: sample.timestamp,
|
|
speed: speedMph,
|
|
overallConfidence: quality.overallConfidence,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Flushes buffered events to SQLite in a single transaction
|
|
Future<void> flush() async {
|
|
if (_isFlushing || _eventBatch.isEmpty) return;
|
|
_isFlushing = true;
|
|
final stopwatch = Stopwatch()..start();
|
|
|
|
try {
|
|
final toWrite = List<FieldTestEventsCompanion>.from(_eventBatch);
|
|
_eventBatch.clear();
|
|
|
|
await _db.batch((batch) {
|
|
batch.insertAll(_db.fieldTestEvents, toWrite);
|
|
});
|
|
|
|
_totalFlushed += toWrite.length;
|
|
stopwatch.stop();
|
|
_lastFlushDurationMs = stopwatch.elapsedMilliseconds;
|
|
} catch (e) {
|
|
_failedFlushCount++;
|
|
if (kDebugMode) print('[FieldTestLogger] Flush error: $e');
|
|
} finally {
|
|
_isFlushing = false;
|
|
}
|
|
}
|
|
|
|
/// Generates forensic summary from recorded ride telemetry and events
|
|
Future<FieldTestSummary> generateAndSaveSummary(int rideId, {bool wasRecovered = false}) async {
|
|
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
|
|
final startTime = ride?.startTime ?? DateTime.now();
|
|
final endTime = ride?.endTime ?? DateTime.now();
|
|
final duration = endTime.difference(startTime);
|
|
|
|
// Query all samples for this ride
|
|
final samples = await (_db.select(_db.telemetrySamples)
|
|
..where((t) => t.rideId.equals(rideId))
|
|
..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp)])
|
|
).get();
|
|
|
|
final totalSamples = samples.length;
|
|
int validSamples = 0;
|
|
int suspectSamples = 0;
|
|
int rejectedSamples = 0;
|
|
|
|
int gpsFixCount = 0;
|
|
double accuracySum = 0.0;
|
|
double? worstAccuracy;
|
|
int? maxGpsAge;
|
|
double maxSpeed = 0.0;
|
|
double maxLean = 0.0;
|
|
double maxLongG = 0.0;
|
|
double maxLatG = 0.0;
|
|
|
|
final validator = TelemetryValidator();
|
|
|
|
for (final s in samples) {
|
|
final sample = TelemetrySample(
|
|
timestamp: s.timestamp,
|
|
latitude: s.latitude,
|
|
longitude: s.longitude,
|
|
speed: s.speed,
|
|
heading: s.heading,
|
|
gpsAccuracy: s.gpsAccuracy,
|
|
leanAngle: s.leanAngle,
|
|
longitudinalG: s.longitudinalG,
|
|
lateralG: s.lateralG,
|
|
);
|
|
|
|
final q = validator.validate(sample);
|
|
if (q.isValid) {
|
|
validSamples++;
|
|
} else if (q.isSuspect) {
|
|
suspectSamples++;
|
|
} else {
|
|
rejectedSamples++;
|
|
}
|
|
|
|
if (s.latitude != null && s.longitude != null) {
|
|
gpsFixCount++;
|
|
}
|
|
if (s.gpsAccuracy != null) {
|
|
accuracySum += s.gpsAccuracy!;
|
|
if (worstAccuracy == null || s.gpsAccuracy! > worstAccuracy) {
|
|
worstAccuracy = s.gpsAccuracy;
|
|
}
|
|
}
|
|
final speedVal = s.speed ?? 0.0;
|
|
final isMoving = speedVal >= 3.0;
|
|
|
|
if (s.speed != null && s.speed! > maxSpeed && s.speed! < 223.7) {
|
|
maxSpeed = s.speed!;
|
|
}
|
|
if (s.leanAngle != null && s.leanAngle!.abs() <= 65.0) {
|
|
if (isMoving && s.leanAngle!.abs() > maxLean) {
|
|
maxLean = s.leanAngle!.abs();
|
|
}
|
|
}
|
|
if (s.longitudinalG != null && s.longitudinalG!.abs() <= 2.5) {
|
|
if (isMoving && s.longitudinalG!.abs() > maxLongG) {
|
|
maxLongG = s.longitudinalG!.abs();
|
|
}
|
|
}
|
|
if (s.lateralG != null && s.lateralG!.abs() <= 2.5) {
|
|
if (isMoving && s.lateralG!.abs() > maxLatG) {
|
|
maxLatG = s.lateralG!.abs();
|
|
}
|
|
}
|
|
}
|
|
|
|
final validPct = totalSamples > 0 ? (validSamples / totalSamples) * 100.0 : 100.0;
|
|
final suspectPct = totalSamples > 0 ? (suspectSamples / totalSamples) * 100.0 : 0.0;
|
|
final rejectedPct = totalSamples > 0 ? (rejectedSamples / totalSamples) * 100.0 : 0.0;
|
|
final avgAccuracy = gpsFixCount > 0 ? accuracySum / gpsFixCount : null;
|
|
|
|
// Performance Runs for this ride
|
|
final perfRuns = await (_db.select(_db.performanceRuns)
|
|
..where((p) => p.rideId.equals(rideId))
|
|
).get();
|
|
|
|
final zeroTo60Runs = perfRuns.where((p) => p.type == '0-60').toList();
|
|
final zeroTo100Runs = perfRuns.where((p) => p.type == '0-100').toList();
|
|
double? best0to60;
|
|
double? best0to100;
|
|
|
|
for (final r in zeroTo60Runs) {
|
|
if (best0to60 == null || r.timeMs < best0to60) best0to60 = r.timeMs;
|
|
}
|
|
for (final r in zeroTo100Runs) {
|
|
if (best0to100 == null || r.timeMs < best0to100) best0to100 = r.timeMs;
|
|
}
|
|
|
|
// Query events count
|
|
final events = await (_db.select(_db.fieldTestEvents)..where((e) => e.rideId.equals(rideId))).get();
|
|
|
|
final qualityReport = RideQualityReport.evaluate(
|
|
totalSamples: totalSamples,
|
|
validSamples: validSamples,
|
|
suspectSamples: suspectSamples,
|
|
rejectedSamples: rejectedSamples,
|
|
timestampGaps: _telemetryGapCount,
|
|
gpsFixCount: gpsFixCount,
|
|
totalGpsAccuracy: accuracySum,
|
|
databaseSamplesLost: _droppedEventsCount,
|
|
imuSaturationCount: _imuSaturationCount,
|
|
isMountCalibrated: true,
|
|
);
|
|
|
|
final summary = FieldTestSummary(
|
|
rideId: rideId,
|
|
startTime: startTime,
|
|
endTime: endTime,
|
|
duration: duration,
|
|
totalSamples: totalSamples,
|
|
validSamples: validSamples,
|
|
suspectSamples: suspectSamples,
|
|
rejectedSamples: rejectedSamples,
|
|
validPercentage: validPct,
|
|
suspectPercentage: suspectPct,
|
|
rejectedPercentage: rejectedPct,
|
|
gpsFixCount: gpsFixCount,
|
|
gpsStaleEventCount: _gpsStaleCount,
|
|
gpsJumpRejectCount: _gpsJumpRejectCount,
|
|
maxGpsAgeMs: maxGpsAge,
|
|
averageGpsAccuracy: avgAccuracy,
|
|
worstGpsAccuracy: worstAccuracy,
|
|
telemetryGapCount: _telemetryGapCount,
|
|
maxTelemetryGapMs: _maxTelemetryGapMs > 0 ? _maxTelemetryGapMs : null,
|
|
imuSaturationCount: _imuSaturationCount,
|
|
maxSpeedMph: maxSpeed > 0 ? maxSpeed : null,
|
|
maxLeanAngle: maxLean > 0 ? maxLean : null,
|
|
maxLongitudinalG: maxLongG > 0 ? maxLongG : null,
|
|
maxLateralG: maxLatG > 0 ? maxLatG : null,
|
|
accelerationEventCount: _accelerationEventCount,
|
|
brakingEventCount: _brakingEventCount,
|
|
zeroTo60Count: zeroTo60Runs.length,
|
|
zeroTo100Count: zeroTo100Runs.length,
|
|
bestZeroTo60: best0to60,
|
|
bestZeroTo100: best0to100,
|
|
databaseSamplesWritten: totalSamples,
|
|
databaseSamplesLost: _droppedEventsCount,
|
|
eventCount: events.length,
|
|
overallConfidence: qualityReport.overallScore / 100.0,
|
|
rideQualityScore: qualityReport.overallScore,
|
|
rideQualityRating: qualityReport.rating,
|
|
wasRecoveredAfterCrash: wasRecovered,
|
|
wasInterrupted: ride?.endTime == null || wasRecovered,
|
|
exportIntegrity: 'VERIFIED_OK',
|
|
);
|
|
|
|
// Save summary to database
|
|
await _db.into(_db.fieldTestSummaries).insertOnConflictUpdate(
|
|
FieldTestSummariesCompanion.insert(
|
|
rideId: rideId,
|
|
startTime: startTime,
|
|
endTime: drift.Value(endTime),
|
|
durationSeconds: drift.Value(duration.inSeconds),
|
|
totalSamples: drift.Value(totalSamples),
|
|
validSamples: drift.Value(validSamples),
|
|
suspectSamples: drift.Value(suspectSamples),
|
|
rejectedSamples: drift.Value(rejectedSamples),
|
|
validPercentage: drift.Value(validPct),
|
|
suspectPercentage: drift.Value(suspectPct),
|
|
rejectedPercentage: drift.Value(rejectedPct),
|
|
gpsFixCount: drift.Value(gpsFixCount),
|
|
gpsStaleEventCount: drift.Value(_gpsStaleCount),
|
|
gpsJumpRejectCount: drift.Value(_gpsJumpRejectCount),
|
|
maxGpsAgeMs: drift.Value(maxGpsAge),
|
|
averageGpsAccuracy: drift.Value(avgAccuracy),
|
|
worstGpsAccuracy: drift.Value(worstAccuracy),
|
|
telemetryGapCount: drift.Value(_telemetryGapCount),
|
|
maxTelemetryGapMs: drift.Value(_maxTelemetryGapMs > 0 ? _maxTelemetryGapMs : null),
|
|
imuSaturationCount: drift.Value(_imuSaturationCount),
|
|
maxSpeedMph: drift.Value(maxSpeed > 0 ? maxSpeed : null),
|
|
maxLeanAngle: drift.Value(maxLean > 0 ? maxLean : null),
|
|
maxLongitudinalG: drift.Value(maxLongG > 0 ? maxLongG : null),
|
|
maxLateralG: drift.Value(maxLatG > 0 ? maxLatG : null),
|
|
accelerationEventCount: drift.Value(_accelerationEventCount),
|
|
brakingEventCount: drift.Value(_brakingEventCount),
|
|
zeroTo60Count: drift.Value(zeroTo60Runs.length),
|
|
zeroTo100Count: drift.Value(zeroTo100Runs.length),
|
|
bestZeroTo60: drift.Value(best0to60),
|
|
bestZeroTo100: drift.Value(best0to100),
|
|
databaseSamplesWritten: drift.Value(totalSamples),
|
|
databaseSamplesLost: drift.Value(_droppedEventsCount),
|
|
eventCount: drift.Value(events.length),
|
|
overallConfidence: drift.Value(qualityReport.overallScore / 100.0),
|
|
rideQualityScore: drift.Value(qualityReport.overallScore.toDouble()),
|
|
rideQualityRating: drift.Value(qualityReport.rating),
|
|
wasRecoveredAfterCrash: drift.Value(wasRecovered),
|
|
wasInterrupted: drift.Value(ride?.endTime == null || wasRecovered),
|
|
exportIntegrity: const drift.Value('VERIFIED_OK'),
|
|
),
|
|
);
|
|
|
|
return summary;
|
|
}
|
|
|
|
Future<void> clearAllSessions() async {
|
|
await _db.delete(_db.fieldTestEvents).go();
|
|
await _db.delete(_db.fieldTestSummaries).go();
|
|
_timelineBuffer.clear();
|
|
if (!_timelineStreamController.isClosed) {
|
|
_timelineStreamController.add([]);
|
|
}
|
|
}
|
|
|
|
void dispose() {
|
|
_flushTimer?.cancel();
|
|
flush();
|
|
_timelineStreamController.close();
|
|
}
|
|
}
|