diff --git a/README.md b/README.md index 8f9c900..743cdc7 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ Both paths converge at `CamUseCaseRepository`, which normalises whatever arrived **DENM transmission**; CiT One path only. Triggers the stationary vehicle profile (`hln-sv`, causeCode 94) via the consider it Use Case API. This is a manual antenna and range test tool. It is never triggered by a detected event or a use case alert, and the control is hidden entirely on the ESP32-C5 path. -**Trip recording**; foreground service records all sensor streams and detects cycling events (braking, turning, stopping) using orientation-independent signal processing. Works fully offline with no OBU connected. +**Trip recording**; foreground service records all sensor streams and detects cycling manoeuvres (braking, turning, stopping) using orientation-independent signal processing. Works fully offline with no OBU connected. The detected manoeuvres are not shown in the app - they raise the CAM transmit rate through the manoeuvre on the ESP32-C5 path, and are kept in the trip's CSV export for offline analysis. -**Trip review**; past trips displayed on an OpenStreetMap layer with detected events overlaid as coloured pins. Tap any pin for event details. +**Trip review**; past trips displayed as a route on an OpenStreetMap layer, with duration and distance. **CSV export**; every sensor sample written to a timestamped CSV in real time. Trip exports additionally include the V2X messages received and their RSSI. Shareable via the standard Android share sheet. diff --git a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt index ac5804c..1727eb0 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/MainActivity.kt @@ -248,10 +248,7 @@ class MainActivity : AppCompatActivity() { val trip = trips.firstOrNull { it.id == tripId } if (trip != null) { - TripReviewScreen( - trip = trip, - viewModel = tripViewModel, - ) + TripReviewScreen(trip = trip) } } diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt index 50c8fcd..517f723 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/DetectionConfig.kt @@ -4,7 +4,26 @@ package com.hawhamburg.micr0bu.domain.detection * All detection thresholds in one place. * * Pass a custom instance to [EventDetector] to tune behaviour without - * recompiling. The defaults match the Phase A specification. + * recompiling. + * + * **These defaults are the values the app actually runs.** They are *not* the + * Phase A specification figures. Phase A specified a more sensitive detector; + * running it on a real bicycle over-triggered, so every signal threshold was + * raised and every sustain requirement lengthened. Those tuned values used to + * live as literals in `TripRecordingService`'s constructor, which meant the + * unit tests exercised the Phase A defaults and nothing exercised what shipped. + * They are the defaults now so that there is exactly one configuration. + * + * The original Phase A figures, kept for provenance: + * `brakingSpeedDropThreshold` 0.5, `brakingAccelStdDevThreshold` 1.2, + * `brakingSustainedFrames` 15, `turningGyroMeanThreshold` 0.4, + * `turningBearingChangeThreshold` 10.0, `turningSustainedFrames` 20, + * `stoppingSpeedThreshold` 0.5, `stoppingFrames` 100, + * `stoppingAccelStdDevThreshold` 0.15. + * + * What motivated each change was never recorded, and the effect on the + * false-positive and false-negative rates has never been measured. That + * remains open; sensitivity is deliberately unchanged by this consolidation. */ data class DetectionConfig( @@ -17,42 +36,51 @@ data class DetectionConfig( * Minimum speed drop (m/s) from the reference speed at braking onset for * a frame to qualify as a braking frame. */ - val brakingSpeedDropThreshold: Double = 0.5, + val brakingSpeedDropThreshold: Double = 1.0, /** Minimum accel std-dev (m/s²) required for a frame to count as braking. */ - val brakingAccelStdDevThreshold: Double = 1.2, + val brakingAccelStdDevThreshold: Double = 1.8, /** Consecutive braking frames required before an event is emitted. */ - val brakingSustainedFrames: Int = 15, + val brakingSustainedFrames: Int = 25, /** - * Peak speed-drop rate (m/s per GPS update ≈ m/s²) above which the braking - * confidence is upgraded from MEDIUM to HIGH. + * Peak *cumulative* speed drop (m/s) from the onset reference speed above + * which the braking confidence is upgraded from MEDIUM to HIGH. + * + * This is a total drop for the episode, not a rate. It was previously + * named `brakingHighConfidenceRate` and documented as "m/s per GPS update + * ≈ m/s²", but the quantity it is compared against in + * [EventDetector.detectBraking] has always been the cumulative drop, which + * grows for as long as the episode lasts. The name was wrong, not the + * comparison: "the rider lost more than this much speed in one braking + * episode" is a coherent criterion, so the name was corrected to match the + * behaviour rather than the other way round. Detector output is unchanged. */ - val brakingHighConfidenceRate: Double = 1.5, + val brakingHighConfidencePeakDrop: Double = 1.5, // ── TURNING ─────────────────────────────────────────────────────────────── /** Minimum gyro mean (rad/s) required for a frame to qualify as turning. */ - val turningGyroMeanThreshold: Double = 0.4, + val turningGyroMeanThreshold: Double = 0.6, /** Bearing-change rate (°/s) that must be exceeded when speed is above the * minimum threshold for a HIGH-confidence turning confirmation. */ - val turningBearingChangeThreshold: Double = 10.0, + val turningBearingChangeThreshold: Double = 15.0, /** GPS speed (m/s) above which the bearing-change criterion is enforced. */ val turningMinSpeedThreshold: Double = 2.0, /** Consecutive turning frames required before an event is emitted. */ - val turningSustainedFrames: Int = 20, + val turningSustainedFrames: Int = 30, // ── STOPPING ───────────────────────────────────────────────────────────── /** GPS speed (m/s) below which a frame is considered a potential stop. */ - val stoppingSpeedThreshold: Double = 0.5, + val stoppingSpeedThreshold: Double = 0.3, /** Consecutive stop frames required (> this value) before an event is emitted. - * At 50 Hz, 100 frames ≈ 2 s. */ - val stoppingFrames: Int = 100, + * At 50 Hz, 150 frames ≈ 3 s. */ + val stoppingFrames: Int = 150, /** Maximum accel std-dev (m/s²) allowed for a frame to count as stationary. */ - val stoppingAccelStdDevThreshold: Double = 0.15, + val stoppingAccelStdDevThreshold: Double = 0.10, ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt index 3d49393..bd0c272 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/domain/detection/EventDetector.kt @@ -14,6 +14,16 @@ import kotlin.math.abs * to [events] (a hot [SharedFlow]). Debounce is implemented with * consecutive-frame counters, not timers. * + * **Who consumes this.** The detector's live consumer is the CAM transmit-rate + * policy: [com.hawhamburg.micr0bu.service.TripRecordingService] forwards every + * emitted event to + * [com.hawhamburg.micr0bu.service.CamTransmitLoop.onDetectedEvent], which + * raises the CAM rate from 1 Hz to the elevated rate for a hold window so that + * nearby stations get denser updates *through* a manoeuvre rather than only at + * the instant it was detected. These thresholds therefore govern a V2X + * behaviour, not a statistic. Events are also persisted per trip for offline + * analysis and CSV export, but nothing in the UI displays them. + * * GPS updates at 1 Hz whilst sensors fire at ~50 Hz. [speedMps] and * [bearingChangeDegPerSec] should be the values from the last known GPS fix; * the detector compares speed against a *reference speed at braking onset* @@ -37,7 +47,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) { private var brakingFrames = 0 private var brakingOnsetSpeed = 0.0 // reference speed when braking started private var brakingStartTime = 0L - private var peakBrakingDrop = 0.0 // peak speed drop observed during this window + private var peakBrakingDrop = 0.0 // peak CUMULATIVE drop from onset speed, m/s (not a rate) private var peakAccelBraking = 0.0 // ── Turning state ───────────────────────────────────────────────────────── @@ -124,7 +134,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) { if (brakingFrames == config.brakingSustainedFrames) { val confidence = - if (peakBrakingDrop > config.brakingHighConfidenceRate) Confidence.HIGH + if (peakBrakingDrop > config.brakingHighConfidencePeakDrop) Confidence.HIGH else Confidence.MEDIUM _events.tryEmit( diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt index 65a658d..349e52d 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/TripRecordingService.kt @@ -26,9 +26,7 @@ import com.hawhamburg.micr0bu.R import com.hawhamburg.micr0bu.data.TripRepository import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository import com.hawhamburg.micr0bu.data.db.AppDatabase -import com.hawhamburg.micr0bu.domain.detection.DetectionConfig import com.hawhamburg.micr0bu.domain.detection.EventDetector -import com.hawhamburg.micr0bu.domain.detection.EventType import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineScope @@ -108,19 +106,10 @@ class TripRecordingService : Service() { // V2xMessageEntity's KDoc for why nothing is retained outside of one. @Inject lateinit var camUseCaseRepository: CamUseCaseRepository private var v2xLoggingJob: Job? = null - private val detector = EventDetector( - DetectionConfig( - brakingSpeedDropThreshold = 1.0, - brakingAccelStdDevThreshold = 1.8, - brakingSustainedFrames = 25, - turningGyroMeanThreshold = 0.6, - turningBearingChangeThreshold = 15.0, - turningSustainedFrames = 30, - stoppingSpeedThreshold = 0.3, - stoppingFrames = 150, - stoppingAccelStdDevThreshold = 0.10, - ) - ) + // These nine thresholds used to be overridden here; they are now the DetectionConfig + // defaults, so there is one configuration and the unit tests exercise it. Behaviour is + // unchanged - see DetectionConfig's KDoc. + private val detector = EventDetector() // ── Sensor fusion state ─────────────────────────────────────────────────── @@ -153,10 +142,10 @@ class TripRecordingService : Service() { private val gpsTrackBuilder = StringBuilder("[") private var gpsPointCount = 0 - // Event counts - private var brakingCount = 0 - private var turningCount = 0 - private var stoppingCount = 0 + // Number of detected events in this trip. Kept only to populate the trips.eventCount + // column; the per-type counts are no longer surfaced anywhere (see EventDetector's KDoc + // for why the detector still runs). + private var detectedEventCount = 0 // ── SensorEventListener ─────────────────────────────────────────────────── @@ -253,9 +242,7 @@ class TripRecordingService : Service() { ).also { it.acquire() } detector.reset() - brakingCount = 0 - turningCount = 0 - stoppingCount = 0 + detectedEventCount = 0 distanceMetres = 0f prevLat = Double.NaN prevLon = Double.NaN @@ -273,15 +260,14 @@ class TripRecordingService : Service() { isRecording = true, currentTripId = currentTripId, elapsedSeconds = 0L, - brakingCount = 0, - turningCount = 0, - stoppingCount = 0, currentSpeedMs = 0f, ) } } - // Collect detector events and persist them + // Collect detector events. The detector's primary consumer is the CAM transmit rate + // policy below; persistence is retained so a recorded ride can still be analysed + // offline, but nothing in the UI reads it. serviceScope.launch { detector.events.collect { event -> if (currentTripId < 0) return@collect @@ -289,19 +275,7 @@ class TripRecordingService : Service() { // Bump the CAM transmit rate through the maneuver, not just at detection instant. // No-op on the CiT One path (see CamTransmitLoop's KDoc). camTransmitLoop.onDetectedEvent() - when (event.type) { - EventType.BRAKING -> brakingCount++ - EventType.TURNING -> turningCount++ - EventType.STOPPING -> stoppingCount++ - } - TripServiceBus.update { - copy( - brakingCount = this@TripRecordingService.brakingCount, - turningCount = this@TripRecordingService.turningCount, - stoppingCount = this@TripRecordingService.stoppingCount, - ) - } - updateNotification() + detectedEventCount++ } } @@ -356,7 +330,7 @@ class TripRecordingService : Service() { v2xLoggingJob = null val endTime = System.currentTimeMillis() - val totalEvents = brakingCount + turningCount + stoppingCount + val totalEvents = detectedEventCount // Close GPS track JSON gpsTrackBuilder.append("]") @@ -454,10 +428,7 @@ class TripRecordingService : Service() { private fun buildNotification(elapsedSeconds: Long) = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("Recording trip") - .setContentText( - "⏱ ${formatElapsed(elapsedSeconds)} · " + - "🚨 $brakingCount 🔄 $turningCount 🛑 $stoppingCount" - ) + .setContentText("⏱ ${formatElapsed(elapsedSeconds)}") .setSmallIcon(R.mipmap.ic_launcher_foreground) .setOngoing(true) .setOnlyAlertOnce(true) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt b/app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt index 0667e04..05f719f 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/service/TripServiceBus.kt @@ -17,9 +17,6 @@ object TripServiceBus { val isRecording: Boolean = false, val currentTripId: Long = -1L, val elapsedSeconds: Long = 0L, - val brakingCount: Int = 0, - val turningCount: Int = 0, - val stoppingCount: Int = 0, val currentSpeedMs: Float = 0f, ) diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt index 03e9a38..c9e349f 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/RecordingScreen.kt @@ -141,46 +141,6 @@ fun RecordingScreen( Spacer(Modifier.height(8.dp)) - // ── Event Detection Counters ───────────────────────────────────────── - if (state.isRecording || tripServiceState.isRecording) { - Text( - stringResource(R.string.rec_events_detected), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.align(Alignment.Start), - ) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - EventCountBadge( - label = stringResource(R.string.rec_event_braking), - count = tripServiceState.brakingCount, - color = Color(0xFFFF5252), - ) - EventCountBadge( - label = stringResource(R.string.rec_event_turning), - count = tripServiceState.turningCount, - color = Color(0xFFFFB300), - ) - EventCountBadge( - label = stringResource(R.string.rec_event_stopping), - count = tripServiceState.stoppingCount, - color = Color(0xFF42A5F5), - ) - } - } - - Spacer(Modifier.height(4.dp)) - } - // ── CSV Session Log shortcut ───────────────────────────────────────── if (!state.isRecording) { OutlinedButton( @@ -228,25 +188,6 @@ fun RecordingScreen( } } -@Composable -private fun EventCountBadge(label: String, count: Int, color: Color) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = count.toString(), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily.Monospace, - color = color, - ) - Spacer(Modifier.height(2.dp)) - Text( - text = label, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } -} - @Composable private fun StreamRow(label: String, active: Boolean) { Row( diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt index c0909c5..d584027 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripHistoryScreen.kt @@ -145,7 +145,6 @@ private fun TripCard( val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0) TripStatChip("⏱ ${formatDuration(durationSec)}") TripStatChip("📍 ${formatDistance(trip.distanceMetres)}") - TripStatChip("🚨 ${trip.eventCount} events") } } IconButton(onClick = onOpen) { diff --git a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt index 5759ffe..cfaa251 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/ui/screens/TripReviewScreen.kt @@ -42,9 +42,7 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner -import com.hawhamburg.micr0bu.data.db.DetectedEventEntity import com.hawhamburg.micr0bu.data.db.RecordedTripEntity -import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel import kotlinx.coroutines.launch import org.osmdroid.config.Configuration import org.osmdroid.tileprovider.tilesource.TileSourceFactory @@ -61,7 +59,6 @@ import java.util.Locale @Composable fun TripReviewScreen( trip: RecordedTripEntity, - viewModel: TripRecordingViewModel, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -70,15 +67,8 @@ fun TripReviewScreen( // provider is ready before MapView is constructed in the factory block. initOsmReview(context) - LaunchedEffect(trip.id) { viewModel.loadTripEvents(trip.id) } - val events by viewModel.selectedTripEvents.collectAsState() - val gpsPoints = remember(trip.gpsTrackJson) { parseGpsTrack(trip.gpsTrackJson) } - var selectedEvent by remember { mutableStateOf(null) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val mapViewRef = remember { mutableStateOf(null) } val lifecycleOwner = LocalLifecycleOwner.current @@ -108,13 +98,6 @@ fun TripReviewScreen( fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text( - "🚨 ${events.count { it.type == "BRAKING" }} " + - "🔄 ${events.count { it.type == "TURNING" }} " + - "🛑 ${events.count { it.type == "STOPPING" }}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } // ── Map ─────────────────────────────────────────────────────────────── @@ -141,33 +124,6 @@ fun TripReviewScreen( mv.overlays.add(polyline) } - // Event pins - events.forEach { event -> - val pinColor = when (event.type) { - "BRAKING" -> Color(0xFFFF5252) - "TURNING" -> Color(0xFFFFB300) - "STOPPING" -> Color(0xFF42A5F5) - else -> Color.Gray - } - val marker = Marker(mv).apply { - position = GeoPoint(event.latitude, event.longitude) - setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM) - title = "${event.type} (${event.confidence})" - setOnMarkerClickListener { _, _ -> - selectedEvent = event - scope.launch { sheetState.show() } - true - } - // Solid-circle pin in the event color - icon = GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(pinColor.toArgb()) - setSize(32, 32) - } - } - mv.overlays.add(marker) - } - // Auto-fit the camera to the track — deferred via post() so the // MapView has been measured before zoomToBoundingBox is called. // Calling it with width/height == 0 (before first layout) crashes osmdroid. @@ -193,82 +149,6 @@ fun TripReviewScreen( ) } - // ── Event detail bottom sheet ───────────────────────────────────────────── - val ev = selectedEvent - if (ev != null) { - ModalBottomSheet( - onDismissRequest = { selectedEvent = null }, - sheetState = sheetState, - dragHandle = { BottomSheetDefaults.DragHandle() }, - ) { - EventDetailSheet(event = ev, onDismiss = { - scope.launch { sheetState.hide() }.invokeOnCompletion { selectedEvent = null } - }) - } - } -} - -// ── Event detail sheet content ──────────────────────────────────────────────── - -@Composable -private fun EventDetailSheet(event: DetectedEventEntity, onDismiss: () -> Unit) { - // Created here (not as a top-level static field) so it always uses the - // current locale even if the user changes it while the app is running. - val sdf = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } - - val accentColor = when (event.type) { - "BRAKING" -> Color(0xFFFF5252) - "TURNING" -> Color(0xFFFFB300) - "STOPPING" -> Color(0xFF42A5F5) - else -> MaterialTheme.colorScheme.primary - } - - Column(modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 32.dp)) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { - Text( - event.type.replaceFirstChar { it.titlecase() }, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = accentColor, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = onDismiss) { - Icon(Icons.Default.Close, contentDescription = "Close") - } - } - - Text( - "Confidence: ${event.confidence}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - sdf.format(Date(event.timestamp)), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.height(12.dp)) - HorizontalDivider() - Spacer(Modifier.height(12.dp)) - - EventDetailRow("Speed", "%.1f m/s".format(event.speedMps)) - EventDetailRow("Peak accel", "%.2f m/s²".format(event.peakAccelMagnitude)) - EventDetailRow("Peak gyro", "%.3f rad/s".format(event.peakGyroMagnitude)) - EventDetailRow("Duration", "${event.durationMs} ms") - EventDetailRow("Location", "%.5f°, %.5f°".format(event.latitude, event.longitude)) - } -} - -@Composable -private fun EventDetailRow(label: String, value: String) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Text(value, style = MaterialTheme.typography.bodyMedium, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium) - } } // ── GPS track parsing ───────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt index 31a0e47..c68ad19 100644 --- a/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt +++ b/app/src/main/java/com/hawhamburg/micr0bu/viewmodel/TripRecordingViewModel.kt @@ -9,7 +9,6 @@ import androidx.lifecycle.viewModelScope import com.hawhamburg.micr0bu.data.TripRepository import com.hawhamburg.micr0bu.data.shareTripCsv import com.hawhamburg.micr0bu.data.db.AppDatabase -import com.hawhamburg.micr0bu.data.db.DetectedEventEntity import com.hawhamburg.micr0bu.data.db.RecordedTripEntity import com.hawhamburg.micr0bu.service.TripRecordingService import com.hawhamburg.micr0bu.service.TripServiceBus @@ -66,20 +65,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic /** All recorded trips, newest first. */ val trips: Flow> = repository.getAllTrips() - // ── Trip review ─────────────────────────────────────────────────────────── - - private val _selectedTripEvents = MutableStateFlow>(emptyList()) - val selectedTripEvents: StateFlow> = _selectedTripEvents.asStateFlow() - - /** Load events for [tripId] into [selectedTripEvents]. */ - fun loadTripEvents(tripId: Long) { - viewModelScope.launch { - repository.getEventsForTrip(tripId).collect { events -> - _selectedTripEvents.value = events - } - } - } - // ── Recording control ───────────────────────────────────────────────────── /** diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c857167..687fcbb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -292,10 +292,6 @@ Fahrten - Erkannte Ereignisse - Bremsen - Abbiegen - Anhalten Ereigniserkennung CSV-Sitzungsprotokoll diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d742eed..2efa2d6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -305,10 +305,6 @@ Trips - Detected Events - Braking - Turning - Stopping Event Detection CSV Session Log diff --git a/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt b/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt index 87b5f40..eac92d1 100644 --- a/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt +++ b/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt @@ -21,34 +21,34 @@ import kotlin.math.sqrt * * No Android emulator required — all production classes have zero Android imports. * - * The test [config] uses a smaller window and fewer sustained frames than the - * production defaults so tests run in milliseconds without generating thousands - * of synthetic samples. + * The test [config] shortens only the window and the sustained-frame counts, so + * tests run in milliseconds instead of generating thousands of synthetic + * samples. Every *signal* threshold is inherited from [DetectionConfig]'s + * defaults, which are the values the app actually runs — the two cannot drift + * apart, which they previously did: the service overrode nine of the twelve + * parameters and these tests validated the un-overridden ones. * * Accel-std-dev notes * ------------------- - * A production threshold of 1.2 m/s² requires genuine variability in the window. - * In the "hard brake" tests we alternate between high and low accel values - * (e.g. 3.5 / 0.5), which yields std dev ≈ 1.5 with a 10-sample window. + * The braking accel-std-dev threshold of 1.8 m/s² requires genuine variability + * in the window. In the "hard brake" tests we alternate between high and low + * accel values (4.5 / 0.5), which yields a population std dev of |hi − lo| / 2 + * = 2.0 in a full window — above the threshold with margin. */ @OptIn(ExperimentalCoroutinesApi::class) class EventDetectorTest { - /** Tighter config so fewer frames are needed to trigger each event. */ + /** + * Shortens the window and the sustained-frame counts so fewer synthetic frames are + * needed per test. Every signal threshold is deliberately left at its default, so + * these tests exercise the thresholds the app ships with. Do not restate a signal + * threshold here — that is exactly how the two configurations drifted apart before. + */ private val config = DetectionConfig( windowSize = 10, brakingSustainedFrames = 5, turningSustainedFrames = 8, stoppingFrames = 20, - // Keep production thresholds for all signal values: - brakingSpeedDropThreshold = 0.5, - brakingAccelStdDevThreshold = 1.2, - brakingHighConfidenceRate = 1.5, - turningGyroMeanThreshold = 0.4, - turningBearingChangeThreshold = 10.0, - turningMinSpeedThreshold = 2.0, - stoppingSpeedThreshold = 0.5, - stoppingAccelStdDevThreshold = 0.15, ) private lateinit var detector: EventDetector @@ -71,12 +71,12 @@ class EventDetectorTest { /** * Produces [n] frames with alternating accelMagnitude values of [hi] and [lo], - * giving a population std dev of |hi - lo| / 2, which exceeds the production - * threshold of 1.2 m/s² when hi=3.5 and lo=0.5 (std dev = 1.5). + * giving a population std dev of |hi - lo| / 2, which exceeds the shipping + * threshold of 1.8 m/s² when hi=4.5 and lo=0.5 (std dev = 2.0). */ private fun alternatingAccelFrames( n: Int, - hi: Double = 3.5, + hi: Double = 4.5, lo: Double = 0.5, speedMps: Double = 10.0, bearingChangeDps: Double = 0.0, @@ -139,12 +139,12 @@ class EventDetectorTest { @Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events -> // Variability established before the drop - see the note in the test above. alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0) - // Drop of 8 m/s > brakingHighConfidenceRate (1.5) + // Drop of 8 m/s > brakingHighConfidencePeakDrop (1.5) alternatingAccelFrames( n = config.brakingSustainedFrames + 5, - hi = 3.5, + hi = 4.5, lo = 0.5, - speedMps = 2.0, // drop from 10 → 8 m/s + speedMps = 2.0, // drop from 10 → 2 m/s timeOffset = config.windowSize, ) val braking = events.filter { it.type == EventType.BRAKING } @@ -159,12 +159,16 @@ class EventDetectorTest { @Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events -> // Variability established before the drop - see `hard brake triggers BRAKING event`. alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0) - // Drop of 0.8 m/s — above speed-drop threshold (0.5) but below high-conf rate (1.5) + // Drop of 1.2 m/s — above the speed-drop threshold (1.0) but below the + // high-confidence peak drop (1.5), so this must land as MEDIUM. The window + // between those two values is narrow at the shipping thresholds, which is + // itself worth knowing: MEDIUM braking is only emitted for drops in + // (1.0, 1.5] m/s. alternatingAccelFrames( n = config.brakingSustainedFrames + 5, - hi = 3.5, + hi = 4.5, lo = 0.5, - speedMps = 2.2, // drop = 0.8 m/s + speedMps = 1.8, // drop = 1.2 m/s timeOffset = config.windowSize, ) val braking = events.filter { it.type == EventType.BRAKING } @@ -179,9 +183,9 @@ class EventDetectorTest { repeat(total) { i -> detector.processSample( accelMagnitude = 0.3, - gyroMagnitude = 0.8, // mean → well above 0.4 threshold + gyroMagnitude = 0.8, // mean → above the 0.6 threshold speedMps = 4.0, // above 2 m/s → bearing also checked - bearingChangeDegPerSec = 15.0, // above 10 °/s → both signals agree + bearingChangeDegPerSec = 20.0, // above 15 °/s → both signals agree latitude = 53.5, longitude = 10.0, timestamp = i * 20L, @@ -193,7 +197,7 @@ class EventDetectorTest { @Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events -> val total = config.windowSize + config.turningSustainedFrames + 4 repeat(total) { i -> - detector.processSample(0.3, 0.8, 4.0, 15.0, 53.5, 10.0, i * 20L) + detector.processSample(0.3, 0.8, 4.0, 20.0, 53.5, 10.0, i * 20L) } val turning = events.filter { it.type == EventType.TURNING } assertTrue(turning.isNotEmpty()) @@ -205,9 +209,9 @@ class EventDetectorTest { repeat(total) { i -> detector.processSample( accelMagnitude = 0.2, - gyroMagnitude = 0.6, // above gyro threshold + gyroMagnitude = 0.9, // above the 0.6 gyro threshold speedMps = 1.0, // below 2 m/s → bearing not enforced - bearingChangeDegPerSec = 3.0, // below bearing threshold + bearingChangeDegPerSec = 3.0, // below the 15 °/s bearing threshold latitude = 53.5, longitude = 10.0, timestamp = i * 20L, @@ -253,7 +257,7 @@ class EventDetectorTest { // Speed stays at zero; occasional accel/gyro spikes from bag jostle repeat(50) { i -> val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold - val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.4 + val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.6 detector.processSample( accelMagnitude = accel, gyroMagnitude = gyro, @@ -265,7 +269,7 @@ class EventDetectorTest { ) } // speed = 0 → no speed drop possible → no BRAKING - // gyro mean stays below 0.4 (only 1/7 frames spike to 0.35) → no TURNING + // gyro mean stays below 0.6 (only 1/7 frames spike to 0.35) → no TURNING val unwanted = events.filter { it.type == EventType.BRAKING || it.type == EventType.TURNING } assertTrue("Bag movement must not trigger BRAKING or TURNING, got: $events", unwanted.isEmpty()) } @@ -299,13 +303,12 @@ class EventDetectorTest { } // Second stop episode. Deliberately longer than the first: stopping also requires the // accel std dev to be BELOW a threshold, and the rolling window still holds the five - // moving samples above. It takes 8 further frames for those to drain out far enough for - // the std dev to fall under 0.15, and only then does the counter start. The first episode - // needs no such allowance because the window begins empty. - // - // stoppingFrames + 5 was not enough - the second episode reached 17 of the 21 frames it - // needs and silently emitted nothing, which is what made this test fail. - repeat(config.stoppingFrames + 10) { + // moving samples above. At the shipping threshold of 0.10 m/s² even a single 0.5 sample + // left in a 10-sample window gives a std dev of ~0.14, so ALL five have to be evicted + // before the counter can start - that is a full windowSize of stationary frames. Only + // then do the 21 qualifying frames the event needs begin to accumulate. The first + // episode needs no such allowance because the window begins empty. + repeat(config.stoppingFrames + 20) { detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L) } diff --git a/docs/01-requirements-traceability.md b/docs/01-requirements-traceability.md index 3e94bee..156d23f 100644 --- a/docs/01-requirements-traceability.md +++ b/docs/01-requirements-traceability.md @@ -140,7 +140,7 @@ share no code. The requirement is satisfied twice, by different means. | 11.2 | Running Standard Deviation Event Detector | **Done** | `EventDetector.kt`, `RunningStats.kt` | **18 unit tests, 0 failures** | | 11.3 | Trip Recording Architecture | **Done** | `TripRepository.kt`, `TripRecordingService.kt` *(cited)* | — | | 11.4 | Data Model | **Done** | `data/db/` Room entities *(cited)* | — | -| 11.5 | New UI Elements for Phase A | **Done** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | — | +| 11.5 | New UI Elements for Phase A | **Partial — scope reduced** | `TripHistoryScreen.kt`, `TripReviewScreen.kt` | event pins/counters removed by decision, see note | | 11.6 | Phase A Success Criteria | **Partial** | — | needs a real ride; see Open Items | **Correction note (11.2).** Four `EventDetectorTest` cases had been failing since the initial commit. @@ -149,6 +149,23 @@ described stimuli the detector cannot physically see, because they ignored the s rolling standard-deviation window. Tests corrected, assertions unchanged, detector untouched. This is worth reporting — it is a finding about test design, not a defect. +**Scope note (11.5).** The event-detection UI — the live per-type counters on the recording screen, +the coloured event pins and detail sheet on the trip review map, and the event count on the trip +history card — was removed deliberately. A count of the rider's own braking events is not a goal of +this project. The detector itself still runs: it is the input to the CAM transmit-rate policy +(§ 13), which raises the beacon rate from 1 Hz to the elevated rate for five seconds after a +detected manoeuvre. Events remain persisted and exported to CSV for offline analysis. + +**Defect note (11.2).** Two defects found while documenting the detector were fixed on 2026-09-07. +The nine threshold overrides in `TripRecordingService`'s constructor were promoted to +`DetectionConfig`'s defaults and the override deleted, so there is one configuration and +`EventDetectorTest` exercises the shipping thresholds rather than the superseded Phase A ones; +detector sensitivity is unchanged, and the synthetic stimuli were re-derived because several no +longer cleared the stricter real thresholds. `brakingHighConfidenceRate` was renamed +`brakingHighConfidencePeakDrop`: it was documented as a rate but has always been compared against +the peak cumulative speed drop. The name was corrected rather than the comparison, so detector +output is unchanged and the confidence assertions remain valid evidence. + ## 12. Future Architecture & Open Design Questions | § | Title | Status | Notes | diff --git a/docs/MicrOBU-Technical-Documentation.docx b/docs/MicrOBU-Technical-Documentation.docx index 1a711ac..d4e8b14 100644 Binary files a/docs/MicrOBU-Technical-Documentation.docx and b/docs/MicrOBU-Technical-Documentation.docx differ diff --git a/docs/MicrOBU-User-Guide.docx b/docs/MicrOBU-User-Guide.docx index 1fed90b..0a715ba 100644 Binary files a/docs/MicrOBU-User-Guide.docx and b/docs/MicrOBU-User-Guide.docx differ