diff options
| author | Claude <noreply@anthropic.com> | 2026-05-27 05:38:24 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-27 05:38:24 +0000 |
| commit | 4f87ae6cda6131012447a8b2ddde088bb8844b04 (patch) | |
| tree | 22f21f730783b5401caeb05799b3653de473d1ae /test-runner | |
| parent | 1f9f0ec3f7ce986134ed75836fc42e43582aa34f (diff) | |
Add RACE mode to TackDetector for rapid mark-rounding detection
Introduces TackMode enum (CRUISING/RACE) and per-mode Config, replacing
the previous single set of hardcoded constants.
RACE config: T_SETTLE=12s, T_MANEUVER=20s, STAB_MAX=30°, MIN_GAP=20s,
START_SKIP=20s — tighter windows for quick successive tacks at race
starts and mark roundings. CRUISING remains the default.
Two new tests demonstrate the behavioral difference: RACE fires earlier
(past 20s cold-start vs 60s) and keeps separate events for a 50s gap
that CRUISING deduplicates (50s < 60s MIN_GAP). 18/18 passing.
https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD
Diffstat (limited to 'test-runner')
| -rw-r--r-- | test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt | 73 | ||||
| -rw-r--r-- | test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt | 22 |
2 files changed, 68 insertions, 27 deletions
diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt index 5bf86c3..4aab436 100644 --- a/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt @@ -5,11 +5,13 @@ import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin +enum class TackMode { CRUISING, RACE } + /** * Detects tacks and jibes in a recorded GPS track. * * A tack or jibe has three phases: - * [── settled leg A (≥30 s) ──][─ guard (15 s) ─][apex][─ guard (15 s) ─][── settled leg B (≥30 s) ──] + * [── settled leg A ──][─ guard ─][apex][─ guard ─][── settled leg B ──] * * For each GPS fix as candidate apex: collect before/after settle windows (excluding the guard zone), * require MIN_PTS fixes in each, compute circularMean + circularMAD, reject if MAD > STAB_MAX, @@ -22,17 +24,31 @@ import kotlin.math.sin * No SOG gate: circularMAD stability check handles noise at any speed. * A boat at anchor has effectively random COG (MAD ≈ 90°) and is always rejected. * A real settled tack leg has MAD typically 5–15° and passes. - * Mode: CRUISING — conservative (prefers missing a tack over reporting a phantom). - * Race mode (shorter windows, wider MAD) is a future backlog item. + * + * Modes: + * CRUISING (default) — conservative; T_SETTLE=30s, T_MANEUVER=30s, STAB_MAX=20°, MIN_GAP=60s + * RACE — tight windows for rapid mark-rounding; T_SETTLE=12s, STAB_MAX=30°, MIN_GAP=20s */ object TackDetector { - private const val T_SETTLE = 30_000L // ms — stable heading window required before/after - private const val T_MANEUVER = 30_000L // ms — guard zone around apex (±15 s each side) - private const val STAB_MAX = 20.0 // ° — max circularMAD in settle windows - private const val MIN_DELTA = 75.0 // ° — minimum heading change to count as a maneuver - private const val MIN_GAP_MS = 60_000L // ms — minimum time between accepted events - private const val START_SKIP_MS = 60_000L // ms — skip first 60 s (cold-start noise) - private const val MIN_PTS = 3 // minimum GPS fixes required in each settle window + + private data class Config( + val tSettle: Long, + val tManeuver: Long, + val stabMax: Double, + val minDelta: Double, + val minGapMs: Long, + val startSkipMs: Long, + val minPts: Int + ) + + private val CRUISING_CFG = Config( + tSettle = 30_000L, tManeuver = 30_000L, stabMax = 20.0, + minDelta = 75.0, minGapMs = 60_000L, startSkipMs = 60_000L, minPts = 3 + ) + private val RACE_CFG = Config( + tSettle = 12_000L, tManeuver = 20_000L, stabMax = 30.0, + minDelta = 75.0, minGapMs = 20_000L, startSkipMs = 20_000L, minPts = 3 + ) private data class Candidate( val index: Int, @@ -42,36 +58,37 @@ object TackDetector { val cogAfter: Double ) - fun detectTacks(points: List<TrackPoint>): List<TackEvent> { - if (points.size < MIN_PTS) return emptyList() + fun detectTacks(points: List<TrackPoint>, mode: TackMode = TackMode.CRUISING): List<TackEvent> { + val cfg = if (mode == TackMode.RACE) RACE_CFG else CRUISING_CFG + if (points.size < cfg.minPts) return emptyList() val t0 = points.first().timestampMs val raw = mutableListOf<Candidate>() for (i in points.indices) { val t = points[i].timestampMs - if (t - t0 < START_SKIP_MS) continue + if (t - t0 < cfg.startSkipMs) continue - val beforeEnd = t - T_MANEUVER / 2 - val beforeStart = beforeEnd - T_SETTLE + val beforeEnd = t - cfg.tManeuver / 2 + val beforeStart = beforeEnd - cfg.tSettle val before = points.subList(lowerBound(points, beforeStart), lowerBound(points, beforeEnd)) - if (before.size < MIN_PTS) continue + if (before.size < cfg.minPts) continue - val afterStart = t + T_MANEUVER / 2 - val afterEnd = afterStart + T_SETTLE + val afterStart = t + cfg.tManeuver / 2 + val afterEnd = afterStart + cfg.tSettle val after = points.subList(upperBound(points, afterStart), upperBound(points, afterEnd)) - if (after.size < MIN_PTS) continue + if (after.size < cfg.minPts) continue val cogBefore = circularMean(before.map { it.cogDeg }) val spreadBefore = circularMAD(before.map { it.cogDeg }, cogBefore) - if (spreadBefore > STAB_MAX) continue + if (spreadBefore > cfg.stabMax) continue val cogAfter = circularMean(after.map { it.cogDeg }) val spreadAfter = circularMAD(after.map { it.cogDeg }, cogAfter) - if (spreadAfter > STAB_MAX) continue + if (spreadAfter > cfg.stabMax) continue val delta = abs(angleDiff(cogBefore, cogAfter)) - if (delta < MIN_DELTA) continue + if (delta < cfg.minDelta) continue raw += Candidate(i, t, delta, cogBefore, cogAfter) } @@ -86,21 +103,21 @@ object TackDetector { var prevMs = Long.MIN_VALUE / 2 for (c in raw) { - if (best != null && c.timestampMs - prevMs >= MIN_GAP_MS) { - results += buildTackEvent(points, best!!) + if (best != null && c.timestampMs - prevMs >= cfg.minGapMs) { + results += buildTackEvent(points, best!!, cfg) best = c } else { if (best == null || c.delta > best!!.delta) best = c } prevMs = c.timestampMs } - best?.let { results += buildTackEvent(points, it) } + best?.let { results += buildTackEvent(points, it, cfg) } return results } - private fun buildTackEvent(points: List<TrackPoint>, c: Candidate): TackEvent { + private fun buildTackEvent(points: List<TrackPoint>, c: Candidate, cfg: Config): TackEvent { // Refine map pin: find max instantaneous heading rate within maneuver zone - val maneuvRange = (c.timestampMs - T_MANEUVER / 2)..(c.timestampMs + T_MANEUVER / 2) + val maneuvRange = (c.timestampMs - cfg.tManeuver / 2)..(c.timestampMs + cfg.tManeuver / 2) var bestIdx = c.index var bestRate = 0.0 for (i in 1 until points.size) { @@ -111,6 +128,8 @@ object TackDetector { return TackEvent(bestIdx, points[bestIdx].lat, points[bestIdx].lon, c.cogBefore, c.cogAfter) } + // Binary search helpers — points must be sorted by timestampMs (always true for GPS tracks). + // lowerBound: first index where ts >= ms; upperBound: first index where ts > ms. private fun lowerBound(points: List<TrackPoint>, ms: Long): Int { var lo = 0; var hi = points.size while (lo < hi) { val mid = (lo + hi) ushr 1; if (points[mid].timestampMs < ms) lo = mid + 1 else hi = mid } 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 265ba76..e3bca9f 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 @@ -124,4 +124,26 @@ class TackDetectorTest { val tacks = TackDetector.detectTacks(leg1 + leg2 + leg3) assertEquals(1, tacks.size) } + + // ── Race mode ───────────────────────────────────────────────────────────── + + // RACE config: T_SETTLE=12s, T_MANEUVER=20s, START_SKIP=20s, MIN_GAP=20s, STAB_MAX=30° + // Min apex time in RACE at 1 Hz: START_SKIP(20) + T_SETTLE(12) + T_MANEUVER/2(10) = 42s from t0 + // So leg1=30s (cog A), leg2=30s (cog B): apex candidates fire around t=30s in leg2. + // For t=42 in leg2 (t0=0): before=[42-22..42-10]=[20,32]→in leg1 ✓; after=[52..64]→in leg2 ✓ + + @Test fun `race mode detects tack that falls inside cruising cold-start window`() { + // Tack at t=30s is inside CRUISING START_SKIP=60s but past RACE START_SKIP=20s + val pts = leg(330.0, 30, 0L) + leg(70.0, 30, 30_000L) + assertEquals(1, TackDetector.detectTacks(pts, TackMode.RACE).size) + assertEquals(0, TackDetector.detectTacks(pts, TackMode.CRUISING).size) + } + + @Test fun `race mode yields two events for gap that cruising deduplicates`() { + // Intermediate leg of 50s: gap > RACE MIN_GAP=20s → 2 events + // gap < CRUISING MIN_GAP=60s → 1 event (deduped) + val pts = leg(330.0, 60, 0L) + leg(70.0, 50, 60_000L) + leg(330.0, 60, 110_000L) + assertEquals(2, TackDetector.detectTacks(pts, TackMode.RACE).size) + assertEquals(1, TackDetector.detectTacks(pts, TackMode.CRUISING).size) + } } |
