summaryrefslogtreecommitdiff
path: root/test-runner
diff options
context:
space:
mode:
Diffstat (limited to 'test-runner')
l---------[-rw-r--r--]test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt161
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