Files
MicrOBU/app/src/test/java/com/hawhamburg/micr0bu/EventDetectorTest.kt
T
Ashin Walpola 1ad123a6f8 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.
2026-09-08 16:20:14 +02:00

368 lines
18 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.hawhamburg.micr0bu
import com.hawhamburg.micr0bu.domain.detection.Confidence
import com.hawhamburg.micr0bu.domain.detection.DetectedEvent
import com.hawhamburg.micr0bu.domain.detection.DetectionConfig
import com.hawhamburg.micr0bu.domain.detection.EventDetector
import com.hawhamburg.micr0bu.domain.detection.EventType
import com.hawhamburg.micr0bu.domain.detection.RunningStats
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import kotlin.math.sqrt
/**
* Unit tests for [EventDetector] and [RunningStats] using synthetic sensor data.
*
* No Android emulator required — all production classes have zero Android imports.
*
* 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
* -------------------
* 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 {
/**
* 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,
)
private lateinit var detector: EventDetector
@Before fun setUp() { detector = EventDetector(config) }
// ─── Helpers ──────────────────────────────────────────────────────────────
/** Runs [block] inside a coroutine that collects all emitted events. */
private fun runCollecting(
block: suspend kotlinx.coroutines.test.TestScope.(events: MutableList<DetectedEvent>) -> Unit,
) = runTest {
val events = mutableListOf<DetectedEvent>()
val job = launch(UnconfinedTestDispatcher(testScheduler)) {
detector.events.collect { events.add(it) }
}
block(events)
job.cancel()
}
/**
* Produces [n] frames with alternating accelMagnitude values of [hi] and [lo],
* 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 = 4.5,
lo: Double = 0.5,
speedMps: Double = 10.0,
bearingChangeDps: Double = 0.0,
timeOffset: Int = 0,
) {
repeat(n) { i ->
detector.processSample(
accelMagnitude = if (i % 2 == 0) hi else lo,
gyroMagnitude = 0.05,
speedMps = speedMps,
bearingChangeDegPerSec = bearingChangeDps,
latitude = 53.5,
longitude = 10.0,
timestamp = (timeOffset + i) * 20L,
)
}
}
// ─── Normal riding — no false triggers ───────────────────────────────────
@Test fun `normal riding produces no events`() = runCollecting { events ->
// Steady 5 m/s, low gyro, very low accel variance (constant value → stdDev = 0)
repeat(50) { i ->
detector.processSample(
accelMagnitude = 0.2,
gyroMagnitude = 0.1,
speedMps = 5.0,
bearingChangeDegPerSec = 2.0,
latitude = 53.5,
longitude = 10.0,
timestamp = i * 20L,
)
}
assertTrue("No events expected during steady riding, got: $events", events.isEmpty())
}
// ─── Hard brake ───────────────────────────────────────────────────────────
@Test fun `hard brake triggers BRAKING event`() = runCollecting { events ->
// Phase 1: cruising at 10 m/s with the accelerometer variability a moving bike actually
// has. This matters: [EventDetector] requires the speed drop and the accel std dev to be
// true on the SAME frame, and the std dev is a rolling window. Filling phase 1 with a
// constant accel drives that window to zero, so on the one frame where the speed drop
// exists the std dev is still ~0.75 and braking can never start - by the time the window
// has recovered, prevSpeedMps has caught up and the drop is gone.
//
// Constant accel right up to the instant of a brake is also not physical. The IMU is
// sampled continuously while GPS speed lags, so the shaking precedes the reported drop.
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
// Phase 2: GPS reports 4 m/s (drop = 6 m/s > 0.5 threshold).
alternatingAccelFrames(
n = config.brakingSustainedFrames + 5,
speedMps = 4.0,
timeOffset = config.windowSize,
)
val braking = events.filter { it.type == EventType.BRAKING }
assertTrue("Expected at least one BRAKING event, got: $events", braking.isNotEmpty())
}
@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 > brakingHighConfidencePeakDrop (1.5)
alternatingAccelFrames(
n = config.brakingSustainedFrames + 5,
hi = 4.5,
lo = 0.5,
speedMps = 2.0, // drop from 10 → 2 m/s
timeOffset = config.windowSize,
)
val braking = events.filter { it.type == EventType.BRAKING }
assertTrue(braking.isNotEmpty())
assertEquals(
"Large speed drop should yield HIGH confidence",
Confidence.HIGH,
braking.first().confidence,
)
}
@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 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 = 4.5,
lo = 0.5,
speedMps = 1.8, // drop = 1.2 m/s
timeOffset = config.windowSize,
)
val braking = events.filter { it.type == EventType.BRAKING }
assertTrue(braking.isNotEmpty())
assertEquals(Confidence.MEDIUM, braking.first().confidence)
}
// ─── Left turn ────────────────────────────────────────────────────────────
@Test fun `sustained high gyro above 2ms triggers TURNING event`() = runCollecting { events ->
val total = config.windowSize + config.turningSustainedFrames + 4
repeat(total) { i ->
detector.processSample(
accelMagnitude = 0.3,
gyroMagnitude = 0.8, // mean → above the 0.6 threshold
speedMps = 4.0, // above 2 m/s → bearing also checked
bearingChangeDegPerSec = 20.0, // above 15 °/s → both signals agree
latitude = 53.5,
longitude = 10.0,
timestamp = i * 20L,
)
}
assertTrue("Expected TURNING event, got: $events", events.any { it.type == EventType.TURNING })
}
@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, 20.0, 53.5, 10.0, i * 20L)
}
val turning = events.filter { it.type == EventType.TURNING }
assertTrue(turning.isNotEmpty())
assertEquals(Confidence.HIGH, turning.first().confidence)
}
@Test fun `turning at low speed with gyro only gets LOW confidence`() = runCollecting { events ->
val total = config.windowSize + config.turningSustainedFrames + 4
repeat(total) { i ->
detector.processSample(
accelMagnitude = 0.2,
gyroMagnitude = 0.9, // above the 0.6 gyro threshold
speedMps = 1.0, // below 2 m/s → bearing not enforced
bearingChangeDegPerSec = 3.0, // below the 15 °/s bearing threshold
latitude = 53.5,
longitude = 10.0,
timestamp = i * 20L,
)
}
val turning = events.filter { it.type == EventType.TURNING }
assertTrue("Expected TURNING event at low speed, got: $events", turning.isNotEmpty())
assertEquals("Low-speed turn should be LOW confidence", Confidence.LOW, turning.first().confidence)
}
// ─── Full stop ────────────────────────────────────────────────────────────
@Test fun `full stop for required frames triggers STOPPING event`() = runCollecting { events ->
val frames = config.stoppingFrames + 5
repeat(frames) { i ->
detector.processSample(
accelMagnitude = 0.02, // constant → stdDev = 0 < 0.15
gyroMagnitude = 0.01,
speedMps = 0.1, // < 0.5 threshold
bearingChangeDegPerSec = 0.0,
latitude = 53.5,
longitude = 10.0,
timestamp = i * 20L,
)
}
val stopping = events.filter { it.type == EventType.STOPPING }
assertTrue("Expected STOPPING event, got: $events", stopping.isNotEmpty())
assertEquals("STOPPING should always be HIGH confidence", Confidence.HIGH, stopping.first().confidence)
}
@Test fun `STOPPING emitted exactly once per stop episode`() = runCollecting { events ->
val frames = config.stoppingFrames + 30
repeat(frames) { i ->
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, i * 20L)
}
val stopping = events.filter { it.type == EventType.STOPPING }
assertEquals("STOPPING must emit exactly once per episode", 1, stopping.size)
}
// ─── Bag movement while stationary ───────────────────────────────────────
@Test fun `bag movement while stationary does not trigger BRAKING or TURNING`() = runCollecting { events ->
// 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.6
detector.processSample(
accelMagnitude = accel,
gyroMagnitude = gyro,
speedMps = 0.0,
bearingChangeDegPerSec = 1.0,
latitude = 53.5,
longitude = 10.0,
timestamp = i * 20L,
)
}
// speed = 0 → no speed drop possible → no BRAKING
// 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())
}
// ─── Starting from stationary ─────────────────────────────────────────────
@Test fun `starting from stationary does not produce false BRAKING event`() = runCollecting { events ->
// Begin at rest then accelerate — speed only INCREASES
repeat(10) { i ->
detector.processSample(0.1, 0.05, 0.0, 0.0, 53.5, 10.0, i * 20L)
}
repeat(20) { i ->
val speed = i * 0.3 // 0 → 5.7 m/s — monotonically increasing
detector.processSample(0.8, 0.1, speed, 1.0, 53.5, 10.0, (10 + i) * 20L)
}
val braking = events.filter { it.type == EventType.BRAKING }
assertTrue("Starting from stationary must not trigger BRAKING, got: $events", braking.isEmpty())
}
@Test fun `stopping then re-accelerating then stopping triggers two STOPPING events`() = runCollecting { events ->
val stopFrames = config.stoppingFrames + 5
var t = 0
// First stop episode
repeat(stopFrames) {
detector.processSample(0.02, 0.01, 0.1, 0.0, 53.5, 10.0, t++ * 20L)
}
// Brief acceleration — resets the stopping counter
repeat(5) {
detector.processSample(0.5, 0.1, 5.0, 2.0, 53.5, 10.0, t++ * 20L)
}
// 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. 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)
}
val stopping = events.filter { it.type == EventType.STOPPING }
assertEquals("Expected two STOPPING events (one per episode)", 2, stopping.size)
}
// ─── RunningStats unit tests ──────────────────────────────────────────────
@Test fun `RunningStats mean and stdDev are correct for known sequence`() {
val stats = RunningStats(windowSize = 4)
// [2, 4, 4, 4] → mean = 3.5, variance = 0.75
stats.add(2.0); stats.add(4.0); stats.add(4.0); stats.add(4.0)
assertEquals(3.5, stats.mean(), 1e-9)
assertEquals(0.75, stats.variance(), 1e-9)
assertEquals(sqrt(0.75), stats.stdDev(), 1e-9)
}
@Test fun `RunningStats evicts oldest value when window is full`() {
val stats = RunningStats(windowSize = 3)
stats.add(1.0); stats.add(2.0); stats.add(3.0)
assertEquals(2.0, stats.mean(), 1e-9) // [1,2,3] → 2.0
stats.add(10.0)
assertEquals(5.0, stats.mean(), 1e-9) // [2,3,10] → 5.0
}
@Test fun `RunningStats size grows correctly and caps at windowSize`() {
val stats = RunningStats(windowSize = 5)
assertEquals(0, stats.size())
stats.add(1.0); assertEquals(1, stats.size())
stats.add(1.0); assertEquals(2, stats.size())
repeat(10) { stats.add(1.0) }
assertEquals(5, stats.size())
}
@Test fun `RunningStats reset clears all state`() {
val stats = RunningStats(windowSize = 5)
repeat(5) { stats.add(it.toDouble()) }
stats.reset()
assertEquals(0, stats.size())
assertEquals(0.0, stats.mean(), 1e-9)
assertEquals(0.0, stats.stdDev(), 1e-9)
}
@Test fun `RunningStats variance is zero for constant sequence`() {
val stats = RunningStats(windowSize = 10)
repeat(10) { stats.add(3.14) }
assertEquals(0.0, stats.variance(), 1e-9)
}
@Test fun `RunningStats returns zero mean and stdDev for empty window`() {
val stats = RunningStats(windowSize = 10)
assertEquals(0.0, stats.mean(), 1e-9)
assertEquals(0.0, stats.stdDev(), 1e-9)
}
}