diff options
| author | Claude <noreply@anthropic.com> | 2026-05-26 07:13:53 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-26 07:13:53 +0000 |
| commit | 5c3bcee9a574780476ac4a4888b51b76f186c032 (patch) | |
| tree | d983e88e63dd26a099143a5e71e6f356c7faeec5 /test-runner/src/test | |
| parent | d35410c707900075bf4207bab518ead44d43c6e7 (diff) | |
| parent | 8c1d1605f529e32876ba4cb325a019a311c5011d (diff) | |
Merge claude/optimize-nav-location-tracking-Vac5Z into main
- Add notes/photos to finished tracks (TrackDetailSheet)
- Single Nav voice trip report (remove NarrativeStyle, 4-style picker)
- Fix speed coloring on MapLibre 11.x (Expression.step on numeric property)
- Redesign tack detection: time-based windows + circularMAD stability
- Update worklog
https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn
Diffstat (limited to 'test-runner/src/test')
| -rw-r--r-- | test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt | 126 |
1 files changed, 70 insertions, 56 deletions
diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt index 540683d..6d946a0 100644 --- a/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt +++ b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt @@ -5,28 +5,26 @@ import org.junit.Test class TackDetectorTest { - // Build a track that's well past the 2-minute cold-start skip. - // Use 30s spacing: 6 warmup points (3 min) + tack points. - private val STEP_MS = 30_000L // 30 s between points - - private fun warmupPoints(cog: Double, count: Int = 5): List<TrackPoint> = + // 1 Hz GPS (FULL mode). Time-based windows: + // T_SETTLE=30s, T_MANEUVER=30s, START_SKIP=60s. + // Minimum for a candidate to fire: START_SKIP(60) + T_SETTLE(30) + T_MANEUVER/2(15) = 105 s from t0. + // To also have a 30s after-window: apex at ~105s, after-window ends at 105+15+30=150s. + // So a safe warmup leg is 120 pts (t=0..119s), tack apex around t=120s, after-leg ≥ 60 pts. + private val STEP_MS = 1_000L // 1 s between points + + private fun leg(cog: Double, count: Int, startMs: Long, sogKnots: Double = 5.0): List<TrackPoint> = (0 until count).map { i -> - TrackPoint(37.0 + i * 0.005, -122.0, 5.0, cog, 12.0, 0.0, true, i * STEP_MS) + TrackPoint(37.0 + (startMs / 1000 + i) * 0.00001, -122.0, sogKnots, cog, 12.0, 0.0, true, + startMs + i * STEP_MS) } - private fun tackTrack(cogBefore: Double, cogAfter: Double, eachLeg: Int = 5): List<TrackPoint> { - val warmup = warmupPoints(cogBefore) // 5 pts × 30s = 2.5 min, tack at ~2.5+ min - val after = (0 until eachLeg).map { i -> - TrackPoint(37.0 + (warmup.size + i) * 0.005, -122.0, 5.0, cogAfter, 12.0, 0.0, true, - (warmup.size + i) * STEP_MS) - } - return warmup + after - } + // Standard two-leg track: 120 s warmup on cogBefore, 120 s on cogAfter. + // Tack apex candidates fire around t≈120 s, well past START_SKIP=60s. + private fun tackTrack(cogBefore: Double, cogAfter: Double): List<TrackPoint> = + leg(cogBefore, 120, 0L) + leg(cogAfter, 120, 120_000L) @Test fun `straight course yields no tacks`() { - val pts = warmupPoints(90.0) + (5 until 15).map { i -> - TrackPoint(37.0 + i * 0.01, -122.0, 5.0, 90.0, 12.0, 0.0, true, i * STEP_MS) - } + val pts = leg(90.0, 300, 0L) assertTrue(TackDetector.detectTacks(pts).isEmpty()) } @@ -42,51 +40,40 @@ class TackDetectorTest { } @Test fun `detects jibe on broad reach`() { - assertEquals(1, TackDetector.detectTacks(tackTrack(150.0, 250.0)).size) + // 150° → 290°: delta = 140°, within [60°, 160°] + assertEquals(1, TackDetector.detectTacks(tackTrack(150.0, 290.0)).size) } @Test fun `handles 0-360 wrap`() { assertEquals(1, TackDetector.detectTacks(tackTrack(350.0, 80.0)).size) } - @Test fun `gradual course change is not a tack`() { - val pts = (0 until 30).map { i -> - TrackPoint(37.0 + i * 0.01, -122.0, 5.0, i * 5.0, 12.0, 0.0, true, i * STEP_MS) - } - assertTrue(TackDetector.detectTacks(pts).isEmpty()) - } - - @Test fun `small heading change below threshold is ignored`() { + @Test fun `heading change below MIN_DELTA is ignored`() { + // 45° delta — below MIN_DELTA=60° assertTrue(TackDetector.detectTacks(tackTrack(90.0, 135.0)).isEmpty()) } - @Test fun `very large heading change above threshold is ignored`() { - assertTrue(TackDetector.detectTacks(tackTrack(90.0, 250.0)).isEmpty()) + @Test fun `heading change above MAX_DELTA is ignored`() { + // 170° delta — above MAX_DELTA=160° (U-turn, not a tack) + assertTrue(TackDetector.detectTacks(tackTrack(90.0, 260.0)).isEmpty()) } - @Test fun `detects two tacks in sequence`() { - // 5 pts warmup + 5 pts leg2 + 5 pts leg3; gaps of 5*30s=150s > MIN_GAP_MS=45s - val warmup = warmupPoints(330.0) - val leg2 = (5 until 10).map { i -> - TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 70.0, 12.0, 0.0, true, i * STEP_MS) - } - val leg3 = (10 until 15).map { i -> - TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 330.0, 12.0, 0.0, true, i * STEP_MS) - } - assertEquals(2, TackDetector.detectTacks(warmup + leg2 + leg3).size) + @Test fun `detects two tacks in sequence with sufficient gap`() { + // leg1: 120s, leg2: 180s (> MIN_GAP_MS=60s), leg3: 120s + val leg1 = leg(330.0, 120, 0L) + val leg2 = leg(70.0, 180, 120_000L) + val leg3 = leg(330.0, 120, 300_000L) + val tacks = TackDetector.detectTacks(leg1 + leg2 + leg3) + assertEquals(2, tacks.size) } - @Test fun `deduplicates tacks within MIN_GAP_MS`() { - // Both tack candidates at i=5 and i=6 are within 30s of each other — only 1 should register - val warmup = warmupPoints(330.0) - val after = (5 until 15).map { i -> - TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 70.0, 12.0, 0.0, true, i * STEP_MS) - } - val tacks = TackDetector.detectTacks(warmup + after) + @Test fun `deduplicates adjacent candidates into single tack`() { + // Single abrupt tack fires many consecutive raw candidates; must collapse to 1 + val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0)) assertEquals(1, tacks.size) } - @Test fun `tack event contains correct position`() { + @Test fun `tack event contains plausible position`() { val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0)) assertEquals(1, tacks.size) assertTrue(tacks[0].lat in 37.0..37.1) @@ -94,20 +81,47 @@ class TackDetectorTest { } @Test fun `too few points returns empty`() { - val pts = (0 until 3).map { i -> - TrackPoint(37.0, -122.0, 5.0, 90.0, 12.0, 0.0, true, i * STEP_MS) + val pts = leg(90.0, 2, 0L) + assertTrue(TackDetector.detectTacks(pts).isEmpty()) + } + + @Test fun `cold start suppresses tack in first 60 seconds`() { + // Tack apex at t=30s — inside START_SKIP_MS=60s → filtered + val pts = leg(330.0, 40, 0L) + leg(70.0, 60, 40_000L) + assertEquals(0, TackDetector.detectTacks(pts).size) + } + + @Test fun `noisy anchor COG does not produce false tack`() { + // Simulate boat at anchor: COG rotates 0..360 uniformly (MAD ≈ 90°, fails STAB_MAX=20°) + val pts = (0 until 300).map { i -> + TrackPoint(37.0, -122.0, 0.1, (i * 37.0) % 360.0, 12.0, 0.0, true, i * STEP_MS) } assertTrue(TackDetector.detectTacks(pts).isEmpty()) } - @Test fun `cold start filter suppresses tacks in first 2 minutes`() { - // Tack happens at t=60s from track start → filtered by START_SKIP_MS=120_000 - val start = 0L - val pts = (0 until 6).map { i -> - TrackPoint(37.0, -122.0, 5.0, 330.0, 12.0, 0.0, true, start + i * 10_000L) - } + (0 until 6).map { i -> - TrackPoint(37.0, -122.0, 5.0, 70.0, 12.0, 0.0, true, start + (6 + i) * 10_000L) + @Test fun `unstable before-window prevents false detection`() { + // Before-window has scattered headings (std dev >> STAB_MAX), after-window is stable + val noisyBefore = (0 until 120).map { i -> + TrackPoint(37.0, -122.0, 3.0, (i * 47.0) % 360.0, 12.0, 0.0, true, i * STEP_MS) } - assertEquals(0, TackDetector.detectTacks(pts).size) + val stableAfter = leg(70.0, 120, 120_000L) + assertTrue(TackDetector.detectTacks(noisyBefore + stableAfter).isEmpty()) + } + + @Test fun `gradual 5-degree-per-second course change is not a tack`() { + // Course changes 5°/s for 60 s = 300° total change but never a sudden tack + val pts = (0 until 300).map { i -> + TrackPoint(37.0 + i * 0.0001, -122.0, 5.0, (i * 5.0) % 360.0, 12.0, 0.0, true, i * STEP_MS) + } + assertTrue(TackDetector.detectTacks(pts).isEmpty()) + } + + @Test fun `two tacks closer than MIN_GAP_MS produces only one event`() { + // leg1=120s, leg2=45s (< MIN_GAP_MS=60s gap to leg3 candidates), leg3=120s + val leg1 = leg(330.0, 120, 0L) + val leg2 = leg(70.0, 45, 120_000L) + val leg3 = leg(330.0, 120, 165_000L) + val tacks = TackDetector.detectTacks(leg1 + leg2 + leg3) + assertEquals(1, tacks.size) } } |
