summaryrefslogtreecommitdiff
path: root/test-runner/src/main/kotlin
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-05-28 07:09:43 +0000
committerClaude <noreply@anthropic.com>2026-05-28 07:09:43 +0000
commitc498b33300d260bbe64bec49de9ef78d9a322a6d (patch)
tree2ee8b3fc08d759b105de0a8c2b36d4301c3f393a /test-runner/src/main/kotlin
parent3673ed0ef79d776c6da3bfa0db9de8b08326fd32 (diff)
Tech debt: delete com.example.androidapp ghost package, symlink TackDetector, fix forecast current hour
- Deleted all 18 stale source files in com.example.androidapp (main + test) that duplicated org.terst.nav implementations from an earlier namespace - Fixed the 3 tide model files (TidePrediction/Station/Constituent) whose package declarations read com.example.androidapp.data.model despite living in org.terst.nav.data.model directories - Fixed HarmonicTideCalculator.kt imports to match the corrected package - Fixed TideModelTest.kt package declaration - Migrated HarmonicTideCalculatorTest to org.terst.nav.tide with correct imports - Replaced test-runner's TackDetector.kt copy with a symlink to the android-app canonical source so the two can never diverge again - Fixed addGpsPoint to pick the current wall-clock hour's forecast slot instead of always using the app-start hour (first item in forecast list) https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD
Diffstat (limited to 'test-runner/src/main/kotlin')
l---------[-rw-r--r--]test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt142
1 files changed, 1 insertions, 141 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..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,141 +1 @@
-package org.terst.nav.track
-
-import kotlin.math.abs
-import kotlin.math.atan2
-import kotlin.math.cos
-import kotlin.math.sin
-
-/**
- * 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) ──]
- *
- * 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.
- * Mode: CRUISING — conservative (prefers missing a tack over reporting a phantom).
- * Race mode (shorter windows, wider MAD) is a future backlog item.
- */
-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 Candidate(
- val index: Int,
- val timestampMs: Long,
- val delta: Double,
- val cogBefore: Double,
- val cogAfter: Double
- )
-
- fun detectTacks(points: List<TrackPoint>): List<TackEvent> {
- if (points.size < MIN_PTS) 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
-
- val beforeEnd = t - T_MANEUVER / 2
- val beforeStart = beforeEnd - T_SETTLE
- val before = points.subList(lowerBound(points, beforeStart), lowerBound(points, beforeEnd))
- if (before.size < MIN_PTS) continue
-
- val afterStart = t + T_MANEUVER / 2
- val afterEnd = afterStart + T_SETTLE
- val after = points.subList(upperBound(points, afterStart), upperBound(points, afterEnd))
- if (after.size < MIN_PTS) continue
-
- val cogBefore = circularMean(before.map { it.cogDeg })
- val spreadBefore = circularMAD(before.map { it.cogDeg }, cogBefore)
- if (spreadBefore > STAB_MAX) continue
-
- val cogAfter = circularMean(after.map { it.cogDeg })
- val spreadAfter = circularMAD(after.map { it.cogDeg }, cogAfter)
- if (spreadAfter > STAB_MAX) continue
-
- val delta = abs(angleDiff(cogBefore, cogAfter))
- if (delta < MIN_DELTA) 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 >= MIN_GAP_MS) {
- results += buildTackEvent(points, best!!)
- best = c
- } else {
- if (best == null || c.delta > best!!.delta) best = c
- }
- prevMs = c.timestampMs
- }
- best?.let { results += buildTackEvent(points, it) }
- return results
- }
-
- private fun buildTackEvent(points: List<TrackPoint>, c: Candidate): TackEvent {
- // Refine map pin: find max instantaneous heading rate within maneuver zone
- val maneuvRange = (c.timestampMs - T_MANEUVER / 2)..(c.timestampMs + T_MANEUVER / 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)
- }
-
- 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