From 62cc93086498d57ef7298eddb40cc382dfc10c6d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 22:18:38 +0000 Subject: Fix Long overflow in TackDetector: var lastTackMs = Long.MIN_VALUE caused subtraction overflow, filtering every tack --- .../kotlin/org/terst/nav/track/TackDetectorTest.kt | 98 +++++++++++++--------- 1 file changed, 58 insertions(+), 40 deletions(-) (limited to 'test-runner/src/test/kotlin/org/terst') 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 44bd667..540683d 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 @@ -5,24 +5,29 @@ import org.junit.Test class TackDetectorTest { - private fun pt(lat: Double, lon: Double, cog: Double, ts: Long) = - TrackPoint(lat, lon, 5.0, cog, 12.0, 0.0, true, ts) - - private fun straightTrack(cog: Double, count: Int = 10): List = - (0 until count).map { i -> pt(37.0 + i * 0.01, -122.0, cog, i * 10_000L) } - - private fun tackTrack(cogBefore: Double, cogAfter: Double, each: Int = 6): List { - val before = (0 until each).map { i -> pt(37.0 + i * 0.005, -122.0, cogBefore, i * 10_000L) } - val after = (0 until each).map { i -> pt(37.0 + each * 0.005 + i * 0.005, -122.0, cogAfter, (each + i) * 10_000L) } - return before + after + // Build a track that's well past the 2-minute cold-start skip. + // Use 30s spacing: 6 warmup points (3 min) + tack points. + private val STEP_MS = 30_000L // 30 s between points + + private fun warmupPoints(cog: Double, count: Int = 5): List = + (0 until count).map { i -> + TrackPoint(37.0 + i * 0.005, -122.0, 5.0, cog, 12.0, 0.0, true, i * STEP_MS) + } + + private fun tackTrack(cogBefore: Double, cogAfter: Double, eachLeg: Int = 5): List { + val warmup = warmupPoints(cogBefore) // 5 pts × 30s = 2.5 min, tack at ~2.5+ min + val after = (0 until eachLeg).map { i -> + TrackPoint(37.0 + (warmup.size + i) * 0.005, -122.0, 5.0, cogAfter, 12.0, 0.0, true, + (warmup.size + i) * STEP_MS) + } + return warmup + after } @Test fun `straight course yields no tacks`() { - assertTrue(TackDetector.detectTacks(straightTrack(90.0)).isEmpty()) - } - - @Test fun `straight course on port tack yields no tacks`() { - assertTrue(TackDetector.detectTacks(straightTrack(330.0)).isEmpty()) + val pts = warmupPoints(90.0) + (5 until 15).map { i -> + TrackPoint(37.0 + i * 0.01, -122.0, 5.0, 90.0, 12.0, 0.0, true, i * STEP_MS) + } + assertTrue(TackDetector.detectTacks(pts).isEmpty()) } @Test fun `detects port to starboard tack`() { @@ -33,63 +38,76 @@ class TackDetectorTest { } @Test fun `detects starboard to port tack`() { - val tacks = TackDetector.detectTacks(tackTrack(70.0, 330.0)) - assertEquals(1, tacks.size) + assertEquals(1, TackDetector.detectTacks(tackTrack(70.0, 330.0)).size) } @Test fun `detects jibe on broad reach`() { - // broad reach port → starboard: 150° → 210°, delta = 60° - val tacks = TackDetector.detectTacks(tackTrack(150.0, 250.0)) - assertEquals(1, tacks.size) + assertEquals(1, TackDetector.detectTacks(tackTrack(150.0, 250.0)).size) } @Test fun `handles 0-360 wrap`() { - // 350° → 80°: delta = 90° through north - val tacks = TackDetector.detectTacks(tackTrack(350.0, 80.0)) - assertEquals(1, tacks.size) + assertEquals(1, TackDetector.detectTacks(tackTrack(350.0, 80.0)).size) } @Test fun `gradual course change is not a tack`() { - // COG increases by 5° per point: window delta ≈ 15°, below threshold - val points = (0 until 20).map { i -> pt(37.0 + i * 0.01, -122.0, i * 5.0, i * 10_000L) } - assertTrue(TackDetector.detectTacks(points).isEmpty()) + val pts = (0 until 30).map { i -> + TrackPoint(37.0 + i * 0.01, -122.0, 5.0, i * 5.0, 12.0, 0.0, true, i * STEP_MS) + } + assertTrue(TackDetector.detectTacks(pts).isEmpty()) } @Test fun `small heading change below threshold is ignored`() { - // 45° change — not a tack assertTrue(TackDetector.detectTacks(tackTrack(90.0, 135.0)).isEmpty()) } @Test fun `very large heading change above threshold is ignored`() { - // 160° delta — U-turn, not a normal tack or jibe assertTrue(TackDetector.detectTacks(tackTrack(90.0, 250.0)).isEmpty()) } @Test fun `detects two tacks in sequence`() { - val leg1 = (0 until 6).map { i -> pt(37.0 + i * 0.01, -122.0, 330.0, i * 10_000L) } - val leg2 = (0 until 6).map { i -> pt(37.06 + i * 0.01, -122.0, 70.0, (6 + i) * 10_000L) } - val leg3 = (0 until 6).map { i -> pt(37.12 + i * 0.01, -122.0, 330.0, (12 + i) * 10_000L) } - val tacks = TackDetector.detectTacks(leg1 + leg2 + leg3) - assertEquals(2, tacks.size) + // 5 pts warmup + 5 pts leg2 + 5 pts leg3; gaps of 5*30s=150s > MIN_GAP_MS=45s + val warmup = warmupPoints(330.0) + val leg2 = (5 until 10).map { i -> + TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 70.0, 12.0, 0.0, true, i * STEP_MS) + } + val leg3 = (10 until 15).map { i -> + TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 330.0, 12.0, 0.0, true, i * STEP_MS) + } + assertEquals(2, TackDetector.detectTacks(warmup + leg2 + leg3).size) } - @Test fun `deduplicates tacks at same transition`() { - val before = (0 until 8).map { i -> pt(37.0, -122.0 + i * 0.0001, 330.0, i * 5_000L) } - val after = (0 until 8).map { i -> pt(37.0, -122.0 + 0.0008 + i * 0.0001, 70.0, (8 + i) * 5_000L) } - val tacks = TackDetector.detectTacks(before + after) + @Test fun `deduplicates tacks within MIN_GAP_MS`() { + // Both tack candidates at i=5 and i=6 are within 30s of each other — only 1 should register + val warmup = warmupPoints(330.0) + val after = (5 until 15).map { i -> + TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 70.0, 12.0, 0.0, true, i * STEP_MS) + } + val tacks = TackDetector.detectTacks(warmup + after) assertEquals(1, tacks.size) } @Test fun `tack event contains correct position`() { val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0)) assertEquals(1, tacks.size) - // Position should be somewhere in the track bounds assertTrue(tacks[0].lat in 37.0..37.1) assertTrue(tacks[0].lon in -122.1..-122.0) } @Test fun `too few points returns empty`() { - val points = (0 until 3).map { i -> pt(37.0, -122.0, 90.0, i * 1000L) } - assertTrue(TackDetector.detectTacks(points).isEmpty()) + val pts = (0 until 3).map { i -> + TrackPoint(37.0, -122.0, 5.0, 90.0, 12.0, 0.0, true, i * STEP_MS) + } + assertTrue(TackDetector.detectTacks(pts).isEmpty()) + } + + @Test fun `cold start filter suppresses tacks in first 2 minutes`() { + // Tack happens at t=60s from track start → filtered by START_SKIP_MS=120_000 + val start = 0L + val pts = (0 until 6).map { i -> + TrackPoint(37.0, -122.0, 5.0, 330.0, 12.0, 0.0, true, start + i * 10_000L) + } + (0 until 6).map { i -> + TrackPoint(37.0, -122.0, 5.0, 70.0, 12.0, 0.0, true, start + (6 + i) * 10_000L) + } + assertEquals(0, TackDetector.detectTacks(pts).size) } } -- cgit v1.2.3 From 8dbf3f883274fc0ddd16e5507f732629c85e3c91 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 26 May 2026 06:39:05 +0000 Subject: Redesign tack detection: time-based windows + circularMAD stability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the point-count window (HALF_WIN=2) with 30s time-based settle windows that give consistent reliability regardless of GPS update rate (1 Hz FULL vs 0.2 Hz ECONOMY). Key changes: - Before/after windows are time-based (T_SETTLE=30s) around a guard zone (T_MANEUVER=30s), not point counts. - Stability uses circularMAD (<20°) across all fixes in each window, replacing the single-pair spread check. This correctly handles 0°/360° wrap and suppresses random anchor-watch COG noise (MAD≈90°). - No SOG gate: averaging 30 fixes over 30s reduces noise by √30 regardless of boat speed. A boat at anchor has MAD≈90° and is rejected by the stability check. - MAX_DELTA raised to 160° (covers deep jibes the old 140° missed). - START_SKIP reduced to 60s (stability check handles cold-start noise). - De-duplication uses adjacent-candidate comparison so a long stream of raw candidates from one maneuver stays in a single group even if it spans >MIN_GAP_MS in total. - Position refined to max instantaneous heading rate within the maneuver zone. - Mode: CRUISING (conservative). Race mode noted as future backlog. 16/16 unit tests pass at 1 Hz (FULL mode GPS rate). https://claude.ai/code/session_01YUbuZNDAoLea4cf9UGQ9qn --- .../kotlin/org/terst/nav/track/TackDetector.kt | 128 +++++++++++++++++---- .../kotlin/org/terst/nav/track/TackDetector.kt | 128 +++++++++++++++++---- .../kotlin/org/terst/nav/track/TackDetectorTest.kt | 126 +++++++++++--------- 3 files changed, 278 insertions(+), 104 deletions(-) (limited to 'test-runner/src/test/kotlin/org/terst') diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt index b0d256b..763e693 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt @@ -1,50 +1,130 @@ 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 in [MIN_DELTA, MAX_DELTA]. + * + * 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 HALF_WIN = 2 - private const val MIN_DELTA = 60.0 - private const val MAX_DELTA = 140.0 - private const val STABILITY_MAX = 30.0 - private const val MIN_GAP_MS = 45_000L // 45 s minimum between tacks - private const val START_SKIP_MS = 120_000L // skip first 2 min (GPS cold-start noise) + 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_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 < 2 * HALF_WIN + 1) return emptyList() + if (points.size < MIN_PTS) return emptyList() + val t0 = points.first().timestampMs - val results = mutableListOf() - var lastTackMs: Long? = null + 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.filter { it.timestampMs in beforeStart until beforeEnd } + if (before.size < MIN_PTS) continue - for (i in HALF_WIN until points.size - HALF_WIN) { - if (points[i].timestampMs - points.first().timestampMs < START_SKIP_MS) continue - if (lastTackMs != null && points[i].timestampMs - lastTackMs < MIN_GAP_MS) continue + val afterStart = t + T_MANEUVER / 2 + val afterEnd = afterStart + T_SETTLE + val after = points.filter { it.timestampMs > afterStart && it.timestampMs <= afterEnd } + if (after.size < MIN_PTS) continue - val spreadBefore = abs(angleDiff(points[i - 2].cogDeg, points[i - 1].cogDeg)) - val spreadAfter = abs(angleDiff(points[i + 1].cogDeg, points[i + 2].cogDeg)) - if (spreadBefore > STABILITY_MAX || spreadAfter > STABILITY_MAX) 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 cogBefore = circularMean(points[i - 2].cogDeg, points[i - 1].cogDeg) - val cogAfter = circularMean(points[i + 1].cogDeg, points[i + 2].cogDeg) val delta = abs(angleDiff(cogBefore, cogAfter)) + if (delta < MIN_DELTA || delta > MAX_DELTA) continue + + raw += Candidate(i, t, delta, cogBefore, cogAfter) + } + if (raw.isEmpty()) return emptyList() - if (delta in MIN_DELTA..MAX_DELTA) { - results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter) - lastTackMs = points[i].timestampMs + // 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) + } + internal fun angleDiff(from: Double, to: Double): Double { var diff = to - from - while (diff > 180) diff -= 360 + while (diff > 180) diff -= 360 while (diff < -180) diff += 360 return diff } - private fun circularMean(a: Double, b: Double): Double { - val half = angleDiff(a, b) / 2.0 - return ((a + half) % 360.0 + 360.0) % 360.0 + 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 index b0d256b..763e693 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 @@ -1,50 +1,130 @@ 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 in [MIN_DELTA, MAX_DELTA]. + * + * 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 HALF_WIN = 2 - private const val MIN_DELTA = 60.0 - private const val MAX_DELTA = 140.0 - private const val STABILITY_MAX = 30.0 - private const val MIN_GAP_MS = 45_000L // 45 s minimum between tacks - private const val START_SKIP_MS = 120_000L // skip first 2 min (GPS cold-start noise) + 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_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 < 2 * HALF_WIN + 1) return emptyList() + if (points.size < MIN_PTS) return emptyList() + val t0 = points.first().timestampMs - val results = mutableListOf() - var lastTackMs: Long? = null + 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.filter { it.timestampMs in beforeStart until beforeEnd } + if (before.size < MIN_PTS) continue - for (i in HALF_WIN until points.size - HALF_WIN) { - if (points[i].timestampMs - points.first().timestampMs < START_SKIP_MS) continue - if (lastTackMs != null && points[i].timestampMs - lastTackMs < MIN_GAP_MS) continue + val afterStart = t + T_MANEUVER / 2 + val afterEnd = afterStart + T_SETTLE + val after = points.filter { it.timestampMs > afterStart && it.timestampMs <= afterEnd } + if (after.size < MIN_PTS) continue - val spreadBefore = abs(angleDiff(points[i - 2].cogDeg, points[i - 1].cogDeg)) - val spreadAfter = abs(angleDiff(points[i + 1].cogDeg, points[i + 2].cogDeg)) - if (spreadBefore > STABILITY_MAX || spreadAfter > STABILITY_MAX) 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 cogBefore = circularMean(points[i - 2].cogDeg, points[i - 1].cogDeg) - val cogAfter = circularMean(points[i + 1].cogDeg, points[i + 2].cogDeg) val delta = abs(angleDiff(cogBefore, cogAfter)) + if (delta < MIN_DELTA || delta > MAX_DELTA) continue + + raw += Candidate(i, t, delta, cogBefore, cogAfter) + } + if (raw.isEmpty()) return emptyList() - if (delta in MIN_DELTA..MAX_DELTA) { - results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter) - lastTackMs = points[i].timestampMs + // 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) + } + internal fun angleDiff(from: Double, to: Double): Double { var diff = to - from - while (diff > 180) diff -= 360 + while (diff > 180) diff -= 360 while (diff < -180) diff += 360 return diff } - private fun circularMean(a: Double, b: Double): Double { - val half = angleDiff(a, b) / 2.0 - return ((a + half) % 360.0 + 360.0) % 360.0 + 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/test/kotlin/org/terst/nav/track/TackDetectorTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt index 540683d..6d946a0 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 @@ -5,28 +5,26 @@ import org.junit.Test class TackDetectorTest { - // Build a track that's well past the 2-minute cold-start skip. - // Use 30s spacing: 6 warmup points (3 min) + tack points. - private val STEP_MS = 30_000L // 30 s between points - - private fun warmupPoints(cog: Double, count: Int = 5): List = + // 1 Hz GPS (FULL mode). Time-based windows: + // T_SETTLE=30s, T_MANEUVER=30s, START_SKIP=60s. + // Minimum for a candidate to fire: START_SKIP(60) + T_SETTLE(30) + T_MANEUVER/2(15) = 105 s from t0. + // To also have a 30s after-window: apex at ~105s, after-window ends at 105+15+30=150s. + // So a safe warmup leg is 120 pts (t=0..119s), tack apex around t=120s, after-leg ≥ 60 pts. + private val STEP_MS = 1_000L // 1 s between points + + private fun leg(cog: Double, count: Int, startMs: Long, sogKnots: Double = 5.0): List = (0 until count).map { i -> - TrackPoint(37.0 + i * 0.005, -122.0, 5.0, cog, 12.0, 0.0, true, i * STEP_MS) + TrackPoint(37.0 + (startMs / 1000 + i) * 0.00001, -122.0, sogKnots, cog, 12.0, 0.0, true, + startMs + i * STEP_MS) } - private fun tackTrack(cogBefore: Double, cogAfter: Double, eachLeg: Int = 5): List { - val warmup = warmupPoints(cogBefore) // 5 pts × 30s = 2.5 min, tack at ~2.5+ min - val after = (0 until eachLeg).map { i -> - TrackPoint(37.0 + (warmup.size + i) * 0.005, -122.0, 5.0, cogAfter, 12.0, 0.0, true, - (warmup.size + i) * STEP_MS) - } - return warmup + after - } + // Standard two-leg track: 120 s warmup on cogBefore, 120 s on cogAfter. + // Tack apex candidates fire around t≈120 s, well past START_SKIP=60s. + private fun tackTrack(cogBefore: Double, cogAfter: Double): List = + leg(cogBefore, 120, 0L) + leg(cogAfter, 120, 120_000L) @Test fun `straight course yields no tacks`() { - val pts = warmupPoints(90.0) + (5 until 15).map { i -> - TrackPoint(37.0 + i * 0.01, -122.0, 5.0, 90.0, 12.0, 0.0, true, i * STEP_MS) - } + val pts = leg(90.0, 300, 0L) assertTrue(TackDetector.detectTacks(pts).isEmpty()) } @@ -42,51 +40,40 @@ class TackDetectorTest { } @Test fun `detects jibe on broad reach`() { - assertEquals(1, TackDetector.detectTacks(tackTrack(150.0, 250.0)).size) + // 150° → 290°: delta = 140°, within [60°, 160°] + assertEquals(1, TackDetector.detectTacks(tackTrack(150.0, 290.0)).size) } @Test fun `handles 0-360 wrap`() { assertEquals(1, TackDetector.detectTacks(tackTrack(350.0, 80.0)).size) } - @Test fun `gradual course change is not a tack`() { - val pts = (0 until 30).map { i -> - TrackPoint(37.0 + i * 0.01, -122.0, 5.0, i * 5.0, 12.0, 0.0, true, i * STEP_MS) - } - assertTrue(TackDetector.detectTacks(pts).isEmpty()) - } - - @Test fun `small heading change below threshold is ignored`() { + @Test fun `heading change below MIN_DELTA is ignored`() { + // 45° delta — below MIN_DELTA=60° assertTrue(TackDetector.detectTacks(tackTrack(90.0, 135.0)).isEmpty()) } - @Test fun `very large heading change above threshold is ignored`() { - assertTrue(TackDetector.detectTacks(tackTrack(90.0, 250.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 `detects two tacks in sequence`() { - // 5 pts warmup + 5 pts leg2 + 5 pts leg3; gaps of 5*30s=150s > MIN_GAP_MS=45s - val warmup = warmupPoints(330.0) - val leg2 = (5 until 10).map { i -> - TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 70.0, 12.0, 0.0, true, i * STEP_MS) - } - val leg3 = (10 until 15).map { i -> - TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 330.0, 12.0, 0.0, true, i * STEP_MS) - } - assertEquals(2, TackDetector.detectTacks(warmup + leg2 + leg3).size) + @Test fun `detects two tacks in sequence with sufficient gap`() { + // leg1: 120s, leg2: 180s (> MIN_GAP_MS=60s), leg3: 120s + val leg1 = leg(330.0, 120, 0L) + val leg2 = leg(70.0, 180, 120_000L) + val leg3 = leg(330.0, 120, 300_000L) + val tacks = TackDetector.detectTacks(leg1 + leg2 + leg3) + assertEquals(2, tacks.size) } - @Test fun `deduplicates tacks within MIN_GAP_MS`() { - // Both tack candidates at i=5 and i=6 are within 30s of each other — only 1 should register - val warmup = warmupPoints(330.0) - val after = (5 until 15).map { i -> - TrackPoint(37.0 + i * 0.005, -122.0, 5.0, 70.0, 12.0, 0.0, true, i * STEP_MS) - } - val tacks = TackDetector.detectTacks(warmup + after) + @Test fun `deduplicates adjacent candidates into single tack`() { + // Single abrupt tack fires many consecutive raw candidates; must collapse to 1 + val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0)) assertEquals(1, tacks.size) } - @Test fun `tack event contains correct position`() { + @Test fun `tack event contains plausible position`() { val tacks = TackDetector.detectTacks(tackTrack(330.0, 70.0)) assertEquals(1, tacks.size) assertTrue(tacks[0].lat in 37.0..37.1) @@ -94,20 +81,47 @@ class TackDetectorTest { } @Test fun `too few points returns empty`() { - val pts = (0 until 3).map { i -> - TrackPoint(37.0, -122.0, 5.0, 90.0, 12.0, 0.0, true, i * STEP_MS) + val pts = leg(90.0, 2, 0L) + assertTrue(TackDetector.detectTacks(pts).isEmpty()) + } + + @Test fun `cold start suppresses tack in first 60 seconds`() { + // Tack apex at t=30s — inside START_SKIP_MS=60s → filtered + val pts = leg(330.0, 40, 0L) + leg(70.0, 60, 40_000L) + assertEquals(0, TackDetector.detectTacks(pts).size) + } + + @Test fun `noisy anchor COG does not produce false tack`() { + // Simulate boat at anchor: COG rotates 0..360 uniformly (MAD ≈ 90°, fails STAB_MAX=20°) + val pts = (0 until 300).map { i -> + TrackPoint(37.0, -122.0, 0.1, (i * 37.0) % 360.0, 12.0, 0.0, true, i * STEP_MS) } assertTrue(TackDetector.detectTacks(pts).isEmpty()) } - @Test fun `cold start filter suppresses tacks in first 2 minutes`() { - // Tack happens at t=60s from track start → filtered by START_SKIP_MS=120_000 - val start = 0L - val pts = (0 until 6).map { i -> - TrackPoint(37.0, -122.0, 5.0, 330.0, 12.0, 0.0, true, start + i * 10_000L) - } + (0 until 6).map { i -> - TrackPoint(37.0, -122.0, 5.0, 70.0, 12.0, 0.0, true, start + (6 + i) * 10_000L) + @Test fun `unstable before-window prevents false detection`() { + // Before-window has scattered headings (std dev >> STAB_MAX), after-window is stable + val noisyBefore = (0 until 120).map { i -> + TrackPoint(37.0, -122.0, 3.0, (i * 47.0) % 360.0, 12.0, 0.0, true, i * STEP_MS) } - assertEquals(0, TackDetector.detectTacks(pts).size) + val stableAfter = leg(70.0, 120, 120_000L) + assertTrue(TackDetector.detectTacks(noisyBefore + stableAfter).isEmpty()) + } + + @Test fun `gradual 5-degree-per-second course change is not a tack`() { + // Course changes 5°/s for 60 s = 300° total change but never a sudden tack + val pts = (0 until 300).map { i -> + TrackPoint(37.0 + i * 0.0001, -122.0, 5.0, (i * 5.0) % 360.0, 12.0, 0.0, true, i * STEP_MS) + } + assertTrue(TackDetector.detectTacks(pts).isEmpty()) + } + + @Test fun `two tacks closer than MIN_GAP_MS produces only one event`() { + // leg1=120s, leg2=45s (< MIN_GAP_MS=60s gap to leg3 candidates), leg3=120s + val leg1 = leg(330.0, 120, 0L) + val leg2 = leg(70.0, 45, 120_000L) + val leg3 = leg(330.0, 120, 165_000L) + val tacks = TackDetector.detectTacks(leg1 + leg2 + leg3) + assertEquals(1, tacks.size) } } -- cgit v1.2.3 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 --- .../org/terst/nav/track/SavedTracksFragment.kt | 14 ++++++------ .../kotlin/org/terst/nav/track/TackDetector.kt | 25 ++++++++++++++++------ .../kotlin/org/terst/nav/track/TrackDetailSheet.kt | 8 ++++++- .../kotlin/org/terst/nav/track/TrackRepository.kt | 3 ++- .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 6 ++++++ .../kotlin/org/terst/nav/track/TackDetector.kt | 23 ++++++++++++++------ .../kotlin/org/terst/nav/track/TackDetectorTest.kt | 8 +++---- 7 files changed, 63 insertions(+), 24 deletions(-) (limited to 'test-runner/src/test/kotlin/org/terst') diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt index 67f32f6..fca83cd 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/SavedTracksFragment.kt @@ -13,6 +13,7 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.terst.nav.MainActivity import org.terst.nav.R @@ -52,12 +53,13 @@ class SavedTracksFragment : Fragment() { rv.adapter = adapter viewLifecycleOwner.lifecycleScope.launch { - viewModel.savedTracks.collect { tracks -> - adapter.submitList(tracks) - val empty = tracks.isEmpty() - layoutEmpty.visibility = if (empty) View.VISIBLE else View.GONE - rv.visibility = if (empty) View.GONE else View.VISIBLE - } + viewModel.savedTracks + .combine(viewModel.isLoadingTracks) { tracks, loading -> tracks to loading } + .collect { (tracks, loading) -> + adapter.submitList(tracks) + layoutEmpty.visibility = if (!loading && tracks.isEmpty()) View.VISIBLE else View.GONE + rv.visibility = if (!loading && tracks.isNotEmpty()) View.VISIBLE else View.GONE + } } // SAF storage setup prompt — only relevant on API 29+ diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt index 763e693..429449c 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TackDetector.kt +++ b/android-app/app/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,20 @@ object TackDetector { 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, 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 diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt index 1d92e18..7c26a01 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackDetailSheet.kt @@ -320,8 +320,14 @@ class TrackDetailSheet : Fragment() { for (t in tacks) { val p = points[t.index] val delta = TackDetector.angleDiff(t.cogBefore, t.cogAfter) - val type = if (abs(delta) > 70) "Tack" else "Jibe" val dir = if (delta > 0) "→ stbd" else "→ port" + // Classify as tack/jibe using true wind angle if available; otherwise label as maneuver. + val twa = if (p.isTrueWind) p.windAngleDeg else null + val type = when { + twa == null -> "Maneuver" + twa < 90.0 || twa > 270.0 -> "Tack" + else -> "Jibe" + } events += LogEvent(p.lat, p.lon, p.timestampMs, "⬡", "$type $dir", "%.0f° → %.0f° (Δ%.0f°)".format(t.cogBefore, t.cogAfter, abs(delta))) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt index 0bf1dff..c40c9c2 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt @@ -145,7 +145,7 @@ class TrackRepository(context: Context) { * Returns all completed tracks as [SavedTrack] wrappers (summary + tacks pre-computed). * Internally delegates to [getPastTracks]; the first call loads from disk. */ - suspend fun getSavedTracks(): List = + suspend fun getSavedTracks(): List = withContext(Dispatchers.IO) { getPastTracks() .filter { it.isNotEmpty() } .mapNotNull { points -> @@ -153,6 +153,7 @@ class TrackRepository(context: Context) { .onFailure { e -> NavLogger.e("track", "toSavedTrack failed: ${e.javaClass.simpleName}: ${e.message}") } .getOrNull() } + } fun safState(): SafState = storage.safState() diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt index 49de36f..f664aa9 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt @@ -101,6 +101,9 @@ class MainViewModel( val vesselRepository get() = NavApplication.vesselRepository + private val _isLoadingTracks = MutableStateFlow(true) + val isLoadingTracks: StateFlow = _isLoadingTracks.asStateFlow() + init { viewModelScope.launch { runCatching { trackRepository.getPastTracks() } @@ -111,6 +114,7 @@ class MainViewModel( .onFailure { e -> NavLogger.e("track", "init: getSavedTracks failed: ${e.javaClass.simpleName}: ${e.message}") } } .onFailure { e -> NavLogger.e("track", "init: failed to load past tracks: ${e.javaClass.simpleName}: ${e.message}") } + _isLoadingTracks.value = false } } @@ -449,6 +453,7 @@ class MainViewModel( */ fun reloadPastTracks() { viewModelScope.launch { + _isLoadingTracks.value = true runCatching { trackRepository.reloadTracks() } .onSuccess { tracks -> _pastTracks.value = tracks @@ -460,6 +465,7 @@ class MainViewModel( .onFailure { e -> NavLogger.e("track", "reloadPastTracks: failed: ${e.javaClass.simpleName}: ${e.message}") } + _isLoadingTracks.value = false } } 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 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`() { -- cgit v1.2.3