2026-06-03 14:25:20 +02:00
|
|
|
|
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.
|
|
|
|
|
|
*
|
2026-09-08 16:20:14 +02:00
|
|
|
|
* 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.
|
2026-06-03 14:25:20 +02:00
|
|
|
|
*
|
|
|
|
|
|
* Accel-std-dev notes
|
|
|
|
|
|
* -------------------
|
2026-09-08 16:20:14 +02:00
|
|
|
|
* 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.
|
2026-06-03 14:25:20 +02:00
|
|
|
|
*/
|
|
|
|
|
|
@OptIn(ExperimentalCoroutinesApi::class)
|
|
|
|
|
|
class EventDetectorTest {
|
|
|
|
|
|
|
2026-09-08 16:20:14 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
|
|
|
|
|
*/
|
2026-06-03 14:25:20 +02:00
|
|
|
|
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],
|
2026-09-08 16:20:14 +02:00
|
|
|
|
* 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).
|
2026-06-03 14:25:20 +02:00
|
|
|
|
*/
|
|
|
|
|
|
private fun alternatingAccelFrames(
|
|
|
|
|
|
n: Int,
|
2026-09-08 16:20:14 +02:00
|
|
|
|
hi: Double = 4.5,
|
2026-06-03 14:25:20 +02:00
|
|
|
|
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 ->
|
2026-08-25 15:21:34 +02:00
|
|
|
|
// 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).
|
2026-06-03 14:25:20 +02:00
|
|
|
|
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 ->
|
2026-08-25 15:21:34 +02:00
|
|
|
|
// Variability established before the drop - see the note in the test above.
|
|
|
|
|
|
alternatingAccelFrames(n = config.windowSize, speedMps = 10.0, timeOffset = 0)
|
2026-09-08 16:20:14 +02:00
|
|
|
|
// Drop of 8 m/s > brakingHighConfidencePeakDrop (1.5)
|
2026-06-03 14:25:20 +02:00
|
|
|
|
alternatingAccelFrames(
|
|
|
|
|
|
n = config.brakingSustainedFrames + 5,
|
2026-09-08 16:20:14 +02:00
|
|
|
|
hi = 4.5,
|
2026-06-03 14:25:20 +02:00
|
|
|
|
lo = 0.5,
|
2026-09-08 16:20:14 +02:00
|
|
|
|
speedMps = 2.0, // drop from 10 → 2 m/s
|
2026-06-03 14:25:20 +02:00
|
|
|
|
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 ->
|
2026-08-25 15:21:34 +02:00
|
|
|
|
// Variability established before the drop - see `hard brake triggers BRAKING event`.
|
|
|
|
|
|
alternatingAccelFrames(n = config.windowSize, speedMps = 3.0, timeOffset = 0)
|
2026-09-08 16:20:14 +02:00
|
|
|
|
// 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.
|
2026-06-03 14:25:20 +02:00
|
|
|
|
alternatingAccelFrames(
|
|
|
|
|
|
n = config.brakingSustainedFrames + 5,
|
2026-09-08 16:20:14 +02:00
|
|
|
|
hi = 4.5,
|
2026-06-03 14:25:20 +02:00
|
|
|
|
lo = 0.5,
|
2026-09-08 16:20:14 +02:00
|
|
|
|
speedMps = 1.8, // drop = 1.2 m/s
|
2026-06-03 14:25:20 +02:00
|
|
|
|
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,
|
2026-09-08 16:20:14 +02:00
|
|
|
|
gyroMagnitude = 0.8, // mean → above the 0.6 threshold
|
2026-06-03 14:25:20 +02:00
|
|
|
|
speedMps = 4.0, // above 2 m/s → bearing also checked
|
2026-09-08 16:20:14 +02:00
|
|
|
|
bearingChangeDegPerSec = 20.0, // above 15 °/s → both signals agree
|
2026-06-03 14:25:20 +02:00
|
|
|
|
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 ->
|
2026-09-08 16:20:14 +02:00
|
|
|
|
detector.processSample(0.3, 0.8, 4.0, 20.0, 53.5, 10.0, i * 20L)
|
2026-06-03 14:25:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-09-08 16:20:14 +02:00
|
|
|
|
gyroMagnitude = 0.9, // above the 0.6 gyro threshold
|
2026-06-03 14:25:20 +02:00
|
|
|
|
speedMps = 1.0, // below 2 m/s → bearing not enforced
|
2026-09-08 16:20:14 +02:00
|
|
|
|
bearingChangeDegPerSec = 3.0, // below the 15 °/s bearing threshold
|
2026-06-03 14:25:20 +02:00
|
|
|
|
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
|
2026-09-08 16:20:14 +02:00
|
|
|
|
val gyro = if (i % 7 == 0) 0.35 else 0.05 // occasional spike but mean stays < 0.6
|
2026-06-03 14:25:20 +02:00
|
|
|
|
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
|
2026-09-08 16:20:14 +02:00
|
|
|
|
// gyro mean stays below 0.6 (only 1/7 frames spike to 0.35) → no TURNING
|
2026-06-03 14:25:20 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-25 15:21:34 +02:00
|
|
|
|
// 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
|
2026-09-08 16:20:14 +02:00
|
|
|
|
// 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) {
|
2026-06-03 14:25:20 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|