From 3673ed0ef79d776c6da3bfa0db9de8b08326fd32 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 07:46:09 +0000 Subject: Fix ANR, false tack detections, and empty-state flash on track load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ANR: getSavedTracks() had no IO dispatcher — TackDetector ran on main thread at O(n²) cost (~77M ops for a 2.5h track). Added withContext(IO). TackDetector: replaced O(n) list.filter with O(log n) binary-search subList for before/after windows; O(n²) → O(n log n). Raised MIN_DELTA 60°→75° to stop detecting ordinary course corrections as maneuvers. Removed MAX_DELTA cap entirely — 180° crash-tacks are valid; the circularMAD stability windows are the real quality gate. TrackDetailSheet: dropped the arbitrary abs(delta)>70 Tack/Jibe heuristic. Now uses true wind angle when available for classification; falls back to "Maneuver" when TWD is absent. SavedTracksFragment: added isLoadingTracks StateFlow (MainViewModel); combine() suppresses the empty-state layout while the initial IO load runs so it no longer flashes "No saved tracks yet" before data arrives. 16/16 TackDetector tests pass. https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD --- .../kotlin/org/terst/nav/track/TackDetector.kt | 23 ++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) (limited to 'test-runner/src/main/kotlin/org') 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 763e693..5bf86c3 100644 --- a/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt +++ b/test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt @@ -13,7 +13,7 @@ import kotlin.math.sin * * 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 in [MIN_DELTA, MAX_DELTA]. + * 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 @@ -29,8 +29,7 @@ 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 = 60.0 // ° — minimum heading change to count as tack/jibe - private const val MAX_DELTA = 160.0 // ° — maximum heading change (beyond = U-turn, not a tack) + 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 @@ -55,12 +54,12 @@ object TackDetector { val beforeEnd = t - T_MANEUVER / 2 val beforeStart = beforeEnd - T_SETTLE - val before = points.filter { it.timestampMs in beforeStart until beforeEnd } + 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.filter { it.timestampMs > afterStart && it.timestampMs <= afterEnd } + val after = points.subList(upperBound(points, afterStart), upperBound(points, afterEnd)) if (after.size < MIN_PTS) continue val cogBefore = circularMean(before.map { it.cogDeg }) @@ -72,7 +71,7 @@ object TackDetector { if (spreadAfter > STAB_MAX) continue val delta = abs(angleDiff(cogBefore, cogAfter)) - if (delta < MIN_DELTA || delta > MAX_DELTA) continue + if (delta < MIN_DELTA) continue raw += Candidate(i, t, delta, cogBefore, cogAfter) } @@ -112,6 +111,18 @@ object TackDetector { 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 -- cgit v1.2.3