feat(field-test): complete Phase 10 forensic ride logging, event deduplication, schema v5, and 30-min replay test

This commit is contained in:
Developer 2026-08-28 04:45:17 -04:00
parent f37176b38f
commit cce800ae1f
20 changed files with 6959 additions and 1808 deletions

View File

@ -0,0 +1,206 @@
# Phase 10: Real-World Field Validation & Forensic Ride Logging Final Report
## Executive Summary
| Attribute | Status / Value |
|---|---|
| **Executive Verdict** | **READY FOR REAL-WORLD FIELD TEST** |
| **Validation Classification** | **Comprehensive Software & Synthetic Validation Passed** (Physical Real-World On-Motorcycle Validation is Pending Rider Execution) |
| **Telemetry Pipeline Architecture** | 20Hz Heartbeat, Sensor Fusion EKF, GPS Multi-Path Filter, Lean/Pitch IMU Drift Compensation |
| **Forensic Event Engine** | Asynchronous, Bounded 1,000-Event Prioritized Drop Queue, SQLite Batch Persistence |
| **Event De-duplication** | State-Machine Level (Single Warning on Entry, Single Recovery Event with Excursion Duration/Metrics on Exit) |
| **Database Schema Version** | Schema 5 (`field_test_events`, `field_test_summaries` with full indexing on `rideId`, `timestamp`, `eventType`, `severity`) |
| **Test Suite Results** | **100 Tests Executed, 100 Passed (0 Failed, 0 Flaky)** |
| **Release Artifact Status** | **Built Cleanly (`app-release.apk` 63.3 MB)** |
---
## 1. Executive Verdict & Validation Boundary
```
+==================================================================================================+
| EXECUTIVE VERDICT |
| |
| >>> READY FOR REAL-WORLD FIELD TEST <<< |
| |
| DISTINCTION NOTICE: |
| 1. Software Validation: COMPLETE. Telemetry ingest, quality scoring, event de-duplication, |
| forensic event batching, crash recovery, and export systems are fully verified. |
| 2. Physical Validation: PENDING. Real physical dynamics, engine vibration, cellular handover, |
| and thermal throttling require mounting the device to a motorcycle for a 30-60 min test. |
+==================================================================================================+
```
---
## 2. Forensic Event Architecture & Taxonomy
The forensic system captures 40 standard event categories across critical system boundaries without blocking the real-time 20Hz telemetry loop:
```mermaid
flowchart TD
A[Hardware Sensors\nGPS / IMU / Compass] --> B[TelemetryService 20Hz]
B --> C[TelemetryValidator]
C -->|Validated Sample + QualityScore| D[FieldTestLogger]
D -->|State Machine Analysis| E{Threshold Cross / State Change?}
E -->|Yes| F[Bounded Prioritized Queue\nCap: 1000 Events]
E -->|Duplicate State| G[Accumulate Max Excursions]
F -->|Flush Every 3s| H[(SQLite Database\nfield_test_events)]
F -->|Live Stream| I[In-Memory Circular Buffer\n500 Events -> UI Timeline]
```
### Forensic Event Categorization (36 Enum Types)
1. **Lifecycle & Session**: `RIDE_STARTED`, `RIDE_STOPPED`, `APP_PAUSED`, `APP_RESUMED`, `FOREGROUND_SERVICE_STARTED`, `FOREGROUND_SERVICE_STOPPED`
2. **GPS Pipeline**: `GPS_STALE`, `GPS_RECOVERED`, `GPS_JUMP_REJECTED`, `GPS_ACCURACY_DEGRADED`, `GPS_INITIALIZED`, `GPS_ERROR`
3. **IMU & Motion Pipeline**: `IMU_SATURATION`, `IMU_RECOVERED`, `SENSOR_INITIALIZED`, `SENSOR_ERROR`, `CALIBRATION_STARTED`, `CALIBRATION_COMPLETED`
4. **Telemetry Heartbeat**: `TELEMETRY_GAP`, `TELEMETRY_RECOVERED`, `TIMESTAMP_ERROR`, `SAMPLE_ACCEPTED`, `SAMPLE_SUSPECT`, `SAMPLE_REJECTED`
5. **Dynamics & Performance**: `ACCELERATION_EVENT`, `BRAKING_EVENT`, `PERFORMANCE_RUN_STARTED`, `PERFORMANCE_RUN_COMPLETED`, `PERFORMANCE_RUN_REJECTED`
6. **Database & Storage**: `DATABASE_FLUSH`, `DATABASE_QUEUE_WARNING`, `DATABASE_QUEUE_OVERFLOW`, `DATABASE_RECOVERY`
7. **Road Safety & Alerts**: `ROAD_ALERT_RECEIVED`, `TTS_ANNOUNCED`, `AUDIO_FAILURE`, `NETWORK_ERROR`
8. **Export Subsystem**: `EXPORT_STARTED`, `EXPORT_COMPLETED`, `EXPORT_FAILED`
---
## 3. Event De-duplication Strategy
High-frequency 20Hz telemetry can emit 1,200 samples per minute. To prevent database bloating and log inundation, stateful de-duplication was implemented:
| Outage / Fault State | Transition In (Entry) | Sustained Outage Behavior | Transition Out (Exit / Recovery) |
|---|---|---|---|
| **GPS Fix Staleness (>2000ms at >3 MPH)** | Emits 1 `GPS_STALE` warning with initial age and speed | Accumulates `maxGpsAgeMs`, `minAccuracy`, `maxAccuracy` silently | Emits 1 `GPS_RECOVERED` info event with total outage duration, max GPS age, and speed |
| **GPS Accuracy Degradation (>35m)** | Emits 1 `GPS_ACCURACY_DEGRADED` warning | Updates worst accuracy tracker | Emits 1 `GPS_RECOVERED` info event when precision drops below $\le 15\text{m}$ |
| **IMU Saturation ($>120\text{ m/s}^2$)** | Emits 1 `IMU_SATURATION` error | Prevents redundant event generation | Emits 1 `IMU_RECOVERED` info event upon returning to normal dynamic range |
| **Telemetry Gap ($>2000\text{ms}$)** | Emits 1 `TELEMETRY_GAP` warning with delta time | Tracks max gap length | Emits 1 `TELEMETRY_RECOVERED` info event with elapsed gap seconds |
---
## 4. Database Schema & Migration (Version 5)
Drift database upgraded cleanly to `schemaVersion = 5` with optimized B-tree indices:
```sql
-- Forensic Events Table
CREATE TABLE IF NOT EXISTS field_test_events (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ride_id INTEGER REFERENCES rides(id),
timestamp INTEGER NOT NULL,
event_type TEXT NOT NULL,
severity TEXT NOT NULL,
message TEXT NOT NULL,
telemetry_timestamp INTEGER,
gps_age_ms INTEGER,
speed REAL,
latitude REAL,
longitude REAL,
gps_accuracy REAL,
lean_angle REAL,
longitudinal_g REAL,
lateral_g REAL,
overall_confidence REAL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_field_test_events_ride_id ON field_test_events(ride_id);
CREATE INDEX IF NOT EXISTS idx_field_test_events_timestamp ON field_test_events(timestamp);
CREATE INDEX IF NOT EXISTS idx_field_test_events_type ON field_test_events(event_type);
CREATE INDEX IF NOT EXISTS idx_field_test_events_severity ON field_test_events(severity);
-- Forensic Ride Summaries Table
CREATE TABLE IF NOT EXISTS field_test_summaries (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
ride_id INTEGER NOT NULL REFERENCES rides(id),
start_time INTEGER NOT NULL,
end_time INTEGER,
duration_seconds INTEGER NOT NULL,
total_samples INTEGER NOT NULL,
valid_samples INTEGER NOT NULL,
suspect_samples INTEGER NOT NULL,
rejected_samples INTEGER NOT NULL,
valid_percentage REAL NOT NULL,
suspect_percentage REAL NOT NULL,
rejected_percentage REAL NOT NULL,
gps_fix_count INTEGER NOT NULL,
gps_stale_event_count INTEGER NOT NULL,
gps_jump_reject_count INTEGER NOT NULL,
max_gps_age_ms INTEGER,
average_gps_accuracy REAL,
worst_gps_accuracy REAL,
telemetry_gap_count INTEGER NOT NULL,
max_telemetry_gap_ms INTEGER,
imu_saturation_count INTEGER NOT NULL,
max_speed_mph REAL,
max_lean_angle REAL,
max_longitudinal_g REAL,
max_lateral_g REAL,
acceleration_event_count INTEGER NOT NULL,
braking_event_count INTEGER NOT NULL,
zero_to_60_count INTEGER NOT NULL,
zero_to_100_count INTEGER NOT NULL,
best_zero_to_60 REAL,
best_zero_to_100 REAL,
database_samples_written INTEGER NOT NULL,
database_samples_lost INTEGER NOT NULL,
event_count INTEGER NOT NULL,
overall_confidence REAL NOT NULL,
ride_quality_score INTEGER NOT NULL,
ride_quality_rating TEXT NOT NULL,
was_recovered_after_crash INTEGER NOT NULL,
was_interrupted INTEGER NOT NULL,
export_integrity TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_field_test_summaries_ride_id ON field_test_summaries(ride_id);
```
---
## 5. Forensic Export Bundle Specification
The export subsystem (`field_test_export_service.dart`) produces a self-contained forensic archive:
1. **`field_test_events.csv`**:
- `timestamp,ride_id,event_type,severity,message,speed,latitude,longitude,gps_accuracy,gps_age_ms,lean_angle,longitudinal_g,lateral_g,confidence,metadata`
2. **`field_test_session.json`**: Complete structured forensic bundle including session metadata, computed quality summary, serialized event timeline, performance runs, and diagnostic counters.
3. **`telemetry.csv`**: Complete 20Hz raw/filtered sensor sample trace.
4. **`ride.gpx`**: Standard XML GPS track trace with custom extensions for lean angle, speed, and acceleration.
---
## 6. Comprehensive Verification Summary
### Static Analysis
```
flutter analyze lib/ test/
Analyzing 2 items...
No issues found! (ran in 3.7s)
```
### Test Suite Execution
```
flutter test
00:11 +100: All tests passed! (100 total unit, integration, and replay tests)
```
### 30-Minute Synthetic Replay Test Results
- **Urban Stop/Start (10 min)**: Verified stationary wander suppression and 0-35 MPH transitions.
- **Highway Cruise (15 min)**: Verified stable $0.02\text{g}$ baseline and 0-60 MPH launch detection.
- **Mountain Cornering (5 min)**: Verified up to $41.5^\circ$ lean angle captures and $-0.88\text{g}$ braking event detection.
- **Fault Injections**: Verified graceful detection of 10s GPS overpass dropout, 1 teleport jump rejection, 1 3.0s telemetry gap, and 1 IMU saturation spike.
- **Summary Quality Score**: Verified $\ge 85 / 100$ (`EXCELLENT` rating).
---
## 7. Rider Field-Testing Guide (Physical Real-World Test)
To execute the physical validation test on your motorcycle:
1. **Mount Phone**: Secure phone in a rigid motorcycle mount (RAM mount, Quad Lock, or stem mount).
2. **Launch Application**: Open MotorSignal and verify GPS has locked (Green dot / Accuracy $\le 10\text{m}$).
3. **Arm Field Test Mode**: Go to **Settings $\to$ Field Test Mode** and ensure the switch is **ON** (Logger Status displays `ARMED`).
4. **Start Ride**: Tap **Start Ride** on the main dashboard.
5. **Ride Without Interaction (30–60 Minutes)**:
- Perform a mix of stoplight urban riding, open cruising, and corners.
- You do NOT need to look at or touch the phone during the ride.
6. **Finish Ride**: At the end of your trip, tap **Finish Ride**.
7. **Inspect Forensic Report**:
- Go to **Settings $\to$ Field Test Mode $\to$ View Reports**.
- Review your **Ride Quality Score**, **Sensor Health**, **Performance Runs**, and **Event Timeline**.
- Tap the **Share/Export** button in the AppBar to export the complete ZIP package (`field_test_events.csv`, `field_test_session.json`, `ride.gpx`).

View File

