diff options
Diffstat (limited to 'test-runner/src')
| l---------[-rw-r--r--] | test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt | 161 |
1 files changed, 1 insertions, 160 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 4aab436..4261694 100644..120000 --- a/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt @@ -1,160 +1 @@ -package org.terst.nav.track - -import kotlin.math.abs -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 ──][─ 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, - * accept if heading delta ≥ MIN_DELTA (no upper cap — 180° crash-tacks are valid). - * - * De-duplicate: group candidates within MIN_GAP_MS, keep max-delta per group. - * Refine position: within the maneuver zone, find the point with the greatest instantaneous - * heading rate of change as the map pin. - * - * 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. - * - * 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 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, - val timestampMs: Long, - val delta: Double, - val cogBefore: Double, - val cogAfter: Double - ) - - 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 < cfg.startSkipMs) continue - - val beforeEnd = t - cfg.tManeuver / 2 - val beforeStart = beforeEnd - cfg.tSettle - val before = points.subList(lowerBound(points, beforeStart), lowerBound(points, beforeEnd)) - if (before.size < cfg.minPts) continue - - val afterStart = t + cfg.tManeuver / 2 - val afterEnd = afterStart + cfg.tSettle - val after = points.subList(upperBound(points, afterStart), upperBound(points, afterEnd)) - if (after.size < cfg.minPts) continue - - val cogBefore = circularMean(before.map { it.cogDeg }) - val spreadBefore = circularMAD(before.map { it.cogDeg }, cogBefore) - if (spreadBefore > cfg.stabMax) continue - - val cogAfter = circularMean(after.map { it.cogDeg }) - val spreadAfter = circularMAD(after.map { it.cogDeg }, cogAfter) - if (spreadAfter > cfg.stabMax) continue - - val delta = abs(angleDiff(cogBefore, cogAfter)) - if (delta < cfg.minDelta) continue - - raw += Candidate(i, t, delta, cogBefore, cogAfter) - } - if (raw.isEmpty()) return emptyList() - - // De-duplicate: if consecutive raw candidates are within MIN_GAP_MS of each other, they - // belong to the same maneuver. Keep the max-delta candidate per group. - // Compare against the PREVIOUS candidate (adjacent comparison) so a long stream of - // close candidates from one maneuver stays in a single group regardless of total span. - val results = mutableListOf<TackEvent>() - var best: Candidate? = null - var prevMs = Long.MIN_VALUE / 2 - - for (c in raw) { - 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, cfg) } - return results - } - - 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 - cfg.tManeuver / 2)..(c.timestampMs + cfg.tManeuver / 2) - var bestIdx = c.index - var bestRate = 0.0 - for (i in 1 until points.size) { - if (points[i].timestampMs !in maneuvRange) continue - val rate = abs(angleDiff(points[i - 1].cogDeg, points[i].cogDeg)) - if (rate > bestRate) { bestRate = rate; bestIdx = i } - } - 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 } - return lo - } - - private fun upperBound(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 } - return lo - } - - internal fun angleDiff(from: Double, to: Double): Double { - var diff = to - from - while (diff > 180) diff -= 360 - while (diff < -180) diff += 360 - return diff - } - - private fun circularMean(angles: List<Double>): Double { - val sinSum = angles.sumOf { sin(Math.toRadians(it)) } - val cosSum = angles.sumOf { cos(Math.toRadians(it)) } - return ((Math.toDegrees(atan2(sinSum, cosSum)) % 360.0) + 360.0) % 360.0 - } - - private fun circularMAD(angles: List<Double>, mean: Double): Double = - angles.map { abs(angleDiff(it, mean)) }.average() -} +../../../../../../../../android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt
\ No newline at end of file |
