summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-27 05:38:24 +0000
committerClaude <noreply@anthropic.com>2026-05-27 05:38:24 +0000
commit4f87ae6cda6131012447a8b2ddde088bb8844b04 (patch)
tree22f21f730783b5401caeb05799b3653de473d1ae /android-app/app/src/main/kotlin
parent1f9f0ec3f7ce986134ed75836fc42e43582aa34f (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 'android-app/app/src/main/kotlin')
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt71
1 files changed, 44 insertions, 27 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
index 429449c..4aab436 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
+++ b/android-app/app/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) {