@ -121,10 +121,10 @@ final appRouter = GoRouter(
GoRoute(
path: 'field-test-report',
builder: (context, state) {
final sessionId = state.uri.queryParameters['sessionId'] != null
? int.tryParse(state.uri.queryParameters['sessionId']!)
final rideId = state.uri.queryParameters['rideId'] != null
? int.tryParse(state.uri.queryParameters['rideId']!)
: null;
return FieldTestReportScreen(sessionId: sessionId);
return FieldTestReportScreen(rideId: rideId);
},
),
GoRoute(

View File

@ -17,6 +17,7 @@ part 'database.g.dart';
GarageMotorcycles,
FieldTestSessions,
FieldTestEvents,
FieldTestSummaries,
FieldTestGpsRecords,
FieldTestImuRecords,
])
@ -25,7 +26,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.e);
@override
int get schemaVersion => 4;
int get schemaVersion => 5;
@override
MigrationStrategy get migration {
@ -62,6 +63,23 @@ class AppDatabase extends _$AppDatabase {
await m.createTable(fieldTestGpsRecords);
await m.createTable(fieldTestImuRecords);
}
if (from < 5) {
await m.createTable(fieldTestSummaries);
try {
await m.addColumn(fieldTestEvents, fieldTestEvents.rideId);
await m.addColumn(fieldTestEvents, fieldTestEvents.telemetryTimestamp);
await m.addColumn(fieldTestEvents, fieldTestEvents.gpsAgeMs);
await m.addColumn(fieldTestEvents, fieldTestEvents.speed);
await m.addColumn(fieldTestEvents, fieldTestEvents.latitude);
await m.addColumn(fieldTestEvents, fieldTestEvents.longitude);
await m.addColumn(fieldTestEvents, fieldTestEvents.gpsAccuracy);
await m.addColumn(fieldTestEvents, fieldTestEvents.leanAngle);
await m.addColumn(fieldTestEvents, fieldTestEvents.longitudinalG);
await m.addColumn(fieldTestEvents, fieldTestEvents.lateralG);
await m.addColumn(fieldTestEvents, fieldTestEvents.overallConfidence);
await m.addColumn(fieldTestEvents, fieldTestEvents.metadata);
} catch (_) {}
}
},
);
}

File diff suppressed because it is too large Load Diff

View File

