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:
@@ -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.
|
**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.
|
**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 }
|
val trip = trips.firstOrNull { it.id == tripId }
|
||||||
|
|
||||||
if (trip != null) {
|
if (trip != null) {
|
||||||
TripReviewScreen(
|
TripReviewScreen(trip = trip)
|
||||||
trip = trip,
|
|
||||||
viewModel = tripViewModel,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,26 @@ package com.hawhamburg.micr0bu.domain.detection
|
|||||||
* All detection thresholds in one place.
|
* All detection thresholds in one place.
|
||||||
*
|
*
|
||||||
* Pass a custom instance to [EventDetector] to tune behaviour without
|
* 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(
|
data class DetectionConfig(
|
||||||
|
|
||||||
@@ -17,42 +36,51 @@ data class DetectionConfig(
|
|||||||
* Minimum speed drop (m/s) from the reference speed at braking onset for
|
* Minimum speed drop (m/s) from the reference speed at braking onset for
|
||||||
* a frame to qualify as a braking frame.
|
* 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. */
|
/** 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. */
|
/** 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
|
* Peak *cumulative* speed drop (m/s) from the onset reference speed above
|
||||||
* confidence is upgraded from MEDIUM to HIGH.
|
* 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 ───────────────────────────────────────────────────────────────
|
// ── TURNING ───────────────────────────────────────────────────────────────
|
||||||
/** Minimum gyro mean (rad/s) required for a frame to qualify as 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
|
/** Bearing-change rate (°/s) that must be exceeded when speed is above the
|
||||||
* minimum threshold for a HIGH-confidence turning confirmation. */
|
* 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. */
|
/** GPS speed (m/s) above which the bearing-change criterion is enforced. */
|
||||||
val turningMinSpeedThreshold: Double = 2.0,
|
val turningMinSpeedThreshold: Double = 2.0,
|
||||||
|
|
||||||
/** Consecutive turning frames required before an event is emitted. */
|
/** Consecutive turning frames required before an event is emitted. */
|
||||||
val turningSustainedFrames: Int = 20,
|
val turningSustainedFrames: Int = 30,
|
||||||
|
|
||||||
// ── STOPPING ─────────────────────────────────────────────────────────────
|
// ── STOPPING ─────────────────────────────────────────────────────────────
|
||||||
/** GPS speed (m/s) below which a frame is considered a potential stop. */
|
/** 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.
|
/** Consecutive stop frames required (> this value) before an event is emitted.
|
||||||
* At 50 Hz, 100 frames ≈ 2 s. */
|
* At 50 Hz, 150 frames ≈ 3 s. */
|
||||||
val stoppingFrames: Int = 100,
|
val stoppingFrames: Int = 150,
|
||||||
|
|
||||||
/** Maximum accel std-dev (m/s²) allowed for a frame to count as stationary. */
|
/** 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
|
* to [events] (a hot [SharedFlow]). Debounce is implemented with
|
||||||
* consecutive-frame counters, not timers.
|
* 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
|
* GPS updates at 1 Hz whilst sensors fire at ~50 Hz. [speedMps] and
|
||||||
* [bearingChangeDegPerSec] should be the values from the last known GPS fix;
|
* [bearingChangeDegPerSec] should be the values from the last known GPS fix;
|
||||||
* the detector compares speed against a *reference speed at braking onset*
|
* 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 brakingFrames = 0
|
||||||
private var brakingOnsetSpeed = 0.0 // reference speed when braking started
|
private var brakingOnsetSpeed = 0.0 // reference speed when braking started
|
||||||
private var brakingStartTime = 0L
|
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
|
private var peakAccelBraking = 0.0
|
||||||
|
|
||||||
// ── Turning state ─────────────────────────────────────────────────────────
|
// ── Turning state ─────────────────────────────────────────────────────────
|
||||||
@@ -124,7 +134,7 @@ class EventDetector(private val config: DetectionConfig = DetectionConfig()) {
|
|||||||
|
|
||||||
if (brakingFrames == config.brakingSustainedFrames) {
|
if (brakingFrames == config.brakingSustainedFrames) {
|
||||||
val confidence =
|
val confidence =
|
||||||
if (peakBrakingDrop > config.brakingHighConfidenceRate) Confidence.HIGH
|
if (peakBrakingDrop > config.brakingHighConfidencePeakDrop) Confidence.HIGH
|
||||||
else Confidence.MEDIUM
|
else Confidence.MEDIUM
|
||||||
|
|
||||||
_events.tryEmit(
|
_events.tryEmit(
|
||||||
|
|||||||
@@ -26,9 +26,7 @@ import com.hawhamburg.micr0bu.R
|
|||||||
import com.hawhamburg.micr0bu.data.TripRepository
|
import com.hawhamburg.micr0bu.data.TripRepository
|
||||||
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
|
import com.hawhamburg.micr0bu.data.cam.CamUseCaseRepository
|
||||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
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.EventDetector
|
||||||
import com.hawhamburg.micr0bu.domain.detection.EventType
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -108,19 +106,10 @@ class TripRecordingService : Service() {
|
|||||||
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
|
// V2xMessageEntity's KDoc for why nothing is retained outside of one.
|
||||||
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
|
@Inject lateinit var camUseCaseRepository: CamUseCaseRepository
|
||||||
private var v2xLoggingJob: Job? = null
|
private var v2xLoggingJob: Job? = null
|
||||||
private val detector = EventDetector(
|
// These nine thresholds used to be overridden here; they are now the DetectionConfig
|
||||||
DetectionConfig(
|
// defaults, so there is one configuration and the unit tests exercise it. Behaviour is
|
||||||
brakingSpeedDropThreshold = 1.0,
|
// unchanged - see DetectionConfig's KDoc.
|
||||||
brakingAccelStdDevThreshold = 1.8,
|
private val detector = EventDetector()
|
||||||
brakingSustainedFrames = 25,
|
|
||||||
turningGyroMeanThreshold = 0.6,
|
|
||||||
turningBearingChangeThreshold = 15.0,
|
|
||||||
turningSustainedFrames = 30,
|
|
||||||
stoppingSpeedThreshold = 0.3,
|
|
||||||
stoppingFrames = 150,
|
|
||||||
stoppingAccelStdDevThreshold = 0.10,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Sensor fusion state ───────────────────────────────────────────────────
|
// ── Sensor fusion state ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -153,10 +142,10 @@ class TripRecordingService : Service() {
|
|||||||
private val gpsTrackBuilder = StringBuilder("[")
|
private val gpsTrackBuilder = StringBuilder("[")
|
||||||
private var gpsPointCount = 0
|
private var gpsPointCount = 0
|
||||||
|
|
||||||
// Event counts
|
// Number of detected events in this trip. Kept only to populate the trips.eventCount
|
||||||
private var brakingCount = 0
|
// column; the per-type counts are no longer surfaced anywhere (see EventDetector's KDoc
|
||||||
private var turningCount = 0
|
// for why the detector still runs).
|
||||||
private var stoppingCount = 0
|
private var detectedEventCount = 0
|
||||||
|
|
||||||
// ── SensorEventListener ───────────────────────────────────────────────────
|
// ── SensorEventListener ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -253,9 +242,7 @@ class TripRecordingService : Service() {
|
|||||||
).also { it.acquire() }
|
).also { it.acquire() }
|
||||||
|
|
||||||
detector.reset()
|
detector.reset()
|
||||||
brakingCount = 0
|
detectedEventCount = 0
|
||||||
turningCount = 0
|
|
||||||
stoppingCount = 0
|
|
||||||
distanceMetres = 0f
|
distanceMetres = 0f
|
||||||
prevLat = Double.NaN
|
prevLat = Double.NaN
|
||||||
prevLon = Double.NaN
|
prevLon = Double.NaN
|
||||||
@@ -273,15 +260,14 @@ class TripRecordingService : Service() {
|
|||||||
isRecording = true,
|
isRecording = true,
|
||||||
currentTripId = currentTripId,
|
currentTripId = currentTripId,
|
||||||
elapsedSeconds = 0L,
|
elapsedSeconds = 0L,
|
||||||
brakingCount = 0,
|
|
||||||
turningCount = 0,
|
|
||||||
stoppingCount = 0,
|
|
||||||
currentSpeedMs = 0f,
|
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 {
|
serviceScope.launch {
|
||||||
detector.events.collect { event ->
|
detector.events.collect { event ->
|
||||||
if (currentTripId < 0) return@collect
|
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.
|
// Bump the CAM transmit rate through the maneuver, not just at detection instant.
|
||||||
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
|
// No-op on the CiT One path (see CamTransmitLoop's KDoc).
|
||||||
camTransmitLoop.onDetectedEvent()
|
camTransmitLoop.onDetectedEvent()
|
||||||
when (event.type) {
|
detectedEventCount++
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +330,7 @@ class TripRecordingService : Service() {
|
|||||||
v2xLoggingJob = null
|
v2xLoggingJob = null
|
||||||
|
|
||||||
val endTime = System.currentTimeMillis()
|
val endTime = System.currentTimeMillis()
|
||||||
val totalEvents = brakingCount + turningCount + stoppingCount
|
val totalEvents = detectedEventCount
|
||||||
|
|
||||||
// Close GPS track JSON
|
// Close GPS track JSON
|
||||||
gpsTrackBuilder.append("]")
|
gpsTrackBuilder.append("]")
|
||||||
@@ -454,10 +428,7 @@ class TripRecordingService : Service() {
|
|||||||
private fun buildNotification(elapsedSeconds: Long) =
|
private fun buildNotification(elapsedSeconds: Long) =
|
||||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
.setContentTitle("Recording trip")
|
.setContentTitle("Recording trip")
|
||||||
.setContentText(
|
.setContentText("⏱ ${formatElapsed(elapsedSeconds)}")
|
||||||
"⏱ ${formatElapsed(elapsedSeconds)} · " +
|
|
||||||
"🚨 $brakingCount 🔄 $turningCount 🛑 $stoppingCount"
|
|
||||||
)
|
|
||||||
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ object TripServiceBus {
|
|||||||
val isRecording: Boolean = false,
|
val isRecording: Boolean = false,
|
||||||
val currentTripId: Long = -1L,
|
val currentTripId: Long = -1L,
|
||||||
val elapsedSeconds: Long = 0L,
|
val elapsedSeconds: Long = 0L,
|
||||||
val brakingCount: Int = 0,
|
|
||||||
val turningCount: Int = 0,
|
|
||||||
val stoppingCount: Int = 0,
|
|
||||||
val currentSpeedMs: Float = 0f,
|
val currentSpeedMs: Float = 0f,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -141,46 +141,6 @@ fun RecordingScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
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 ─────────────────────────────────────────
|
// ── CSV Session Log shortcut ─────────────────────────────────────────
|
||||||
if (!state.isRecording) {
|
if (!state.isRecording) {
|
||||||
OutlinedButton(
|
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
|
@Composable
|
||||||
private fun StreamRow(label: String, active: Boolean) {
|
private fun StreamRow(label: String, active: Boolean) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ private fun TripCard(
|
|||||||
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
|
val durationSec = ((trip.endTime - trip.startTime) / 1000).coerceAtLeast(0)
|
||||||
TripStatChip("⏱ ${formatDuration(durationSec)}")
|
TripStatChip("⏱ ${formatDuration(durationSec)}")
|
||||||
TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
|
TripStatChip("📍 ${formatDistance(trip.distanceMetres)}")
|
||||||
TripStatChip("🚨 ${trip.eventCount} events")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpen) {
|
IconButton(onClick = onOpen) {
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import com.hawhamburg.micr0bu.data.db.DetectedEventEntity
|
|
||||||
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
import com.hawhamburg.micr0bu.data.db.RecordedTripEntity
|
||||||
import com.hawhamburg.micr0bu.viewmodel.TripRecordingViewModel
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
@@ -61,7 +59,6 @@ import java.util.Locale
|
|||||||
@Composable
|
@Composable
|
||||||
fun TripReviewScreen(
|
fun TripReviewScreen(
|
||||||
trip: RecordedTripEntity,
|
trip: RecordedTripEntity,
|
||||||
viewModel: TripRecordingViewModel,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -70,15 +67,8 @@ fun TripReviewScreen(
|
|||||||
// provider is ready before MapView is constructed in the factory block.
|
// provider is ready before MapView is constructed in the factory block.
|
||||||
initOsmReview(context)
|
initOsmReview(context)
|
||||||
|
|
||||||
LaunchedEffect(trip.id) { viewModel.loadTripEvents(trip.id) }
|
|
||||||
val events by viewModel.selectedTripEvents.collectAsState()
|
|
||||||
|
|
||||||
val gpsPoints = remember(trip.gpsTrackJson) { parseGpsTrack(trip.gpsTrackJson) }
|
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 mapViewRef = remember { mutableStateOf<MapView?>(null) }
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
@@ -108,13 +98,6 @@ fun TripReviewScreen(
|
|||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 ───────────────────────────────────────────────────────────────
|
// ── Map ───────────────────────────────────────────────────────────────
|
||||||
@@ -141,33 +124,6 @@ fun TripReviewScreen(
|
|||||||
mv.overlays.add(polyline)
|
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
|
// Auto-fit the camera to the track — deferred via post() so the
|
||||||
// MapView has been measured before zoomToBoundingBox is called.
|
// MapView has been measured before zoomToBoundingBox is called.
|
||||||
// Calling it with width/height == 0 (before first layout) crashes osmdroid.
|
// 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 ─────────────────────────────────────────────────────────
|
// ── GPS track parsing ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.hawhamburg.micr0bu.data.TripRepository
|
import com.hawhamburg.micr0bu.data.TripRepository
|
||||||
import com.hawhamburg.micr0bu.data.shareTripCsv
|
import com.hawhamburg.micr0bu.data.shareTripCsv
|
||||||
import com.hawhamburg.micr0bu.data.db.AppDatabase
|
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.data.db.RecordedTripEntity
|
||||||
import com.hawhamburg.micr0bu.service.TripRecordingService
|
import com.hawhamburg.micr0bu.service.TripRecordingService
|
||||||
import com.hawhamburg.micr0bu.service.TripServiceBus
|
import com.hawhamburg.micr0bu.service.TripServiceBus
|
||||||
@@ -66,20 +65,6 @@ class TripRecordingViewModel(application: Application) : AndroidViewModel(applic
|
|||||||
/** All recorded trips, newest first. */
|
/** All recorded trips, newest first. */
|
||||||
val trips: Flow<List<RecordedTripEntity>> = repository.getAllTrips()
|
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 ─────────────────────────────────────────────────────
|
// ── Recording control ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -292,10 +292,6 @@
|
|||||||
<string name="nav_trips">Fahrten</string>
|
<string name="nav_trips">Fahrten</string>
|
||||||
|
|
||||||
<!-- Phase A: Recording screen event counters -->
|
<!-- 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_stream_event_detection">Ereigniserkennung</string>
|
||||||
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
|
<string name="rec_open_session_log">CSV-Sitzungsprotokoll</string>
|
||||||
|
|
||||||
|
|||||||
@@ -305,10 +305,6 @@
|
|||||||
<string name="nav_trips">Trips</string>
|
<string name="nav_trips">Trips</string>
|
||||||
|
|
||||||
<!-- Phase A: Recording screen event counters -->
|
<!-- 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_stream_event_detection">Event Detection</string>
|
||||||
<string name="rec_open_session_log">CSV Session Log</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.
|
* No Android emulator required — all production classes have zero Android imports.
|
||||||
*
|
*
|
||||||
* The test [config] uses a smaller window and fewer sustained frames than the
|
* The test [config] shortens only the window and the sustained-frame counts, so
|
||||||
* production defaults so tests run in milliseconds without generating thousands
|
* tests run in milliseconds instead of generating thousands of synthetic
|
||||||
* of synthetic samples.
|
* 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
|
* Accel-std-dev notes
|
||||||
* -------------------
|
* -------------------
|
||||||
* A production threshold of 1.2 m/s² requires genuine variability in the window.
|
* The braking accel-std-dev threshold of 1.8 m/s² requires genuine variability
|
||||||
* In the "hard brake" tests we alternate between high and low accel values
|
* in the window. In the "hard brake" tests we alternate between high and low
|
||||||
* (e.g. 3.5 / 0.5), which yields std dev ≈ 1.5 with a 10-sample window.
|
* 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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class EventDetectorTest {
|
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(
|
private val config = DetectionConfig(
|
||||||
windowSize = 10,
|
windowSize = 10,
|
||||||
brakingSustainedFrames = 5,
|
brakingSustainedFrames = 5,
|
||||||
turningSustainedFrames = 8,
|
turningSustainedFrames = 8,
|
||||||
stoppingFrames = 20,
|
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
|
private lateinit var detector: EventDetector
|
||||||
@@ -71,12 +71,12 @@ class EventDetectorTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
|
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
|
||||||
* giving a population std dev of |hi - lo| / 2, which exceeds the production
|
* giving a population std dev of |hi - lo| / 2, which exceeds the shipping
|
||||||
* threshold of 1.2 m/s² when hi=3.5 and lo=0.5 (std dev = 1.5).
|
* threshold of 1.8 m/s² when hi=4.5 and lo=0.5 (std dev = 2.0).
|
||||||
*/
|
*/
|
||||||
private fun alternatingAccelFrames(
|
private fun alternatingAccelFrames(
|
||||||
n: Int,
|
n: Int,
|
||||||
hi: Double = 3.5,
|
hi: Double = 4.5,
|
||||||
lo: Double = 0.5,
|
lo: Double = 0.5,
|
||||||
speedMps: Double = 10.0,
|
speedMps: Double = 10.0,
|
||||||
bearingChangeDps: Double = 0.0,
|
bearingChangeDps: Double = 0.0,
|
||||||
@@ -139,12 +139,12 @@ class EventDetectorTest {
|
|||||||
@Test fun `hard brake with large speed drop has HIGH confidence`() = runCollecting { events ->
|
@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.
|
// Variability established before the drop - see the note in the test above.
|
||||||
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
|
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(
|
alternatingAccelFrames(
|
||||||
n = config.brakingSustainedFrames + 5,
|
n = config.brakingSustainedFrames + 5,
|
||||||
hi = 3.5,
|
hi = 4.5,
|
||||||
lo = 0.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,
|
timeOffset = config.windowSize,
|
||||||
)
|
)
|
||||||
val braking = events.filter { it.type == EventType.BRAKING }
|
val braking = events.filter { it.type == EventType.BRAKING }
|
||||||
@@ -159,12 +159,16 @@ class EventDetectorTest {
|
|||||||
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
@Test fun `moderate speed drop has MEDIUM confidence`() = runCollecting { events ->
|
||||||
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
||||||
alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
|
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(
|
alternatingAccelFrames(
|
||||||
n = config.brakingSustainedFrames + 5,
|
n = config.brakingSustainedFrames + 5,
|
||||||
hi = 3.5,
|
hi = 4.5,
|
||||||
lo = 0.5,
|
lo = 0.5,
|
||||||
speedMps = 2.2, // drop = 0.8 m/s
|
speedMps = 1.8, // drop = 1.2 m/s
|
||||||
timeOffset = config.windowSize,
|
timeOffset = config.windowSize,
|
||||||
)
|
)
|
||||||
val braking = events.filter { it.type == EventType.BRAKING }
|
val braking = events.filter { it.type == EventType.BRAKING }
|
||||||
@@ -179,9 +183,9 @@ class EventDetectorTest {
|
|||||||
repeat(total) { i ->
|
repeat(total) { i ->
|
||||||
detector.processSample(
|
detector.processSample(
|
||||||
accelMagnitude = 0.3,
|
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
|
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,
|
latitude = 53.5,
|
||||||
longitude = 10.0,
|
longitude = 10.0,
|
||||||
timestamp = i * 20L,
|
timestamp = i * 20L,
|
||||||
@@ -193,7 +197,7 @@ class EventDetectorTest {
|
|||||||
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
|
@Test fun `turning with both signals agreeing gets HIGH confidence`() = runCollecting { events ->
|
||||||
val total = config.windowSize + config.turningSustainedFrames + 4
|
val total = config.windowSize + config.turningSustainedFrames + 4
|
||||||
repeat(total) { i ->
|
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 }
|
val turning = events.filter { it.type == EventType.TURNING }
|
||||||
assertTrue(turning.isNotEmpty())
|
assertTrue(turning.isNotEmpty())
|
||||||
@@ -205,9 +209,9 @@ class EventDetectorTest {
|
|||||||
repeat(total) { i ->
|
repeat(total) { i ->
|
||||||
detector.processSample(
|
detector.processSample(
|
||||||
accelMagnitude = 0.2,
|
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
|
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,
|
latitude = 53.5,
|
||||||
longitude = 10.0,
|
longitude = 10.0,
|
||||||
timestamp = i * 20L,
|
timestamp = i * 20L,
|
||||||
@@ -253,7 +257,7 @@ class EventDetectorTest {
|
|||||||
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
|
// Speed stays at zero; occasional accel/gyro spikes from bag jostle
|
||||||
repeat(50) { i ->
|
repeat(50) { i ->
|
||||||
val accel = if (i % 5 == 0) 1.8 else 0.3 // jitter but mean is below std-dev threshold
|
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(
|
detector.processSample(
|
||||||
accelMagnitude = accel,
|
accelMagnitude = accel,
|
||||||
gyroMagnitude = gyro,
|
gyroMagnitude = gyro,
|
||||||
@@ -265,7 +269,7 @@ class EventDetectorTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// speed = 0 → no speed drop possible → no BRAKING
|
// 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 }
|
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())
|
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
|
// 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
|
// 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
|
// moving samples above. At the shipping threshold of 0.10 m/s² even a single 0.5 sample
|
||||||
// the std dev to fall under 0.15, and only then does the counter start. The first episode
|
// left in a 10-sample window gives a std dev of ~0.14, so ALL five have to be evicted
|
||||||
// needs no such allowance because the window begins empty.
|
// 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
|
||||||
// stoppingFrames + 5 was not enough - the second episode reached 17 of the 21 frames it
|
// episode needs no such allowance because the window begins empty.
|
||||||
// needs and silently emitted nothing, which is what made this test fail.
|
repeat(config.stoppingFrames + 20) {
|
||||||
repeat(config.stoppingFrames + 10) {
|
|
||||||
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
|
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.3 | Trip Recording Architecture | **Done** | `TripRepository.kt`, `TripRecordingService.kt` *(cited)* | — |
|
||||||
| 11.4 | Data Model | **Done** | `data/db/` Room entities *(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 |
|
| 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.
|
**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
|
rolling standard-deviation window. Tests corrected, assertions unchanged, detector untouched. This is
|
||||||
worth reporting — it is a finding about test design, not a defect.
|
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
|
## 12. Future Architecture & Open Design Questions
|
||||||
|
|
||||||
| § | Title | Status | Notes |
|
| § | Title | Status | Notes |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user