From c498b33300d260bbe64bec49de9ef78d9a322a6d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 28 May 2026 07:09:43 +0000 Subject: 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 --- .../kotlin/org/terst/nav/track/TackDetector.kt | 142 +-------------------- 1 file changed, 1 insertion(+), 141 deletions(-) mode change 100644 => 120000 test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt (limited to 'test-runner') 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 deleted file mode 100644 index 5bf86c3..0000000 --- a/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt +++ /dev/null @@ -1,141 +0,0 @@ -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): List { - if (points.size < MIN_PTS) return emptyList() - val t0 = points.first().timestampMs - - val raw = mutableListOf() - - 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() - 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, 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, 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, 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 { - 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, mean: Double): Double = - angles.map { abs(angleDiff(it, mean)) }.average() -} 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 new file mode 120000 index 0000000..4261694 --- /dev/null +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt @@ -0,0 +1 @@ +../../../../../../../../android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt \ No newline at end of file -- cgit v1.2.3