diff options
| author | Claude <noreply@anthropic.com> | 2026-05-26 07:46:09 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-26 07:46:09 +0000 |
| commit | 3673ed0ef79d776c6da3bfa0db9de8b08326fd32 (patch) | |
| tree | 369ec57c831fb3b3de7220f4ba06fb3cb6d32440 /test-runner | |
| parent | e64d07cb49dd3313eb8595935777f31fdfb2f373 (diff) | |
Fix ANR, false tack detections, and empty-state flash on track load
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
Diffstat (limited to 'test-runner')
| -rw-r--r-- | test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt | 23 | ||||
| -rw-r--r-- | test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt | 8 |
2 files changed, 21 insertions, 10 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 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<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 diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt index 6d946a0..265ba76 100644 --- a/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt +++ b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt @@ -49,13 +49,13 @@ class TackDetectorTest { } @Test fun `heading change below MIN_DELTA is ignored`() { - // 45° delta — below MIN_DELTA=60° + // 45° delta — below MIN_DELTA=75° assertTrue(TackDetector.detectTacks(tackTrack(90.0, 135.0)).isEmpty()) } - @Test fun `heading change above MAX_DELTA is ignored`() { - // 170° delta — above MAX_DELTA=160° (U-turn, not a tack) - assertTrue(TackDetector.detectTacks(tackTrack(90.0, 260.0)).isEmpty()) + @Test fun `heading change of 170 degrees is detected`() { + // 170° delta — valid crash-tack; no upper cap + assertEquals(1, TackDetector.detectTacks(tackTrack(90.0, 260.0)).size) } @Test fun `detects two tacks in sequence with sufficient gap`() { |