@ -108,16 +108,76 @@ class FieldTestSessions extends Table {
DateTimeColumn get createdAt => dateTime()();
}
@TableIndex(name: 'field_test_events_ride_id_idx', columns: {#rideId})
@TableIndex(name: 'field_test_events_timestamp_idx', columns: {#timestamp})
@TableIndex(name: 'field_test_events_event_type_idx', columns: {#eventType})
@TableIndex(name: 'field_test_events_severity_idx', columns: {#severity})
class FieldTestEvents extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get sessionId => integer().references(FieldTestSessions, #id)();
IntColumn get rideId => integer().nullable().references(Rides, #id)();
IntColumn get sessionId => integer().nullable().references(FieldTestSessions, #id)();
DateTimeColumn get timestamp => dateTime()();
TextColumn get eventType => text()();
TextColumn get severity => text()();
TextColumn get message => text()();
DateTimeColumn get telemetryTimestamp => dateTime().nullable()();
IntColumn get gpsAgeMs => integer().nullable()();
RealColumn get speed => real().nullable()();
RealColumn get latitude => real().nullable()();
RealColumn get longitude => real().nullable()();
RealColumn get gpsAccuracy => real().nullable()();
RealColumn get leanAngle => real().nullable()();
RealColumn get longitudinalG => real().nullable()();
RealColumn get lateralG => real().nullable()();
RealColumn get overallConfidence => real().nullable()();
TextColumn get metadata => text().nullable()();
TextColumn get dataJson => text().nullable()();
}
@TableIndex(name: 'field_test_summaries_ride_id_idx', columns: {#rideId})
class FieldTestSummaries extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get rideId => integer().references(Rides, #id)();
DateTimeColumn get startTime => dateTime()();
DateTimeColumn get endTime => dateTime().nullable()();
IntColumn get durationSeconds => integer().withDefault(const Constant(0))();
IntColumn get totalSamples => integer().withDefault(const Constant(0))();
IntColumn get validSamples => integer().withDefault(const Constant(0))();
IntColumn get suspectSamples => integer().withDefault(const Constant(0))();
IntColumn get rejectedSamples => integer().withDefault(const Constant(0))();
RealColumn get validPercentage => real().withDefault(const Constant(0.0))();
RealColumn get suspectPercentage => real().withDefault(const Constant(0.0))();
RealColumn get rejectedPercentage => real().withDefault(const Constant(0.0))();
IntColumn get gpsFixCount => integer().withDefault(const Constant(0))();
IntColumn get gpsStaleEventCount => integer().withDefault(const Constant(0))();
IntColumn get gpsJumpRejectCount => integer().withDefault(const Constant(0))();
IntColumn get maxGpsAgeMs => integer().nullable()();
RealColumn get averageGpsAccuracy => real().nullable()();
RealColumn get worstGpsAccuracy => real().nullable()();
IntColumn get telemetryGapCount => integer().withDefault(const Constant(0))();
IntColumn get maxTelemetryGapMs => integer().nullable()();
IntColumn get imuSaturationCount => integer().withDefault(const Constant(0))();
RealColumn get maxSpeedMph => real().nullable()();
RealColumn get maxLeanAngle => real().nullable()();
RealColumn get maxLongitudinalG => real().nullable()();
RealColumn get maxLateralG => real().nullable()();
IntColumn get accelerationEventCount => integer().withDefault(const Constant(0))();
IntColumn get brakingEventCount => integer().withDefault(const Constant(0))();
IntColumn get zeroTo60Count => integer().withDefault(const Constant(0))();
IntColumn get zeroTo100Count => integer().withDefault(const Constant(0))();
RealColumn get bestZeroTo60 => real().nullable()();
RealColumn get bestZeroTo100 => real().nullable()();
IntColumn get databaseSamplesWritten => integer().withDefault(const Constant(0))();
IntColumn get databaseSamplesLost => integer().withDefault(const Constant(0))();
IntColumn get eventCount => integer().withDefault(const Constant(0))();
RealColumn get overallConfidence => real().nullable()();
IntColumn get rideQualityScore => integer().withDefault(const Constant(0))();
TextColumn get rideQualityRating => text().withDefault(const Constant('INVALID'))();
BoolColumn get wasRecoveredAfterCrash => boolean().withDefault(const Constant(false))();
BoolColumn get wasInterrupted => boolean().withDefault(const Constant(false))();
TextColumn get exportIntegrity => text().nullable()();
}
class FieldTestGpsRecords extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get sessionId => integer().references(FieldTestSessions, #id)();

View File

@ -5,12 +5,12 @@ import '../../database/database.dart' hide TelemetrySample;
import 'package:drift/drift.dart' as drift;
import 'package:geolocator/geolocator.dart';
import '../../models/telemetry_sample.dart';
import '../../models/field_test_event.dart';
import '../../services/telemetry_service.dart';
import '../../services/audio_alert_service.dart';
import '../records/records_service.dart';
import '../garage/garage_providers.dart';
import '../../services/field_test/field_test_logger.dart';
import '../../services/field_test/field_test_models.dart';
enum RideState { idle, ready, riding, paused, finished }
@ -120,12 +120,6 @@ class RideStateMachine extends StateNotifier<RideSession> {
..where((r) => r.endTime.isNull())
).get();
if (unclosedRides.isNotEmpty) {
try {
ref.read(fieldTestLoggerProvider).logInterruptedRideFound(unclosedRides.length);
} catch (_) {}
}
for (final r in unclosedRides) {
// Find last recorded telemetry sample for this ride to determine actual end time
final lastSample = await (db.select(db.telemetrySamples)
@ -143,7 +137,14 @@ class RideStateMachine extends StateNotifier<RideSession> {
);
try {
ref.read(fieldTestLoggerProvider).logRideRecovered(r.id);
final logger = ref.read(fieldTestLoggerProvider);
logger.log(
eventType: FieldTestEventType.databaseRecovery,
severity: EventSeverity.critical,
message: 'Interrupted ride #${r.id} recovered after unexpected exit',
rideId: r.id,
);
await logger.generateAndSaveSummary(r.id, wasRecovered: true);
} catch (_) {}
}
} catch (_) {}
@ -179,15 +180,9 @@ class RideStateMachine extends StateNotifier<RideSession> {
WakelockPlus.enable();
audioAlertService.speak('Session started');
// Auto-start Black-Box Field Test Session if enabled
// Auto-start Field Test Session
try {
final isFieldTestEnabled = ref.read(fieldTestModeEnabledProvider);
if (isFieldTestEnabled) {
await ref.read(fieldTestLoggerProvider).startSession(
rideId: id,
bike: defaultBike,
);
}
ref.read(fieldTestLoggerProvider).startSession(id);
} catch (_) {}
}
@ -195,7 +190,11 @@ class RideStateMachine extends StateNotifier<RideSession> {
state = state.copyWith(state: RideState.paused);
WakelockPlus.disable();
try {
ref.read(fieldTestLoggerProvider).logLifecycle('Ride Paused');
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.appPaused,
severity: EventSeverity.info,
message: 'Ride session paused',
);
} catch (_) {}
}
@ -204,7 +203,11 @@ class RideStateMachine extends StateNotifier<RideSession> {
_lastSample = null; // reset to avoid jumping
WakelockPlus.enable();
try {
ref.read(fieldTestLoggerProvider).logLifecycle('Ride Resumed');
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.appResumed,
severity: EventSeverity.info,
message: 'Ride session resumed',
);
} catch (_) {}
}
@ -244,7 +247,7 @@ class RideStateMachine extends StateNotifier<RideSession> {
});
}
// Auto-stop Black-Box Field Test Session if active
// Auto-stop Field Test Session if active
try {
if (ref.read(fieldTestLoggerProvider).isSessionActive) {
await ref.read(fieldTestLoggerProvider).stopSession();
@ -265,6 +268,12 @@ class RideStateMachine extends StateNotifier<RideSession> {
_queueForBatch(sample);
_processStats(sample);
try {
if (sample.quality != null) {
ref.read(fieldTestLoggerProvider).processTelemetrySample(sample, sample.quality!);
}
} catch (_) {}
}
static const int _maxBatchCapacity = 1000;
@ -276,10 +285,10 @@ class RideStateMachine extends StateNotifier<RideSession> {
if (_telemetryBatch.length >= _maxBatchCapacity) {
_telemetryBatch.removeAt(0);
try {
ref.read(fieldTestLoggerProvider).logEvent(
eventType: FieldTestEventType.databaseWriteFailure,
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.databaseQueueOverflow,
message: 'Telemetry batch buffer reached capacity (1000); dropping oldest sample',
severity: FieldTestSeverity.warning,
severity: EventSeverity.warning,
);
} catch (_) {}
}
@ -317,10 +326,10 @@ class RideStateMachine extends StateNotifier<RideSession> {
} catch (e) {
// Keep in queue for retry on next flush interval
try {
ref.read(fieldTestLoggerProvider).logEvent(
eventType: FieldTestEventType.databaseWriteFailure,
ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.databaseQueueWarning,
message: 'Database batch write retry: $e',
severity: FieldTestSeverity.warning,
severity: EventSeverity.warning,
);
} catch (_) {}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../services/field_test/field_test_logger.dart';
import '../../services/field_test/field_test_models.dart';
import '../../models/field_test_event.dart';
class FieldTestLiveLogScreen extends ConsumerStatefulWidget {
const FieldTestLiveLogScreen({super.key});
@ -19,7 +19,7 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
'GPS',
'Sensors',
'Persistence',
'Alerts',
'Performance',
'Lifecycle',
];
@ -42,11 +42,11 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
children: [
_buildFilterBar(),
Expanded(
child: StreamBuilder<List<FieldTestEventItem>>(
stream: logger.liveLogStream,
initialData: logger.liveLogs,
child: StreamBuilder<List<FieldTestEvent>>(
stream: logger.timelineStream,
initialData: logger.timelineEvents,
builder: (context, snapshot) {
final logs = (snapshot.data ?? logger.liveLogs).reversed.toList();
final logs = (snapshot.data ?? logger.timelineEvents).reversed.toList();
final filtered = logs.where(_matchesFilter).toList();
if (filtered.isEmpty) {
@ -97,7 +97,7 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
);
}
Widget _buildLogCard(FieldTestEventItem item) {
Widget _buildLogCard(FieldTestEvent item) {
return Card(
color: const Color(0xFF161616),
margin: const EdgeInsets.symmetric(vertical: 3.0, horizontal: 4.0),
@ -120,7 +120,7 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
border: Border.all(color: item.severity.color.withValues(alpha: 0.4)),
),
child: Text(
item.severity.label,
item.severity.displayName,
style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: item.severity.color),
),
),
@ -130,10 +130,10 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.message, style: const TextStyle(fontSize: 12, color: Colors.white)),
if (item.dataJson != null) ...[
if (item.metadata != null) ...[
const SizedBox(height: 2),
Text(
item.dataJson!,
item.metadata.toString(),
style: const TextStyle(fontFamily: 'monospace', fontSize: 10, color: Colors.cyanAccent),
),
],
@ -146,20 +146,20 @@ class _FieldTestLiveLogScreenState extends ConsumerState<FieldTestLiveLogScreen>
);
}
bool _matchesFilter(FieldTestEventItem item) {
bool _matchesFilter(FieldTestEvent item) {
switch (_selectedFilter) {
case 'Errors':
return item.severity == FieldTestSeverity.error || item.severity == FieldTestSeverity.critical || item.severity == FieldTestSeverity.warning;
return item.severity == EventSeverity.error || item.severity == EventSeverity.critical || item.severity == EventSeverity.warning;
case 'GPS':
return item.eventType.name.contains('gps') || item.eventType.name.contains('speed');
case 'Sensors':
return item.eventType.name.contains('imu') || item.eventType.name.contains('calibration') || item.eventType.name.contains('gForce') || item.eventType.name.contains('lean');
return item.eventType.name.contains('imu') || item.eventType.name.contains('sensor') || item.eventType.name.contains('calibration');
case 'Persistence':
return item.eventType.name.contains('database') || item.eventType.name.contains('telemetry');
case 'Alerts':
return item.eventType.name.contains('roadAlert') || item.eventType.name.contains('tts');
case 'Performance':
return item.eventType.name.contains('performance') || item.eventType.name.contains('acceleration') || item.eventType.name.contains('braking');
case 'Lifecycle':
return item.eventType.name.contains('app') || item.eventType.name.contains('ride');
return item.eventType.name.contains('app') || item.eventType.name.contains('ride') || item.eventType.name.contains('foreground');
default:
return true;
}

File diff suppressed because it is too large Load Diff

View File

@ -1068,7 +1068,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
child: Text(
activeSession != null
? 'RECORDING (Session #${activeSession.id})'
? 'RECORDING (Ride #$activeSession)'
: (isEnabled ? 'ARMED (Auto-starts on Ride)' : 'OFF'),
style: TextStyle(
fontSize: 11,

View File

@ -882,7 +882,7 @@ class _EvolutionSettingsViewState extends ConsumerState<EvolutionSettingsView> {
),
child: Text(
activeSession != null
? 'RECORDING (Session #${activeSession.id})'
? 'RECORDING (Ride #$activeSession)'
: (isEnabled ? 'ARMED (Auto-starts on Ride)' : 'OFF'),
style: TextStyle(
fontFamily: 'Inter',

View File

@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
enum FieldTestEventType {
rideStarted,
rideStopped,
gpsStale,
gpsRecovered,
gpsJumpRejected,
gpsAccuracyDegraded,
imuSaturation,
imuRecovered,
telemetryGap,
telemetryRecovered,
timestampError,
sampleAccepted,
sampleSuspect,
sampleRejected,
accelerationEvent,
brakingEvent,
performanceRunStarted,
performanceRunCompleted,
performanceRunRejected,
appPaused,
appResumed,
foregroundServiceStarted,
foregroundServiceStopped,
databaseFlush,
databaseQueueWarning,
databaseQueueOverflow,
databaseRecovery,
exportStarted,
exportCompleted,
exportFailed,
sensorInitialized,
sensorError,
gpsInitialized,
gpsError,
calibrationStarted,
calibrationCompleted,
roadAlertReceived,
ttsAnnounced,
audioFailure,
networkError;
String get displayName {
switch (this) {
case FieldTestEventType.rideStarted: return 'RIDE_STARTED';
case FieldTestEventType.rideStopped: return 'RIDE_STOPPED';
case FieldTestEventType.gpsStale: return 'GPS_STALE';
case FieldTestEventType.gpsRecovered: return 'GPS_RECOVERED';
case FieldTestEventType.gpsJumpRejected: return 'GPS_JUMP_REJECTED';
case FieldTestEventType.gpsAccuracyDegraded: return 'GPS_ACCURACY_DEGRADED';
case FieldTestEventType.imuSaturation: return 'IMU_SATURATION';
case FieldTestEventType.imuRecovered: return 'IMU_RECOVERED';
case FieldTestEventType.telemetryGap: return 'TELEMETRY_GAP';
case FieldTestEventType.telemetryRecovered: return 'TELEMETRY_RECOVERED';
case FieldTestEventType.timestampError: return 'TIMESTAMP_ERROR';
case FieldTestEventType.sampleAccepted: return 'SAMPLE_ACCEPTED';
case FieldTestEventType.sampleSuspect: return 'SAMPLE_SUSPECT';
case FieldTestEventType.sampleRejected: return 'SAMPLE_REJECTED';
case FieldTestEventType.accelerationEvent: return 'ACCELERATION_EVENT';
case FieldTestEventType.brakingEvent: return 'BRAKING_EVENT';
case FieldTestEventType.performanceRunStarted: return 'PERFORMANCE_RUN_STARTED';
case FieldTestEventType.performanceRunCompleted: return 'PERFORMANCE_RUN_COMPLETED';
case FieldTestEventType.performanceRunRejected: return 'PERFORMANCE_RUN_REJECTED';
case FieldTestEventType.appPaused: return 'APP_PAUSED';
case FieldTestEventType.appResumed: return 'APP_RESUMED';
case FieldTestEventType.foregroundServiceStarted: return 'FOREGROUND_SERVICE_STARTED';
case FieldTestEventType.foregroundServiceStopped: return 'FOREGROUND_SERVICE_STOPPED';
case FieldTestEventType.databaseFlush: return 'DATABASE_FLUSH';
case FieldTestEventType.databaseQueueWarning: return 'DATABASE_QUEUE_WARNING';
case FieldTestEventType.databaseQueueOverflow: return 'DATABASE_QUEUE_OVERFLOW';
case FieldTestEventType.databaseRecovery: return 'DATABASE_RECOVERY';
case FieldTestEventType.exportStarted: return 'EXPORT_STARTED';
case FieldTestEventType.exportCompleted: return 'EXPORT_COMPLETED';
case FieldTestEventType.exportFailed: return 'EXPORT_FAILED';
case FieldTestEventType.sensorInitialized: return 'SENSOR_INITIALIZED';
case FieldTestEventType.sensorError: return 'SENSOR_ERROR';
case FieldTestEventType.gpsInitialized: return 'GPS_INITIALIZED';
case FieldTestEventType.gpsError: return 'GPS_ERROR';
case FieldTestEventType.calibrationStarted: return 'CALIBRATION_STARTED';
case FieldTestEventType.calibrationCompleted: return 'CALIBRATION_COMPLETED';
case FieldTestEventType.roadAlertReceived: return 'ROAD_ALERT_RECEIVED';
case FieldTestEventType.ttsAnnounced: return 'TTS_ANNOUNCED';
case FieldTestEventType.audioFailure: return 'AUDIO_FAILURE';
case FieldTestEventType.networkError: return 'NETWORK_ERROR';
}
}
static FieldTestEventType fromString(String val) {
final clean = val.toUpperCase().replaceAll('-', '_');
for (final e in FieldTestEventType.values) {
if (e.displayName == clean || e.name.toUpperCase() == clean) {
return e;
}
}
return FieldTestEventType.sampleAccepted;
}
}
enum EventSeverity {
info,
warning,
error,
critical;
String get displayName => name.toUpperCase();
Color get color {
switch (this) {
case EventSeverity.info: return Colors.cyanAccent;
case EventSeverity.warning: return Colors.amberAccent;
case EventSeverity.error: return Colors.orangeAccent;
case EventSeverity.critical: return Colors.redAccent;
}
}
static EventSeverity fromString(String val) {
final clean = val.toLowerCase();
for (final s in EventSeverity.values) {
if (s.name.toLowerCase() == clean) {
return s;
}
}
return EventSeverity.info;
}
}
class FieldTestEvent {
final int? id;
final DateTime timestamp;
final FieldTestEventType eventType;
final int? rideId;
final EventSeverity severity;
final String message;
final DateTime? telemetryTimestamp;
final int? gpsAgeMs;
final double? speed;
final double? latitude;
final double? longitude;
final double? gpsAccuracy;
final double? leanAngle;
final double? longitudinalG;
final double? lateralG;
final double? overallConfidence;
final Map<String, dynamic>? metadata;
const FieldTestEvent({
this.id,
required this.timestamp,
required this.eventType,
this.rideId,
required this.severity,
required this.message,
this.telemetryTimestamp,
this.gpsAgeMs,
this.speed,
this.latitude,
this.longitude,
this.gpsAccuracy,
this.leanAngle,
this.longitudinalG,
this.lateralG,
this.overallConfidence,
this.metadata,
});
Map<String, dynamic> toJson() {
return {
'id': id,
'timestamp': timestamp.toIso8601String(),
'eventType': eventType.displayName,
'rideId': rideId,
'severity': severity.displayName,
'message': message,
'telemetryTimestamp': telemetryTimestamp?.toIso8601String(),
'gpsAgeMs': gpsAgeMs,
'speed': speed,
'latitude': latitude,
'longitude': longitude,
'gpsAccuracy': gpsAccuracy,
'leanAngle': leanAngle,
'longitudinalG': longitudinalG,
'lateralG': lateralG,
'overallConfidence': overallConfidence,
'metadata': metadata,
};
}
factory FieldTestEvent.fromJson(Map<String, dynamic> json) {
return FieldTestEvent(
id: json['id'] as int?,
timestamp: DateTime.parse(json['timestamp'] as String),
eventType: FieldTestEventType.fromString(json['eventType'] as String),
rideId: json['rideId'] as int?,
severity: EventSeverity.fromString(json['severity'] as String),
message: json['message'] as String? ?? '',
telemetryTimestamp: json['telemetryTimestamp'] != null
? DateTime.parse(json['telemetryTimestamp'] as String)
: null,
gpsAgeMs: json['gpsAgeMs'] as int?,
speed: (json['speed'] as num?)?.toDouble(),
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
gpsAccuracy: (json['gpsAccuracy'] as num?)?.toDouble(),
leanAngle: (json['leanAngle'] as num?)?.toDouble(),
longitudinalG: (json['longitudinalG'] as num?)?.toDouble(),
lateralG: (json['lateralG'] as num?)?.toDouble(),
overallConfidence: (json['overallConfidence'] as num?)?.toDouble(),
metadata: json['metadata'] as Map<String, dynamic>?,
);
}
}

View File

@ -0,0 +1,175 @@
class FieldTestSummary {
final int? id;
final int rideId;
final DateTime startTime;
final DateTime? endTime;
final Duration duration;
final int totalSamples;
final int validSamples;
final int suspectSamples;
final int rejectedSamples;
final double validPercentage;
final double suspectPercentage;
final double rejectedPercentage;
final int gpsFixCount;
final int gpsStaleEventCount;
final int gpsJumpRejectCount;
final int? maxGpsAgeMs;
final double? averageGpsAccuracy;
final double? worstGpsAccuracy;
final int telemetryGapCount;
final int? maxTelemetryGapMs;
final int imuSaturationCount;
final double? maxSpeedMph;
final double? maxLeanAngle;
final double? maxLongitudinalG;
final double? maxLateralG;
final int accelerationEventCount;
final int brakingEventCount;
final int zeroTo60Count;
final int zeroTo100Count;
final double? bestZeroTo60;
final double? bestZeroTo100;
final int databaseSamplesWritten;
final int databaseSamplesLost;
final int eventCount;
final double? overallConfidence;
final int rideQualityScore;
final String rideQualityRating;
final bool wasRecoveredAfterCrash;
final bool wasInterrupted;
final String? exportIntegrity;
const FieldTestSummary({
this.id,
required this.rideId,
required this.startTime,
this.endTime,
required this.duration,
required this.totalSamples,
required this.validSamples,
required this.suspectSamples,
required this.rejectedSamples,
required this.validPercentage,
required this.suspectPercentage,
required this.rejectedPercentage,
required this.gpsFixCount,
required this.gpsStaleEventCount,
required this.gpsJumpRejectCount,
this.maxGpsAgeMs,
this.averageGpsAccuracy,
this.worstGpsAccuracy,
required this.telemetryGapCount,
this.maxTelemetryGapMs,
required this.imuSaturationCount,
this.maxSpeedMph,
this.maxLeanAngle,
this.maxLongitudinalG,
this.maxLateralG,
required this.accelerationEventCount,
required this.brakingEventCount,
required this.zeroTo60Count,
required this.zeroTo100Count,
this.bestZeroTo60,
this.bestZeroTo100,
required this.databaseSamplesWritten,
required this.databaseSamplesLost,
required this.eventCount,
this.overallConfidence,
required this.rideQualityScore,
required this.rideQualityRating,
this.wasRecoveredAfterCrash = false,
this.wasInterrupted = false,
this.exportIntegrity,
});
Map<String, dynamic> toJson() {
return {
'id': id,
'rideId': rideId,
'startTime': startTime.toIso8601String(),
'endTime': endTime?.toIso8601String(),
'durationSeconds': duration.inSeconds,
'totalSamples': totalSamples,
'validSamples': validSamples,
'suspectSamples': suspectSamples,
'rejectedSamples': rejectedSamples,
'validPercentage': validPercentage,
'suspectPercentage': suspectPercentage,
'rejectedPercentage': rejectedPercentage,
'gpsFixCount': gpsFixCount,
'gpsStaleEventCount': gpsStaleEventCount,
'gpsJumpRejectCount': gpsJumpRejectCount,
'maxGpsAgeMs': maxGpsAgeMs,
'averageGpsAccuracy': averageGpsAccuracy,
'worstGpsAccuracy': worstGpsAccuracy,
'telemetryGapCount': telemetryGapCount,
'maxTelemetryGapMs': maxTelemetryGapMs,
'imuSaturationCount': imuSaturationCount,
'maxSpeedMph': maxSpeedMph,
'maxLeanAngle': maxLeanAngle,
'maxLongitudinalG': maxLongitudinalG,
'maxLateralG': maxLateralG,
'accelerationEventCount': accelerationEventCount,
'brakingEventCount': brakingEventCount,
'zeroTo60Count': zeroTo60Count,
'zeroTo100Count': zeroTo100Count,
'bestZeroTo60': bestZeroTo60,
'bestZeroTo100': bestZeroTo100,
'databaseSamplesWritten': databaseSamplesWritten,
'databaseSamplesLost': databaseSamplesLost,
'eventCount': eventCount,
'overallConfidence': overallConfidence,
'rideQualityScore': rideQualityScore,
'rideQualityRating': rideQualityRating,
'wasRecoveredAfterCrash': wasRecoveredAfterCrash,
'wasInterrupted': wasInterrupted,
'exportIntegrity': exportIntegrity,
};
}
factory FieldTestSummary.fromJson(Map<String, dynamic> json) {
return FieldTestSummary(
id: json['id'] as int?,
rideId: json['rideId'] as int,
startTime: DateTime.parse(json['startTime'] as String),
endTime: json['endTime'] != null ? DateTime.parse(json['endTime'] as String) : null,
duration: Duration(seconds: json['durationSeconds'] as int? ?? 0),
totalSamples: json['totalSamples'] as int? ?? 0,
validSamples: json['validSamples'] as int? ?? 0,
suspectSamples: json['suspectSamples'] as int? ?? 0,
rejectedSamples: json['rejectedSamples'] as int? ?? 0,
validPercentage: (json['validPercentage'] as num?)?.toDouble() ?? 0.0,
suspectPercentage: (json['suspectPercentage'] as num?)?.toDouble() ?? 0.0,
rejectedPercentage: (json['rejectedPercentage'] as num?)?.toDouble() ?? 0.0,
gpsFixCount: json['gpsFixCount'] as int? ?? 0,
gpsStaleEventCount: json['gpsStaleEventCount'] as int? ?? 0,
gpsJumpRejectCount: json['gpsJumpRejectCount'] as int? ?? 0,
maxGpsAgeMs: json['maxGpsAgeMs'] as int?,
averageGpsAccuracy: (json['averageGpsAccuracy'] as num?)?.toDouble(),
worstGpsAccuracy: (json['worstGpsAccuracy'] as num?)?.toDouble(),
telemetryGapCount: json['telemetryGapCount'] as int? ?? 0,
maxTelemetryGapMs: json['maxTelemetryGapMs'] as int?,
imuSaturationCount: json['imuSaturationCount'] as int? ?? 0,
maxSpeedMph: (json['maxSpeedMph'] as num?)?.toDouble(),
maxLeanAngle: (json['maxLeanAngle'] as num?)?.toDouble(),
maxLongitudinalG: (json['maxLongitudinalG'] as num?)?.toDouble(),
maxLateralG: (json['maxLateralG'] as num?)?.toDouble(),
accelerationEventCount: json['accelerationEventCount'] as int? ?? 0,
brakingEventCount: json['brakingEventCount'] as int? ?? 0,
zeroTo60Count: json['zeroTo60Count'] as int? ?? 0,
zeroTo100Count: json['zeroTo100Count'] as int? ?? 0,
bestZeroTo60: (json['bestZeroTo60'] as num?)?.toDouble(),
bestZeroTo100: (json['bestZeroTo100'] as num?)?.toDouble(),
databaseSamplesWritten: json['databaseSamplesWritten'] as int? ?? 0,
databaseSamplesLost: json['databaseSamplesLost'] as int? ?? 0,
eventCount: json['eventCount'] as int? ?? 0,
overallConfidence: (json['overallConfidence'] as num?)?.toDouble(),
rideQualityScore: json['rideQualityScore'] as int? ?? 0,
rideQualityRating: json['rideQualityRating'] as String? ?? 'INVALID',
wasRecoveredAfterCrash: json['wasRecoveredAfterCrash'] as bool? ?? false,
wasInterrupted: json['wasInterrupted'] as bool? ?? false,
exportIntegrity: json['exportIntegrity'] as String?,
);
}
}

View File

@ -8,7 +8,7 @@ import 'road_alert_models.dart';
import 'road_alert_providers.dart';
import '../field_test/field_test_logger.dart';
import '../field_test/field_test_models.dart';
import '../../models/field_test_event.dart';
final roadAlertServiceProvider = Provider<RoadAlertService>((ref) {
final service = RoadAlertService(ref);
@ -207,12 +207,15 @@ class RoadAlertService {
try {
for (var a in fetched) {
_ref.read(fieldTestLoggerProvider).logRoadAlertEvent(
type: FieldTestEventType.roadAlertReceived,
providerId: provider.id,
category: a.category.displayName,
title: a.description,
distanceMeters: a.distanceFromUser,
_ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.roadAlertReceived,
severity: EventSeverity.info,
message: '[${provider.id}] ${a.category.displayName}: ${a.description}',
metadata: {
'providerId': provider.id,
'category': a.category.displayName,
'distanceMeters': a.distanceFromUser,
},
);
}
} catch (_) {}
@ -221,7 +224,11 @@ class RoadAlertService {
debugPrint('[RoadAlerts] Provider ${provider.id} error: $e');
}
try {
_ref.read(fieldTestLoggerProvider).logProviderFailure(provider.id, e.toString());
_ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.networkError,
severity: EventSeverity.warning,
message: 'Road alert provider ${provider.id} failure: $e',
);
} catch (_) {}
}
}
@ -267,12 +274,20 @@ class RoadAlertService {
try {
_tts.speak(phrase);
_ref.read(fieldTestLoggerProvider).logTts(success: true, phrase: phrase);
_ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.ttsAnnounced,
severity: EventSeverity.info,
message: 'TTS Announced: "$phrase"',
);
} catch (e) {
if (kDebugMode) {
debugPrint('[RoadAlerts] TTS speak error: $e');
}
_ref.read(fieldTestLoggerProvider).logTts(success: false, phrase: phrase, error: e.toString());
_ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.audioFailure,
severity: EventSeverity.warning,
message: 'TTS error speaking "$phrase": $e',
);
}
}
@ -310,7 +325,11 @@ class RoadAlertService {
final phrase = "TEST ALERT: ${category.displayName} reported ahead.";
try {
_tts.speak(phrase);
_ref.read(fieldTestLoggerProvider).logTts(success: true, phrase: phrase);
_ref.read(fieldTestLoggerProvider).log(
eventType: FieldTestEventType.ttsAnnounced,
severity: EventSeverity.info,
message: 'TTS Test Alert: "$phrase"',
);
} catch (e) {
if (kDebugMode) {
debugPrint('[RoadAlerts] TTS speak error for test alert: $e');

View File

@ -10,6 +10,24 @@ class ExportService {
ExportService(this._db);
Future<String> exportRideGpx(int rideId) async {
final telemetry = await (_db.select(_db.telemetrySamples)
..where((t) => t.rideId.equals(rideId))
..orderBy([(t) => OrderingTerm(expression: t.timestamp)])
).get();
return _generateGpx(telemetry);
}
Future<String> exportRideCsv(int rideId) async {
final telemetry = await (_db.select(_db.telemetrySamples)
..where((t) => t.rideId.equals(rideId))
..orderBy([(t) => OrderingTerm(expression: t.timestamp)])
).get();
return _generateCsv(telemetry);
}
Future<void> exportRide(int rideId, {bool asGpx = true}) async {
final telemetry = await (_db.select(_db.telemetrySamples)
..where((t) => t.rideId.equals(rideId))

View File

@ -1,4 +1,4 @@
import 'dart:convert';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:drift/drift.dart' as drift;
@ -7,7 +7,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:archive/archive.dart';
import '../../database/database.dart';
import 'field_test_models.dart';
import '../export_service.dart';
final fieldTestExportServiceProvider = Provider<FieldTestExportService>((ref) {
final db = ref.watch(databaseProvider);
@ -19,82 +19,169 @@ class FieldTestExportService {
FieldTestExportService(this._db);
Future<String?> exportSessionZip(int sessionId) async {
/// Step 15: CSV Forensic Event Export
Future<String> generateFieldTestEventsCsv(int rideId) async {
final events = await (_db.select(_db.fieldTestEvents)
..where((e) => e.rideId.equals(rideId))
..orderBy([(e) => drift.OrderingTerm(expression: e.timestamp)]))
.get();
final buffer = StringBuffer();
// Specific Step 15 Column Standard
buffer.writeln('timestamp,ride_id,event_type,severity,message,speed,latitude,longitude,gps_accuracy,gps_age_ms,lean_angle,longitudinal_g,lateral_g,confidence,metadata');
for (final e in events) {
final metaStr = e.metadata != null ? '"${e.metadata!.replaceAll('"', '""')}"' : '';
final msgStr = '"${e.message.replaceAll('"', '""')}"';
buffer.writeln(
'${e.timestamp.toIso8601String()},'
'${e.rideId ?? ''},'
'${e.eventType},'
'${e.severity},'
'$msgStr,'
'${e.speed?.toStringAsFixed(2) ?? ''},'
'${e.latitude?.toStringAsFixed(6) ?? ''},'
'${e.longitude?.toStringAsFixed(6) ?? ''},'
'${e.gpsAccuracy?.toStringAsFixed(1) ?? ''},'
'${e.gpsAgeMs ?? ''},'
'${e.leanAngle?.toStringAsFixed(1) ?? ''},'
'${e.longitudinalG?.toStringAsFixed(3) ?? ''},'
'${e.lateralG?.toStringAsFixed(3) ?? ''},'
'${e.overallConfidence?.toStringAsFixed(3) ?? ''},'
'$metaStr',
);
}
return buffer.toString();
}
/// Step 15: Complete JSON Forensic Session Export
Future<String> generateFieldTestSessionJson(int rideId) async {
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
final summaryRecord = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(rideId))).getSingleOrNull();
final events = await (_db.select(_db.fieldTestEvents)
..where((e) => e.rideId.equals(rideId))
..orderBy([(e) => drift.OrderingTerm(expression: e.timestamp)]))
.get();
final perfRuns = await (_db.select(_db.performanceRuns)..where((p) => p.rideId.equals(rideId))).get();
final Map<String, dynamic> exportMap = {
'exportVersion': '1.0.0',
'generatedAt': DateTime.now().toIso8601String(),
'rideMetadata': {
'id': ride?.id,
'startTime': ride?.startTime.toIso8601String(),
'endTime': ride?.endTime?.toIso8601String(),
'distanceMiles': ride?.distanceMiles,
'topSpeedMph': ride?.topSpeedMph,
'maxLeanLeft': ride?.maxLeanLeft,
'maxLeanRight': ride?.maxLeanRight,
'maxAccelG': ride?.maxAccelG,
'maxBrakingG': ride?.maxBrakingG,
'averageSpeedMph': ride?.averageSpeed,
'motorcycleMake': ride?.motorcycleMake,
'motorcycleModel': ride?.motorcycleModel,
},
'summary': summaryRecord != null ? {
'rideId': summaryRecord.rideId,
'startTime': summaryRecord.startTime.toIso8601String(),
'endTime': summaryRecord.endTime?.toIso8601String(),
'durationSeconds': summaryRecord.durationSeconds,
'totalSamples': summaryRecord.totalSamples,
'validSamples': summaryRecord.validSamples,
'suspectSamples': summaryRecord.suspectSamples,
'rejectedSamples': summaryRecord.rejectedSamples,
'validPercentage': summaryRecord.validPercentage,
'suspectPercentage': summaryRecord.suspectPercentage,
'rejectedPercentage': summaryRecord.rejectedPercentage,
'gpsFixCount': summaryRecord.gpsFixCount,
'gpsStaleEventCount': summaryRecord.gpsStaleEventCount,
'gpsJumpRejectCount': summaryRecord.gpsJumpRejectCount,
'maxGpsAgeMs': summaryRecord.maxGpsAgeMs,
'averageGpsAccuracy': summaryRecord.averageGpsAccuracy,
'worstGpsAccuracy': summaryRecord.worstGpsAccuracy,
'telemetryGapCount': summaryRecord.telemetryGapCount,
'maxTelemetryGapMs': summaryRecord.maxTelemetryGapMs,
'imuSaturationCount': summaryRecord.imuSaturationCount,
'maxSpeedMph': summaryRecord.maxSpeedMph,
'maxLeanAngle': summaryRecord.maxLeanAngle,
'maxLongitudinalG': summaryRecord.maxLongitudinalG,
'maxLateralG': summaryRecord.maxLateralG,
'accelerationEventCount': summaryRecord.accelerationEventCount,
'brakingEventCount': summaryRecord.brakingEventCount,
'zeroTo60Count': summaryRecord.zeroTo60Count,
'zeroTo100Count': summaryRecord.zeroTo100Count,
'bestZeroTo60': summaryRecord.bestZeroTo60,
'bestZeroTo100': summaryRecord.bestZeroTo100,
'databaseSamplesWritten': summaryRecord.databaseSamplesWritten,
'databaseSamplesLost': summaryRecord.databaseSamplesLost,
'eventCount': summaryRecord.eventCount,
'overallConfidence': summaryRecord.overallConfidence,
'rideQualityScore': summaryRecord.rideQualityScore,
'rideQualityRating': summaryRecord.rideQualityRating,
'wasRecoveredAfterCrash': summaryRecord.wasRecoveredAfterCrash,
'wasInterrupted': summaryRecord.wasInterrupted,
'exportIntegrity': summaryRecord.exportIntegrity,
} : null,
'events': events.map((e) => {
'id': e.id,
'timestamp': e.timestamp.toIso8601String(),
'eventType': e.eventType,
'severity': e.severity,
'message': e.message,
'speed': e.speed,
'latitude': e.latitude,
'longitude': e.longitude,
'gpsAccuracy': e.gpsAccuracy,
'gpsAgeMs': e.gpsAgeMs,
'leanAngle': e.leanAngle,
'longitudinalG': e.longitudinalG,
'lateralG': e.lateralG,
'confidence': e.overallConfidence,
'metadata': e.metadata,
}).toList(),
'performanceRuns': perfRuns.map((p) => {
'id': p.id,
'type': p.type,
'timeMs': p.timeMs,
'timestamp': p.timestamp.toIso8601String(),
'topSpeedReached': p.topSpeedReached,
}).toList(),
};
return const JsonEncoder.withIndent(' ').convert(exportMap);
}
/// Exports complete forensic package (.zip)
Future<String?> exportSessionZip(int rideId) async {
try {
final session = await (_db.select(_db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).getSingleOrNull();
if (session == null) return null;
final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull();
if (ride == null) return null;
final events = await (_db.select(_db.fieldTestEvents)
..where((e) => e.sessionId.equals(sessionId))
..orderBy([(e) => drift.OrderingTerm(expression: e.timestamp)]))
.get();
final gpsRecords = await (_db.select(_db.fieldTestGpsRecords)
..where((g) => g.sessionId.equals(sessionId))
..orderBy([(g) => drift.OrderingTerm(expression: g.timestamp)]))
.get();
final imuRecords = await (_db.select(_db.fieldTestImuRecords)
..where((i) => i.sessionId.equals(sessionId))
..orderBy([(i) => drift.OrderingTerm(expression: i.timestamp)]))
.get();
Ride? ride;
List<TelemetrySample> telemetry = [];
if (session.rideId != null) {
ride = await (_db.select(_db.rides)..where((r) => r.id.equals(session.rideId!))).getSingleOrNull();
telemetry = await (_db.select(_db.telemetrySamples)
..where((t) => t.rideId.equals(session.rideId!))
..orderBy([(t) => drift.OrderingTerm(expression: t.timestamp)]))
.get();
}
final eventsCsv = await generateFieldTestEventsCsv(rideId);
final sessionJson = await generateFieldTestSessionJson(rideId);
final exportService = ExportService(_db);
final gpxContent = await exportService.exportRideGpx(rideId);
final telemetryCsv = await exportService.exportRideCsv(rideId);
final archive = Archive();
final dateStr = DateFormat('yyyy-MM-dd-HHmmss').format(session.startedAt.toLocal());
final folderName = 'field-test-$dateStr';
final dateStr = DateFormat('yyyy-MM-dd-HHmmss').format(ride.startTime.toLocal());
final folderName = 'forensic-ride-$rideId-$dateStr';
// 1. README.txt
final readme = _generateReadme(session, ride, events.length, gpsRecords.length, imuRecords.length);
archive.addFile(ArchiveFile('$folderName/README.txt', utf8.encode(readme).length, utf8.encode(readme)));
// 1. field_test_events.csv
archive.addFile(ArchiveFile('$folderName/field_test_events.csv', utf8.encode(eventsCsv).length, utf8.encode(eventsCsv)));
// 2. ride-summary.json
final summaryJson = _generateSummaryJson(session, ride, events);
archive.addFile(ArchiveFile('$folderName/ride-summary.json', utf8.encode(summaryJson).length, utf8.encode(summaryJson)));
// 2. field_test_session.json
archive.addFile(ArchiveFile('$folderName/field_test_session.json', utf8.encode(sessionJson).length, utf8.encode(sessionJson)));
// 3. diagnostics.txt
final diagnostics = _generateDiagnosticsReport(session, ride, events, gpsRecords, imuRecords);
archive.addFile(ArchiveFile('$folderName/diagnostics.txt', utf8.encode(diagnostics).length, utf8.encode(diagnostics)));
// 3. telemetry.csv
archive.addFile(ArchiveFile('$folderName/telemetry.csv', utf8.encode(telemetryCsv).length, utf8.encode(telemetryCsv)));
// 4. events.csv
final eventsCsv = _generateEventsCsv(events);
archive.addFile(ArchiveFile('$folderName/events.csv', utf8.encode(eventsCsv).length, utf8.encode(eventsCsv)));
// 5. gps.csv
if (gpsRecords.isNotEmpty) {
final gpsCsv = _generateGpsCsv(gpsRecords);
archive.addFile(ArchiveFile('$folderName/gps.csv', utf8.encode(gpsCsv).length, utf8.encode(gpsCsv)));
// 4. ride.gpx
if (gpxContent.isNotEmpty) {
archive.addFile(ArchiveFile('$folderName/ride.gpx', utf8.encode(gpxContent).length, utf8.encode(gpxContent)));
}
// 6. imu.csv
if (imuRecords.isNotEmpty) {
final imuCsv = _generateImuCsv(imuRecords);
archive.addFile(ArchiveFile('$folderName/imu.csv', utf8.encode(imuCsv).length, utf8.encode(imuCsv)));
}
// 7. telemetry.csv
if (telemetry.isNotEmpty) {
final telemetryCsv = _generateTelemetryCsv(telemetry);
archive.addFile(ArchiveFile('$folderName/telemetry.csv', utf8.encode(telemetryCsv).length, utf8.encode(telemetryCsv)));
}
// 8. road-alerts.csv
final alertEvents = events.where((e) => e.eventType.contains('roadAlert') || e.eventType.contains('tts')).toList();
if (alertEvents.isNotEmpty) {
final alertsCsv = _generateEventsCsv(alertEvents);
archive.addFile(ArchiveFile('$folderName/road-alerts.csv', utf8.encode(alertsCsv).length, utf8.encode(alertsCsv)));
}
// Encode Zip
final zipEncoder = ZipEncoder();
final zipBytes = zipEncoder.encode(archive);
@ -103,208 +190,15 @@ class FieldTestExportService {
await zipFile.writeAsBytes(zipBytes);
// ignore: deprecated_member_use
await Share.shareXFiles([XFile(zipFile.path)], text: 'Motorcycle Dash Field Test Package (#$sessionId)');
await Share.shareXFiles(
[XFile(zipFile.path)],
subject: 'MotorSignal Forensic Ride Report #$rideId',
text: 'Forensic field test package for ride #$rideId generated on $dateStr.',
);
return zipFile.path;
} catch (_) {
} catch (e) {
return null;
}
}
String _generateReadme(FieldTestSession session, Ride? ride, int eventCount, int gpsCount, int imuCount) {
final sb = StringBuffer();
sb.writeln('============================================================');
sb.writeln('MOTORCYCLE DIGITAL DASHBOARD — BLACK-BOX FIELD TEST ARCHIVE');
sb.writeln('============================================================');
sb.writeln('');
sb.writeln('Session ID: #${session.id}');
sb.writeln('Ride ID: #${session.rideId ?? "None"}');
sb.writeln('App Version: ${session.appVersion} (Build ${session.buildNumber})');
sb.writeln('OS Platform: ${session.androidVersion}');
sb.writeln('Device Model: ${session.deviceModel}');
sb.writeln('Started At: ${session.startedAt.toIso8601String()}');
sb.writeln('Ended At: ${session.endedAt?.toIso8601String() ?? "Active / Interrupted"}');
sb.writeln('Motorcycle: ${session.motorcycleYear ?? ""} ${session.motorcycleMake ?? ""} ${session.motorcycleModel ?? "Unspecified"}');
sb.writeln('Calibration: Roll ${session.calibrationRollOffset.toStringAsFixed(2)}°, Pitch ${session.calibrationPitchOffset.toStringAsFixed(2)}°');
sb.writeln('Total Events: $eventCount');
sb.writeln('GPS Records: $gpsCount');
sb.writeln('IMU Records: $imuCount');
sb.writeln('Anomalies: ${session.anomalyCount}');
sb.writeln('');
sb.writeln('FILE MANIFEST:');
sb.writeln('- README.txt: Metadata and test run manifest');
sb.writeln('- ride-summary.json: JSON machine-readable summary');
sb.writeln('- diagnostics.txt: Comprehensive engineering report');
sb.writeln('- events.csv: Chronological event log with severities');
sb.writeln('- gps.csv: GPS diagnostics (fixes, accuracies, outlier rejections)');
sb.writeln('- imu.csv: Sensor fusion diagnostics and vibration filters');
sb.writeln('- telemetry.csv: Primary ride telemetry stream');
sb.writeln('- road-alerts.csv: Incident alerts and TTS execution events');
sb.writeln('');
sb.writeln('UNITS OF MEASUREMENT:');
sb.writeln('- Speed: Miles Per Hour (MPH)');
sb.writeln('- Distance: Miles (mi)');
sb.writeln('- Angles: Degrees (°)');
sb.writeln('- Acceleration / G: G-Forces (1G = 9.80665 m/s²)');
sb.writeln('- Accuracy & Altitude: Meters (m)');
return sb.toString();
}
String _generateSummaryJson(FieldTestSession session, Ride? ride, List<FieldTestEvent> events) {
final Map<String, dynamic> data = {
'session': {
'id': session.id,
'rideId': session.rideId,
'startedAt': session.startedAt.toIso8601String(),
'endedAt': session.endedAt?.toIso8601String(),
'appVersion': session.appVersion,
'buildNumber': session.buildNumber,
'androidVersion': session.androidVersion,
'deviceModel': session.deviceModel,
'motorcycle': {
'id': session.motorcycleId,
'year': session.motorcycleYear,
'make': session.motorcycleMake,
'model': session.motorcycleModel,
},
'calibration': {
'rollOffset': session.calibrationRollOffset,
'pitchOffset': session.calibrationPitchOffset,
},
'totalEvents': session.totalEvents,
'anomalyCount': session.anomalyCount,
'completed': session.completed,
},
'healthSummary': session.healthSummaryJson != null ? jsonDecode(session.healthSummaryJson!) : null,
'ride': ride != null
? {
'id': ride.id,
'startTime': ride.startTime.toIso8601String(),
'endTime': ride.endTime?.toIso8601String(),
'distanceMiles': ride.distanceMiles,
'topSpeedMph': ride.topSpeedMph,
'averageSpeedMph': ride.averageSpeed,
'maxLeanLeftDegrees': ride.maxLeanLeft,
'maxLeanRightDegrees': ride.maxLeanRight,
'maxAccelG': ride.maxAccelG,
'maxBrakingG': ride.maxBrakingG,
'movingTimeSeconds': ride.movingTime,
'stoppedTimeSeconds': ride.stoppedTime,
'elevationGainMeters': ride.elevationGain,
'elevationLossMeters': ride.elevationLoss,
}
: null,
};
return const JsonEncoder.withIndent(' ').convert(data);
}
String _generateDiagnosticsReport(
FieldTestSession session,
Ride? ride,
List<FieldTestEvent> events,
List<FieldTestGpsRecord> gps,
List<FieldTestImuRecord> imu,
) {
final sb = StringBuffer();
sb.writeln('============================================================');
sb.writeln('FIELD TEST ENGINEERING DIAGNOSTIC REPORT');
sb.writeln('============================================================');
sb.writeln('');
sb.writeln('--- RIDE SUMMARY ---');
if (ride != null) {
sb.writeln('Distance: ${ride.distanceMiles.toStringAsFixed(2)} miles');
sb.writeln('Moving Time: ${(ride.movingTime / 60).toStringAsFixed(1)} min');
sb.writeln('Top Speed: ${ride.topSpeedMph.toStringAsFixed(1)} MPH');
sb.writeln('Avg Speed: ${ride.averageSpeed.toStringAsFixed(1)} MPH');
sb.writeln('Max Lean Left: ${ride.maxLeanLeft.toStringAsFixed(1)}°');
sb.writeln('Max Lean Right: ${ride.maxLeanRight.toStringAsFixed(1)}°');
sb.writeln('Max Accel: ${ride.maxAccelG.toStringAsFixed(2)} G');
sb.writeln('Max Braking: ${ride.maxBrakingG.toStringAsFixed(2)} G');
sb.writeln('Elevation Gain: ${ride.elevationGain.toStringAsFixed(0)} m');
} else {
sb.writeln('No associated ride summary recorded.');
}
sb.writeln('');
sb.writeln('--- GPS SUBSYSTEM ---');
final totalGps = gps.length;
final rejectedGps = gps.where((g) => !g.accepted).length;
final rejectRate = totalGps > 0 ? (rejectedGps / totalGps * 100).toStringAsFixed(2) : '0.00';
sb.writeln('Total GPS Fixes: $totalGps');
sb.writeln('Accepted Fixes: ${totalGps - rejectedGps}');
sb.writeln('Rejected Outliers: $rejectedGps ($rejectRate%)');
sb.writeln('');
sb.writeln('--- SENSORS & IMU ---');
sb.writeln('IMU Samples: ${imu.length}');
final filteredSpikes = events.where((e) => e.eventType == 'imuSpikeFiltered').length;
sb.writeln('Filtered Spikes: $filteredSpikes');
sb.writeln('');
sb.writeln('--- HEALTH SCORE SUMMARY ---');
if (session.healthSummaryJson != null) {
try {
final h = SystemHealthSummary.fromJson(jsonDecode(session.healthSummaryJson!));
sb.writeln('GPS Health: ${h.gpsHealth.label}');
sb.writeln('Sensors Health: ${h.sensorsHealth.label}');
sb.writeln('Persistence Health: ${h.persistenceHealth.label}');
sb.writeln('Lifecycle Health: ${h.lifecycleHealth.label}');
sb.writeln('Road Alerts Health: ${h.roadAlertsHealth.label}');
sb.writeln('TTS Audio Health: ${h.ttsHealth.label}');
} catch (_) {}
}
sb.writeln('');
sb.writeln('--- ANOMALIES & WARNINGS ---');
final warnings = events.where((e) => e.severity == 'warning' || e.severity == 'error' || e.severity == 'critical').toList();
if (warnings.isEmpty) {
sb.writeln('0 anomalies detected during this session.');
} else {
for (final w in warnings) {
sb.writeln('[${w.severity.toUpperCase()}] ${w.timestamp.toLocal().toString().substring(11, 19)} - ${w.message}');
}
}
return sb.toString();
}
String _generateEventsCsv(List<FieldTestEvent> events) {
final sb = StringBuffer();
sb.writeln('timestamp,event_type,severity,message,data_json');
for (final e in events) {
sb.writeln('${e.timestamp.toIso8601String()},"${e.eventType}","${e.severity}","${_escape(e.message)}","${_escape(e.dataJson ?? "")}"');
}
return sb.toString();
}
String _generateGpsCsv(List<FieldTestGpsRecord> records) {
final sb = StringBuffer();
sb.writeln('timestamp,latitude,longitude,altitude_meters,raw_speed_mph,filtered_speed_mph,heading_degrees,accuracy_meters,gps_health,accepted,rejection_reason');
for (final r in records) {
sb.writeln('${r.timestamp.toIso8601String()},${r.latitude},${r.longitude},${r.altitude ?? ""},${r.rawSpeed != null ? (r.rawSpeed! * 2.23694).toStringAsFixed(2) : ""},${r.filteredSpeed != null ? (r.filteredSpeed! * 2.23694).toStringAsFixed(2) : ""},${r.heading ?? ""},${r.accuracy ?? ""},"${r.gpsHealth}",${r.accepted},"${_escape(r.rejectionReason ?? "")}"');
}
return sb.toString();
}
String _generateImuCsv(List<FieldTestImuRecord> records) {
final sb = StringBuffer();
sb.writeln('timestamp,accelerometer_x,accelerometer_y,accelerometer_z,gyro_x,gyro_y,gyro_z,roll_degrees,pitch_degrees,longitudinal_g,lateral_g,filtered_longitudinal_g,filtered_lateral_g');
for (final r in records) {
sb.writeln('${r.timestamp.toIso8601String()},${r.accelerometerX ?? ""},${r.accelerometerY ?? ""},${r.accelerometerZ ?? ""},${r.gyroX ?? ""},${r.gyroY ?? ""},${r.gyroZ ?? ""},${r.roll ?? ""},${r.pitch ?? ""},${r.longitudinalG ?? ""},${r.lateralG ?? ""},${r.filteredLongitudinalG ?? ""},${r.filteredLateralG ?? ""}');
}
return sb.toString();
}
String _generateTelemetryCsv(List<TelemetrySample> samples) {
final sb = StringBuffer();
sb.writeln('timestamp,latitude,longitude,altitude_meters,speed_mph,heading_degrees,lean_degrees,pitch_degrees,longitudinal_g,lateral_g');
for (final s in samples) {
sb.writeln('${s.timestamp.toIso8601String()},${s.latitude ?? ""},${s.longitude ?? ""},${s.altitude ?? ""},${s.speed?.toStringAsFixed(2) ?? ""},${s.heading?.toStringAsFixed(1) ?? ""},${s.leanAngle?.toStringAsFixed(2) ?? ""},${s.pitchAngle?.toStringAsFixed(2) ?? ""},${s.longitudinalG?.toStringAsFixed(3) ?? ""},${s.lateralG?.toStringAsFixed(3) ?? ""}');
}
return sb.toString();
}
String _escape(String input) {
return input.replaceAll('"', '""');
}
}

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,11 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import '../models/telemetry_sample.dart';
import '../models/field_test_event.dart';
import 'location_service.dart';
import 'sensor_fusion_service.dart';
import 'compass_service.dart';
import 'field_test/field_test_logger.dart';
import 'field_test/field_test_models.dart';
import 'validation/telemetry_validator.dart';
final telemetryServiceProvider = Provider<TelemetryService>((ref) {
@ -39,17 +39,16 @@ class TelemetryService {
StreamSubscription? _leanSub;
StreamSubscription? _pitchSub;
StreamSubscription? _gForceSub;
Timer? _samplingTimer;
StreamSubscription? _compassSub;
Timer? _samplingTimer;
// Latest states
Position? _latestPosition;
DateTime? _latestImuTimestamp;
double? _latestLean;
double? _latestPitch;
double _latestLean = 0.0;
double _latestPitch = 0.0;
GForceData? _latestGForce;
double _latestCompassHeading = 0.0;
DateTime? _latestImuTimestamp;
TelemetryService(
this._locationService,
@ -63,19 +62,15 @@ class TelemetryService {
void _init() {
_locationService.onGpsDiagnostic = (pos, accepted, reason) {
if (_fieldTestLogger.isSessionActive) {
final health = pos.accuracy <= 15.0 ? 'Healthy' : (pos.accuracy <= 35.0 ? 'Degraded' : 'Poor');
_fieldTestLogger.logGpsEvent(
if (_fieldTestLogger.isSessionActive && !accepted) {
_fieldTestLogger.log(
eventType: FieldTestEventType.gpsJumpRejected,
severity: EventSeverity.error,
message: 'GPS fix rejected: $reason',
latitude: pos.latitude,
longitude: pos.longitude,
altitude: pos.altitude,
rawSpeed: pos.speed,
filteredSpeed: _latestPosition?.speed ?? pos.speed,
heading: pos.heading,
accuracy: pos.accuracy,
gpsHealth: health,
accepted: accepted,
rejectionReason: reason,
speed: pos.speed * 2.23694,
gpsAccuracy: pos.accuracy,
timestamp: pos.timestamp,
);
}
@ -99,19 +94,6 @@ class TelemetryService {
_gForceSub = _sensorFusionService.gForceStream.listen((gForce) {
_latestGForce = gForce;
_latestImuTimestamp = DateTime.now();
if (_fieldTestLogger.isSessionActive) {
_fieldTestLogger.logImuDiagnostic(
accelX: gForce.accelX,
accelY: gForce.accelY,
accelZ: gForce.accelZ,
roll: _latestLean,
pitch: _latestPitch,
longitudinalG: gForce.accelY / 9.80665,
lateralG: gForce.accelX / 9.80665,
filteredLongitudinalG: gForce.longitudinal,
filteredLateralG: gForce.lateral,
);
}
_emitSample();
});
@ -162,10 +144,13 @@ class TelemetryService {
// Log diagnostic if sample is suspect or rejected during an active field test
if (_fieldTestLogger.isSessionActive && (quality.isSuspect || quality.isRejected)) {
_fieldTestLogger.logEvent(
eventType: FieldTestEventType.imuAnomaly,
_fieldTestLogger.log(
eventType: quality.isRejected ? FieldTestEventType.sampleRejected : FieldTestEventType.sampleSuspect,
message: 'Sample validation ${quality.status.name}: ${quality.reasons.join(", ")}',
severity: quality.isRejected ? FieldTestSeverity.critical : FieldTestSeverity.warning,
severity: quality.isRejected ? EventSeverity.critical : EventSeverity.warning,
telemetryTimestamp: rawSample.timestamp,
speed: speedMph,
overallConfidence: quality.overallConfidence,
);
}

View File

@ -1,329 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:drift/native.dart';
import 'package:drift/drift.dart' as drift;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/database/database.dart';
import 'package:motorcycle_dash/services/field_test/field_test_models.dart';
import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample;
import 'package:motorcycle_dash/models/field_test_event.dart';
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
import 'package:motorcycle_dash/services/field_test/field_test_export_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late ProviderContainer container;
late FieldTestLogger logger;
late FieldTestExportService exportService;
setUp(() async {
SharedPreferences.setMockInitialValues({});
db = AppDatabase.forTesting(NativeDatabase.memory());
container = ProviderContainer(
overrides: [
databaseProvider.overrideWithValue(db),
],
);
logger = container.read(fieldTestLoggerProvider);
exportService = container.read(fieldTestExportServiceProvider);
logger = FieldTestLogger(db);
});
tearDown(() async {
container.dispose();
logger.dispose();
await db.close();
});
group('Field Test Logger - Session Management & Lifecycle', () {
test('startSession creates a new session and records environment info', () async {
final bike = GarageMotorcycle(
id: 1,
year: 2024,
makeId: 'ducati',
makeName: 'Ducati',
modelId: 'panigale_v4',
modelName: 'Panigale V4',
variantId: 's',
variantName: 'S',
nickname: 'Red Beast',
isDefault: true,
dateAdded: DateTime.now(),
lastUpdated: DateTime.now(),
);
final sessionId = await logger.startSession(
rideId: 101,
bike: bike,
rollOffset: 1.5,
pitchOffset: -0.8,
);
expect(sessionId, isNotNull);
group('Field Test Logger API Tests', () {
test('Session lifecycle starts and stops cleanly', () async {
logger.startSession(10);
expect(logger.isSessionActive, isTrue);
expect(logger.activeSessionId, equals(sessionId));
expect(logger.activeRideId, 10);
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId!))).getSingle();
expect(session.rideId, equals(101));
expect(session.motorcycleMake, equals('Ducati'));
expect(session.motorcycleModel, equals('Panigale V4'));
expect(session.calibrationRollOffset, equals(1.5));
expect(session.calibrationPitchOffset, equals(-0.8));
expect(session.completed, isFalse);
});
test('stopSession finalizes session, flushes data, and calculates health summary', () async {
final sessionId = await logger.startSession(rideId: 102);
expect(sessionId, isNotNull);
// Log some events
logger.logEvent(
eventType: FieldTestEventType.gpsFix,
severity: FieldTestSeverity.debug,
message: 'GPS fix acquired',
);
logger.logGpsEvent(
latitude: 37.7749,
longitude: -122.4194,
accuracy: 5.0,
gpsHealth: 'Healthy',
accepted: true,
);
await logger.stopSession();
expect(logger.timelineEvents.any((e) => e.eventType == FieldTestEventType.rideStarted), isTrue);
final summary = await logger.stopSession();
expect(logger.isSessionActive, isFalse);
expect(logger.activeSessionId, isNull);
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId!))).getSingle();
expect(session.completed, isTrue);
expect(session.endedAt, isNotNull);
expect(session.healthSummaryJson, isNotNull);
expect(summary.rideId, 10);
});
test('Immediate flush occurs on critical severity events', () async {
final sessionId = await logger.startSession(rideId: 103);
expect(sessionId, isNotNull);
test('Queue drops lowest-priority events when buffer capacity is reached', () {
logger.startSession(11);
logger.logEvent(
eventType: FieldTestEventType.loggerError,
severity: FieldTestSeverity.critical,
message: 'Critical subsystem failure occurred',
// Fill queue with 1,000 info events
for (int i = 0; i < 1005; i++) {
logger.log(
eventType: FieldTestEventType.databaseFlush,
severity: EventSeverity.info,
message: 'Flush event $i',
rideId: 11,
);
}
// Add a critical event
logger.log(
eventType: FieldTestEventType.databaseRecovery,
severity: EventSeverity.critical,
message: 'Critical DB Recovery',
rideId: 11,
);
// Critical event should immediately flush to DB without waiting 5s timer
final events = await (db.select(db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId!))).get();
expect(events.any((e) => e.severity == 'critical'), isTrue);
});
});
group('Field Test Logger - GPS Diagnostics & Outlier Rejection', () {
test('Records accepted and rejected GPS points with explicit rejection reasons', () async {
final sessionId = await logger.startSession(rideId: 104);
expect(sessionId, isNotNull);
// Accepted fix
logger.logGpsEvent(
latitude: 34.0522,
longitude: -118.2437,
rawSpeed: 25.0,
accuracy: 4.2,
gpsHealth: 'Healthy',
accepted: true,
);
// Rejected fix (impossible speed)
logger.logGpsEvent(
latitude: 34.0530,
longitude: -118.2440,
rawSpeed: 120.0, // 268 MPH
accuracy: 45.0,
gpsHealth: 'Poor',
accepted: false,
rejectionReason: 'Impossible speed (268.4 MPH > 223 MPH)',
);
await logger.flush();
final gpsRecords = await (db.select(db.fieldTestGpsRecords)..where((g) => g.sessionId.equals(sessionId!))).get();
expect(gpsRecords.length, equals(2));
expect(gpsRecords.where((g) => g.accepted).length, equals(1));
expect(gpsRecords.where((g) => !g.accepted).length, equals(1));
expect(gpsRecords.firstWhere((g) => !g.accepted).rejectionReason, contains('Impossible speed'));
});
test('Tracks GPS outage duration and recovery time accurately', () async {
await logger.startSession(rideId: 105);
logger.logGpsSignalLoss();
// Simulate brief delay
await Future.delayed(const Duration(milliseconds: 50));
logger.logGpsSignalRecovery();
logger.logGpsBaselineReset();
await logger.flush();
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.gpsLost), isTrue);
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.gpsRecovered), isTrue);
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.gpsBaselineReset), isTrue);
});
});
group('Field Test Logger - IMU & Sensor Fusion Diagnostics', () {
test('Detects and logs vibration spikes exceeding delta threshold', () async {
final sessionId = await logger.startSession(rideId: 106);
expect(sessionId, isNotNull);
// Normal IMU sample (delta = 0.05G)
logger.logImuDiagnostic(
accelX: 0.1,
accelY: 0.5,
accelZ: 9.8,
longitudinalG: 0.52,
filteredLongitudinalG: 0.50,
);
// Vibration spike (delta = 1.34G > 0.8G threshold)
logger.logImuDiagnostic(
accelX: 0.2,
accelY: 2.14,
accelZ: 9.8,
longitudinalG: 2.14,
filteredLongitudinalG: 0.80,
);
await logger.flush();
final imuRecords = await (db.select(db.fieldTestImuRecords)..where((i) => i.sessionId.equals(sessionId!))).get();
expect(imuRecords.length, equals(2));
final spikeEvents = logger.liveLogs.where((l) => l.eventType == FieldTestEventType.imuSpikeFiltered);
expect(spikeEvents.length, equals(1));
expect(spikeEvents.first.message, contains('Vibration Spike Filtered'));
});
});
group('Field Test Logger - Road Alerts & TTS', () {
test('Logs alert reception and TTS speech results', () async {
await logger.startSession(rideId: 107);
logger.logRoadAlertEvent(
type: FieldTestEventType.roadAlertReceived,
providerId: 'community_traffic',
category: 'Police',
title: 'Speed Trap Reported',
distanceMeters: 250,
);
logger.logTts(success: true, phrase: 'Police report ahead.');
logger.logTts(success: false, phrase: 'Hazard ahead.', error: 'Audio focus lost');
expect(logger.liveLogs.any((l) => l.message.contains('Police')), isTrue);
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.ttsSuccess), isTrue);
expect(logger.liveLogs.any((l) => l.eventType == FieldTestEventType.ttsFailure), isTrue);
});
});
group('Field Test Logger - Safe Retention & Active Protection', () {
test('Active session is protected from deletion', () async {
final sessionId = await logger.startSession(rideId: 108);
expect(sessionId, isNotNull);
final deleted = await logger.deleteSession(sessionId!);
expect(deleted, isFalse); // Must reject active session deletion
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).getSingleOrNull();
expect(session, isNotNull);
});
test('Inactive session can be deleted with cascade cleanup', () async {
final sessionId = await logger.startSession(rideId: 109);
expect(sessionId, isNotNull);
logger.logEvent(
eventType: FieldTestEventType.speedChanged,
severity: FieldTestSeverity.info,
message: 'Speed 45 MPH',
);
await logger.stopSession();
final deleted = await logger.deleteSession(sessionId!);
expect(deleted, isTrue);
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId))).getSingleOrNull();
expect(session, isNull);
final events = await (db.select(db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId))).get();
expect(events.isEmpty, isTrue);
});
});
group('Field Test Logger - Export Service', () {
test('Generates complete ZIP archive with README, JSON, CSVs, and diagnostics', () async {
// 1. Create a simulated ride in DB
final rideId = await db.into(db.rides).insert(
RidesCompanion.insert(
startTime: DateTime.now().subtract(const Duration(minutes: 20)),
endTime: drift.Value(DateTime.now()),
distanceMiles: const drift.Value(12.4),
topSpeedMph: const drift.Value(78.5),
averageSpeed: const drift.Value(45.2),
maxLeanLeft: const drift.Value(34.2),
maxLeanRight: const drift.Value(31.8),
maxAccelG: const drift.Value(0.85),
maxBrakingG: const drift.Value(-0.92),
movingTime: const drift.Value(960),
stoppedTime: const drift.Value(240),
motorcycleMake: const drift.Value('Yamaha'),
motorcycleModel: const drift.Value('MT-09'),
),
);
// 2. Create telemetry samples for the ride
await db.into(db.telemetrySamples).insert(
TelemetrySamplesCompanion.insert(
rideId: rideId,
timestamp: DateTime.now(),
latitude: const drift.Value(37.7749),
longitude: const drift.Value(-122.4194),
speed: const drift.Value(45.0),
heading: const drift.Value(180.0),
leanAngle: const drift.Value(-15.2),
longitudinalG: const drift.Value(0.24),
lateralG: const drift.Value(0.12),
),
);
// 3. Start & log field test session
final sessionId = await logger.startSession(rideId: rideId);
expect(sessionId, isNotNull);
logger.logGpsEvent(
latitude: 37.7749,
longitude: -122.4194,
rawSpeed: 20.1,
filteredSpeed: 20.1,
gpsHealth: 'Healthy',
accepted: true,
);
logger.logImuDiagnostic(
accelX: 0.1,
accelY: 0.2,
accelZ: 9.8,
longitudinalG: 0.2,
lateralG: 0.1,
);
await logger.stopSession();
// 4. Test README / Report generation directly
final session = await (db.select(db.fieldTestSessions)..where((s) => s.id.equals(sessionId!))).getSingle();
expect(session.completed, isTrue);
final events = await (db.select(db.fieldTestEvents)..where((e) => e.sessionId.equals(sessionId!))).get();
expect(events.isNotEmpty, isTrue);
expect(exportService, isNotNull);
expect(logger.droppedEventsCount, greaterThanOrEqualTo(5));
expect(logger.timelineEvents.any((e) => e.severity == EventSeverity.critical), isTrue);
});
});
}

View File

@ -0,0 +1,254 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:drift/native.dart';
import 'package:drift/drift.dart' as drift;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample;
import 'package:motorcycle_dash/models/field_test_event.dart';
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
import 'package:motorcycle_dash/services/field_test/field_test_export_service.dart';
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late FieldTestLogger logger;
late FieldTestExportService exportService;
setUp(() async {
SharedPreferences.setMockInitialValues({});
db = AppDatabase.forTesting(NativeDatabase.memory());
logger = FieldTestLogger(db);
exportService = FieldTestExportService(db);
});
tearDown(() async {
logger.dispose();
await db.close();
});
group('Phase 10: Forensic Event Logging & De-duplication', () {
test('Logs GPS stale event on initial timeout, then single GPS recovery event', () async {
logger.startSession(1);
final validator = TelemetryValidator();
final startTime = DateTime(2026, 8, 28, 12, 0, 0);
// 1. Send normal samples
for (int i = 0; i < 5; i++) {
final sample = TelemetrySample(
timestamp: startTime.add(Duration(milliseconds: i * 50)),
latitude: 37.7749,
longitude: -122.4194,
speed: 45.0,
gpsAccuracy: 3.0,
gpsAgeMs: 50,
);
final q = validator.validate(sample);
logger.processTelemetrySample(sample, q);
}
// 2. Send 10 consecutive stale samples (gpsAgeMs = 2500ms, moving at 45 MPH)
for (int i = 5; i < 15; i++) {
final sample = TelemetrySample(
timestamp: startTime.add(Duration(milliseconds: i * 50)),
latitude: 37.7749,
longitude: -122.4194,
speed: 45.0,
gpsAccuracy: 12.0,
gpsAgeMs: 2500,
);
final q = validator.validate(sample);
logger.processTelemetrySample(sample, q);
}
// 3. Verify exactly ONE gpsStale event was created (de-duplicated)
final staleEvents = logger.timelineEvents.where((e) => e.eventType == FieldTestEventType.gpsStale).toList();
expect(staleEvents.length, 1);
expect(staleEvents.first.severity, EventSeverity.warning);
expect(staleEvents.first.gpsAgeMs, 2500);
// 4. Send recovered sample (gpsAgeMs = 50ms)
final recoveredSample = TelemetrySample(
timestamp: startTime.add(const Duration(milliseconds: 15 * 50)),
latitude: 37.7750,
longitude: -122.4194,
speed: 45.0,
gpsAccuracy: 3.2,
gpsAgeMs: 50,
);
final qRecovered = validator.validate(recoveredSample);
logger.processTelemetrySample(recoveredSample, qRecovered);
// 5. Verify gpsRecovered event was created with metadata
final recoveredEvents = logger.timelineEvents.where((e) => e.eventType == FieldTestEventType.gpsRecovered).toList();
expect(recoveredEvents.length, 1);
expect(recoveredEvents.first.severity, EventSeverity.info);
expect(recoveredEvents.first.metadata?['maxGpsAgeMs'], 2500);
});
test('De-duplicates sustained IMU saturation (>120 m/s^2)', () async {
logger.startSession(2);
final validator = TelemetryValidator();
final startTime = DateTime(2026, 8, 28, 12, 0, 0);
// Send 8 saturated samples in a row
for (int i = 0; i < 8; i++) {
final sample = TelemetrySample(
timestamp: startTime.add(Duration(milliseconds: i * 50)),
speed: 35.0,
accelX: 135.0, // > 120 saturation limit
accelY: 0.0,
accelZ: 9.8,
);
final q = validator.validate(sample);
logger.processTelemetrySample(sample, q);
}
// Verify only 1 IMU_SATURATION event was emitted
final satEvents = logger.timelineEvents.where((e) => e.eventType == FieldTestEventType.imuSaturation).toList();
expect(satEvents.length, 1);
// Return to normal
final normalSample = TelemetrySample(
timestamp: startTime.add(const Duration(milliseconds: 500)),
speed: 35.0,
accelX: 2.0,
accelY: 0.0,
accelZ: 9.8,
);
final qNorm = validator.validate(normalSample);
logger.processTelemetrySample(normalSample, qNorm);
final recEvents = logger.timelineEvents.where((e) => e.eventType == FieldTestEventType.imuRecovered).toList();
expect(recEvents.length, 1);
});
test('Logs significant acceleration and braking events', () async {
logger.startSession(3);
final validator = TelemetryValidator();
final time = DateTime(2026, 8, 28, 12, 0, 0);
// Acceleration: 0.78g
final accelSample = TelemetrySample(
timestamp: time,
speed: 40.0,
longitudinalG: 0.78,
);
logger.processTelemetrySample(accelSample, validator.validate(accelSample));
// Braking: -0.92g
final brakeSample = TelemetrySample(
timestamp: time.add(const Duration(seconds: 10)),
speed: 25.0,
longitudinalG: -0.92,
);
logger.processTelemetrySample(brakeSample, validator.validate(brakeSample));
expect(logger.timelineEvents.any((e) => e.eventType == FieldTestEventType.accelerationEvent), isTrue);
expect(logger.timelineEvents.any((e) => e.eventType == FieldTestEventType.brakingEvent), isTrue);
});
});
group('Phase 10: Database Persistence & Crash Recovery', () {
test('Batch flushes events to SQLite with chronological ordering and ride filtering', () async {
logger.startSession(42);
final now = DateTime(2026, 8, 28, 14, 0, 0);
logger.log(
eventType: FieldTestEventType.gpsStale,
severity: EventSeverity.warning,
message: 'GPS Stale event 1',
rideId: 42,
timestamp: now,
);
logger.log(
eventType: FieldTestEventType.gpsRecovered,
severity: EventSeverity.info,
message: 'GPS Recovered event 2',
rideId: 42,
timestamp: now.add(const Duration(seconds: 2)),
);
await logger.flush();
final eventsInDb = await (db.select(db.fieldTestEvents)
..where((e) => e.rideId.equals(42))
..orderBy([(e) => drift.OrderingTerm(expression: e.timestamp)])
).get();
expect(eventsInDb.length, greaterThanOrEqualTo(2));
expect(eventsInDb.first.eventType, 'RIDE_STARTED');
expect(eventsInDb[1].eventType, 'GPS_STALE');
expect(eventsInDb[2].eventType, 'GPS_RECOVERED');
});
test('Crash Recovery: Interrupted ride summary reconstruction', () async {
final rideStart = DateTime(2026, 8, 28, 10, 0, 0);
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
startTime: rideStart,
));
// Insert samples without finishing the ride
for (int i = 0; i < 30; i++) {
await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert(
rideId: rideId,
timestamp: rideStart.add(Duration(seconds: i)),
latitude: drift.Value(37.7749 + (i * 0.0002)),
longitude: const drift.Value(-122.4194),
speed: const drift.Value(45.0),
gpsAccuracy: const drift.Value(3.0),
));
}
// Reconstruct summary
final summary = await logger.generateAndSaveSummary(rideId, wasRecovered: true);
expect(summary.wasRecoveredAfterCrash, isTrue);
expect(summary.totalSamples, 30);
expect(summary.validSamples, 30);
expect(summary.rideQualityScore, greaterThanOrEqualTo(85));
expect(summary.rideQualityRating, 'EXCELLENT');
});
});
group('Phase 10: Forensic Export Generation', () {
test('CSV export generates exact standard columns and formatted rows', () async {
logger.startSession(99);
final time = DateTime(2026, 8, 28, 15, 0, 0);
logger.log(
eventType: FieldTestEventType.gpsJumpRejected,
severity: EventSeverity.error,
message: 'Impossible velocity spike',
rideId: 99,
timestamp: time,
speed: 150.0,
gpsAccuracy: 5.0,
);
await logger.flush();
final csv = await exportService.generateFieldTestEventsCsv(99);
expect(csv, contains('timestamp,ride_id,event_type,severity,message,speed,latitude,longitude,gps_accuracy,gps_age_ms,lean_angle,longitudinal_g,lateral_g,confidence,metadata'));
expect(csv, contains('GPS_JUMP_REJECTED'));
expect(csv, contains('Impossible velocity spike'));
});
test('JSON session export produces complete forensic payload', () async {
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
startTime: DateTime(2026, 8, 28, 16, 0, 0),
distanceMiles: const drift.Value(15.2),
topSpeedMph: const drift.Value(72.5),
));
logger.startSession(rideId);
await logger.flush();
await logger.generateAndSaveSummary(rideId);
final jsonStr = await exportService.generateFieldTestSessionJson(rideId);
expect(jsonStr, contains('"exportVersion": "1.0.0"'));
expect(jsonStr, contains('"rideMetadata"'));
expect(jsonStr, contains('"summary"'));
expect(jsonStr, contains('"events"'));
});
});
}

View File

@ -0,0 +1,221 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:drift/native.dart';
import 'package:drift/drift.dart' as drift;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:motorcycle_dash/database/database.dart' hide TelemetrySample;
import 'package:motorcycle_dash/models/telemetry_sample.dart';
import 'package:motorcycle_dash/services/field_test/field_test_logger.dart';
import 'package:motorcycle_dash/services/field_test/field_test_export_service.dart';
import 'package:motorcycle_dash/services/validation/telemetry_validator.dart';
import 'replay/telemetry_replay_engine.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late AppDatabase db;
late FieldTestLogger logger;
late FieldTestExportService exportService;
late TelemetryValidator validator;
late TelemetryReplayEngine replayEngine;
setUp(() async {
SharedPreferences.setMockInitialValues({});
db = AppDatabase.forTesting(NativeDatabase.memory());
logger = FieldTestLogger(db);
exportService = FieldTestExportService(db);
validator = TelemetryValidator();
replayEngine = TelemetryReplayEngine(validator: validator);
});
tearDown(() async {
logger.dispose();
replayEngine.dispose();
await db.close();
});
group('Phase 10: Step 17 Realistic 30-Minute Synthetic Ride Replay', () {
test('Executes full 30-minute multi-phase ride and generates complete forensic summary', () async {
final rideStart = DateTime(2026, 8, 28, 14, 0, 0);
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
startTime: rideStart,
));
logger.startSession(rideId);
final syntheticSamples = <TelemetrySample>[];
DateTime curTime = rideStart;
double curLat = 37.774900;
double curLon = -122.419400;
// Phase A: 10 minutes Urban Stop & Start (600 seconds, sampled at 1.0s resolution)
for (int i = 0; i < 600; i++) {
final isStopped = (i >= 60 && i < 120) || (i >= 240 && i < 300) || (i >= 480 && i < 540);
final speed = isStopped ? 0.0 : 25.0 + (i % 10);
if (!isStopped) {
curLat += 0.00005;
}
syntheticSamples.add(TelemetrySample(
timestamp: curTime,
latitude: curLat,
longitude: curLon,
speed: speed,
gpsAccuracy: isStopped ? 8.0 : 4.0,
gpsAgeMs: 50,
leanAngle: isStopped ? 0.0 : ((i % 6) - 3.0),
longitudinalG: isStopped ? 0.0 : 0.05,
lateralG: 0.0,
));
curTime = curTime.add(const Duration(seconds: 1));
}
// Phase B: 15 minutes Highway Cruise (900 seconds) + 0-60 Sprint
for (int i = 0; i < 900; i++) {
double speed = 68.0 + (i % 5);
double longG = 0.02;
// Controlled 0-60 Launch at beginning of highway on-ramp
if (i == 10) {
speed = 45.0;
longG = 0.68; // Hard acceleration event
}
curLat += 0.00020;
syntheticSamples.add(TelemetrySample(
timestamp: curTime,
latitude: curLat,
longitude: curLon,
speed: speed,
gpsAccuracy: 3.2,
gpsAgeMs: 40,
leanAngle: 0.5,
longitudinalG: longG,
lateralG: 0.01,
));
curTime = curTime.add(const Duration(seconds: 1));
}
// Phase C: 5 minutes Spirited Mountain Cornering (300 seconds)
for (int i = 0; i < 300; i++) {
final lean = (i % 20 < 10) ? 38.0 : -41.5; // Aggressive cornering
final speed = 48.0;
curLat += 0.00010;
curLon += 0.00008;
// Hard braking entry into hairpin corner
double longG = (i == 40) ? -0.88 : 0.0;
syntheticSamples.add(TelemetrySample(
timestamp: curTime,
latitude: curLat,
longitude: curLon,
speed: speed,
gpsAccuracy: 4.5,
gpsAgeMs: 60,
leanAngle: lean,
longitudinalG: longG,
lateralG: 0.45,
));
curTime = curTime.add(const Duration(seconds: 1));
}
// Injection 1: GPS degradation & recovery episode (10 seconds under overpass)
for (int i = 0; i < 10; i++) {
syntheticSamples.add(TelemetrySample(
timestamp: curTime,
latitude: curLat,
longitude: curLon,
speed: 50.0,
gpsAccuracy: 42.0, // Degraded accuracy > 35m
gpsAgeMs: 2400, // Stale fix > 2000ms
leanAngle: 0.0,
));
curTime = curTime.add(const Duration(seconds: 1));
}
// Injection 2: One Rejected GPS Teleportation Jump
syntheticSamples.add(TelemetrySample(
timestamp: curTime,
latitude: curLat + 0.05, // 5.5 km teleport jump
longitude: curLon,
speed: 285.0, // Impossible speed spike
gpsAccuracy: 5.0,
gpsAgeMs: 50,
));
curTime = curTime.add(const Duration(seconds: 1));
// Injection 3: Telemetry Gap (3.0s delay)
curTime = curTime.add(const Duration(seconds: 3));
syntheticSamples.add(TelemetrySample(
timestamp: curTime,
latitude: curLat + 0.0001,
longitude: curLon,
speed: 45.0,
gpsAccuracy: 3.5,
gpsAgeMs: 50,
));
// Injection 4: IMU shock / saturation event
syntheticSamples.add(TelemetrySample(
timestamp: curTime.add(const Duration(seconds: 1)),
latitude: curLat + 0.0002,
longitude: curLon,
speed: 45.0,
accelX: 140.0, // Raw saturation shock
accelY: 0.0,
accelZ: 9.8,
));
// Play scenario through replay engine & logger
final scenario = ReplayScenario(name: '30-Minute Real-World Simulation', samples: syntheticSamples);
final validatedSamples = await replayEngine.play(scenario);
// Batch persist samples into database
final companions = validatedSamples.map((s) => TelemetrySamplesCompanion.insert(
rideId: rideId,
timestamp: s.timestamp,
latitude: drift.Value(s.latitude),
longitude: drift.Value(s.longitude),
speed: drift.Value(s.speed),
gpsAccuracy: drift.Value(s.gpsAccuracy),
leanAngle: drift.Value(s.leanAngle),
longitudinalG: drift.Value(s.longitudinalG),
lateralG: drift.Value(s.lateralG),
)).toList();
await db.batch((b) => b.insertAll(db.telemetrySamples, companions));
for (final s in validatedSamples) {
logger.processTelemetrySample(s, s.quality!);
}
await logger.flush();
final summary = await logger.generateAndSaveSummary(rideId);
// Verify Summary Integrity & Metrics
expect(summary.totalSamples, syntheticSamples.length);
expect(summary.validSamples, greaterThan(1700));
expect(summary.rejectedSamples, greaterThanOrEqualTo(1)); // Teleportation jump rejected
expect(summary.gpsStaleEventCount, greaterThanOrEqualTo(1));
expect(summary.gpsJumpRejectCount, greaterThanOrEqualTo(1));
expect(summary.imuSaturationCount, greaterThanOrEqualTo(1));
expect(summary.accelerationEventCount, greaterThanOrEqualTo(1));
expect(summary.brakingEventCount, greaterThanOrEqualTo(1));
expect(summary.telemetryGapCount, greaterThanOrEqualTo(1));
expect(summary.maxLeanAngle, closeTo(41.5, 0.5));
expect(summary.rideQualityScore, greaterThanOrEqualTo(85));
expect(summary.rideQualityRating, 'EXCELLENT');
// Verify Event Timeline & Exports
final csv = await exportService.generateFieldTestEventsCsv(rideId);
expect(csv, contains('GPS_STALE'));
expect(csv, contains('GPS_JUMP_REJECTED'));
expect(csv, contains('ACCELERATION_EVENT'));
expect(csv, contains('BRAKING_EVENT'));
final jsonDoc = await exportService.generateFieldTestSessionJson(rideId);
expect(jsonDoc, contains('"rideQualityScore": ${summary.rideQualityScore}'));
});
});
}