diff options
Diffstat (limited to 'test-runner/src')
| -rw-r--r-- | test-runner/src/main/kotlin/org/terst/nav/track/TackDetector.kt | 20 | ||||
| -rw-r--r-- | test-runner/src/test/kotlin/org/terst/nav/track/TackDetectorTest.kt | 98 |
2 files changed, 68 insertions, 50 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 b392f67..b0d256b 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 @@ -3,23 +3,23 @@ package org.terst.nav.track import kotlin.math.abs object TackDetector { - private const val HALF_WIN = 2 // look 2 points before and after - private const val MIN_DELTA = 60.0 - private const val MAX_DELTA = 140.0 - private const val SKIP_AFTER = 5 // suppress re-detection for this many indices - private const val STABILITY_MAX = 30.0 // max internal spread of a before/after pair + 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) fun detectTacks(points: List<TrackPoint>): List<TackEvent> { if (points.size < 2 * HALF_WIN + 1) return emptyList() val results = mutableListOf<TackEvent>() - var nextCandidate = 0 + var lastTackMs: Long? = null for (i in HALF_WIN until points.size - HALF_WIN) { - if (i < nextCandidate) continue + if (points[i].timestampMs - points.first().timestampMs < START_SKIP_MS) continue + if (lastTackMs != null && points[i].timestampMs - lastTackMs < MIN_GAP_MS) continue - // Require both "before" and "after" pairs to be internally stable; - // if one spans the transition itself, that window is noise. 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 @@ -30,7 +30,7 @@ object TackDetector { if (delta in MIN_DELTA..MAX_DELTA) { results += TackEvent(i, points[i].lat, points[i].lon, cogBefore, cogAfter) - nextCandidate = i + SKIP_AFTER + lastTackMs = points[i].timestampMs } } return results 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<TrackPoint> = - (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<TrackPoint> { - 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<TrackPoint> = + (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<TrackPoint> { + 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) } } |
