Make the event detector a CAM rate input, not a ride-stats readout

The detector's only live consumer is the CAM transmit-rate policy: every
emitted event calls CamTransmitLoop.onDetectedEvent, raising the beacon
rate from 1 Hz to the elevated rate for five seconds so nearby stations
get denser updates through a manoeuvre. Counting one's own braking events
is not a goal of this project, so the display is gone and the detector
stays: the live per-type counters and their notification text, the event
pins and detail sheet on the trip review map, and the event chip on the
history card. Events are still persisted and exported to CSV, which is
the only route to the tuning measurement section 11.3 says is missing.

Fix two defects found while documenting the detector.

TripRecordingService overrode nine of DetectionConfig's twelve parameters
in its constructor, so the tests validated the Phase A defaults while the
phone ran something materially less sensitive. The tuned values are now
the defaults and the override is deleted; the numbers moved location, not
value, so detector sensitivity is unchanged. EventDetectorTest now sets
only windowSize and the sustained-frame counts and inherits every signal
threshold, which cannot drift again. That was not a free change and makes
the same point from the other side: at the real thresholds the old stimuli
triggered nothing. Accel alternating 3.5/0.5 gives a std dev of 1.5 and
never clears 1.8, and the moderate-braking case used a 0.8 m/s drop that
never clears 1.0. Those stimuli are re-derived against the real values.

brakingHighConfidenceRate was documented as a rate but has always been
compared against the peak cumulative drop from the onset speed, which
grows with episode length, so HIGH was assigned more readily than the name
implied. Renamed to brakingHighConfidencePeakDrop rather than changing the
comparison: "lost more than 1.5 m/s in one episode" is coherent, whereas a
rate off a 1 Hz speed signal sampled at 50 Hz spikes on a near-zero
divisor early in an episode. Output is unchanged, so the existing
confidence assertions stay evidence instead of being re-baselined.

Docs 11.3/11.4 updated in place, including the correction of a claim that
detected events do not reach the V2X side; the rate-bump path already
existed when that was written. 55 tests, 0 failures.
This commit is contained in:
Ashin Walpola
2026-09-08 16:20:14 +02:00
parent 83ccf335bb
commit 1ad123a6f8
16 changed files with 132 additions and 312 deletions
+2 -2
View File
@@ -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.
@@ -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)
}
}
@@ -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,
)
@@ -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(
@@ -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)
@@ -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,
)
@@ -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(
@@ -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) {
@@ -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<DetectedEventEntity?>(null) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
val mapViewRef = remember { mutableStateOf<MapView?>(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 ─────────────────────────────────────────────────────────
@@ -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<List<RecordedTripEntity>> = repository.getAllTrips()
// ── Trip review ───────────────────────────────────────────────────────────
private val _selectedTripEvents = MutableStateFlow<List<DetectedEventEntity>>(emptyList())
val selectedTripEvents: StateFlow<List<DetectedEventEntity>> = _selectedTripEvents.asStateFlow()
/** Load events for [tripId] into [selectedTripEvents]. */
fun loadTripEvents(tripId: Long) {
viewModelScope.launch {
repository.getEventsForTrip(tripId).collect { events ->
_selectedTripEvents.value = events
}
}
}
// ── Recording control ─────────────────────────────────────────────────────
/**
-4
View File
@@ -292,10 +292,6 @@
<string name="nav_trips">Fahrten</string>
<!-- Phase A: Recording screen event counters -->
<string name="rec_events_detected">Erkannte Ereignisse</string>
<string name="rec_event_braking">Bremsen</string>
<string name="rec_event_turning">Abbiegen</string>
<string name="rec_event_stopping">Anhalten</string>
<string name="rec_stream_event_detection">Ereigniserkennung</string>
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
-4
View File
@@ -305,10 +305,6 @@
<string name="nav_trips">Trips</string>
<!-- Phase A: Recording screen event counters -->
<string name="rec_events_detected">Detected Events</string>
<string name="rec_event_braking">Braking</string>
<string name="rec_event_turning">Turning</string>
<string name="rec_event_stopping">Stopping</string>
<string name="rec_stream_event_detection">Event Detection</string>
<string name="rec_open_session_log">CSV Session Log</string>
@@ -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)
}
+18 -1
View File
@@ -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 |
Binary file not shown.
Binary file not shown.