summaryrefslogtreecommitdiff
path: root/test-runner/src
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-04-22 08:45:55 +0000
committerClaude <noreply@anthropic.com>2026-04-22 08:45:55 +0000
commit87f9cb753da3325d13fdbc442d526567e056d6e1 (patch)
treefcad4c91a817b3ce623abbaf5b12dab875d08aa3 /test-runner/src
parent447c274999b6b412985b640f841ab92d8c23c0f8 (diff)
feat: CPA/TCPA collision alerts and live track stats
CPA/TCPA alerts: - CpaAlert data class with severity (CAUTION/WARNING/DANGER) and label() - CpaThresholds: 0.5/1.0/2.0 nm, max TCPA 30 min - MainViewModel.updateCpaAlerts() runs on every GPS update and AIS sentence; skips stationary contacts; filters TCPA ≤ 0 or > 30 min; sorts by TCPA ascending - SafetyFragment: new COLLISION AVOIDANCE card shows live alert list, color-coded by severity; clears to "No traffic alerts" when safe - MainActivity: observes cpaAlerts → safetyFragment.updateCpaAlerts() Track stats: - TrackStats data class: distanceNm, durationMs, avgSogKnots, durationFormatted (m:ss / h:mm:ss) - TrackRepository.haversineNm() + computeStats() using point timestamps - MainViewModel exposes trackStats StateFlow, cleared on stopTrack() - activity_main: dark pill overlay bar (tv_track_stats) above map - MainActivity: shows "● REC 0.00nm m:ss x.xkt avg" while recording Tests: 95 total, all GREEN (+16 new — 9 TrackStats, 7 CpaAlert) https://claude.ai/code/session_011h2dXbgXg3PesQMmQUNTCW
Diffstat (limited to 'test-runner/src')
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt23
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt1
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt30
-rw-r--r--test-runner/src/main/kotlin/org/terst/nav/track/TrackStats.kt15
-rw-r--r--test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt78
-rw-r--r--test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt79
6 files changed, 225 insertions, 1 deletions
diff --git a/test-runner/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt b/test-runner/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt
new file mode 100644
index 0000000..1d635c0
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt
@@ -0,0 +1,23 @@
+package org.terst.nav.ais
+
+enum class CpaSeverity { CAUTION, WARNING, DANGER }
+
+data class CpaAlert(
+ val mmsi: Int,
+ val vesselName: String,
+ val cpaNm: Double,
+ val tcpaMin: Double,
+ val severity: CpaSeverity
+)
+
+object CpaThresholds {
+ const val DANGER_CPA_NM = 0.5
+ const val WARNING_CPA_NM = 1.0
+ const val CAUTION_CPA_NM = 2.0
+ const val MAX_TCPA_MIN = 30.0 // ignore contacts closing in > 30 min
+}
+
+fun CpaAlert.label(): String {
+ val name = if (vesselName.isNotBlank()) vesselName else "MMSI $mmsi"
+ return "%-12s CPA %.1fnm T+%.0fmin".format(name.take(12), cpaNm, tcpaMin)
+}
diff --git a/test-runner/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt b/test-runner/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt
index 7e98451..298af59 100644
--- a/test-runner/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt
+++ b/test-runner/src/main/kotlin/org/terst/nav/ais/CpaCalculator.kt
@@ -2,7 +2,6 @@ package org.terst.nav.ais
import kotlin.math.cos
import kotlin.math.sqrt
-import kotlin.math.PI
object CpaCalculator {
fun compute(
diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
index 7953822..a68e315 100644
--- a/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
+++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -1,5 +1,10 @@
package org.terst.nav.track
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
class TrackRepository {
var isRecording: Boolean = false
@@ -23,4 +28,29 @@ class TrackRepository {
}
fun getPoints(): List<TrackPoint> = points.toList()
+
+ fun computeStats(): TrackStats? {
+ if (points.size < 2) return null
+ var distNm = 0.0
+ for (i in 1 until points.size) {
+ distNm += haversineNm(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon)
+ }
+ val durationMs = points.last().timestampMs - points.first().timestampMs
+ val avgSog = if (durationMs > 0) {
+ points.map { it.sogKnots }.average()
+ } else 0.0
+ return TrackStats(distNm, durationMs, avgSog)
+ }
+
+ companion object {
+ fun haversineNm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val r = 3440.065 // Earth radius in nautical miles
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2) * sin(dLat / 2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) *
+ sin(dLon / 2) * sin(dLon / 2)
+ return r * 2 * atan2(sqrt(a), sqrt(1 - a))
+ }
+ }
}
diff --git a/test-runner/src/main/kotlin/org/terst/nav/track/TrackStats.kt b/test-runner/src/main/kotlin/org/terst/nav/track/TrackStats.kt
new file mode 100644
index 0000000..39153c6
--- /dev/null
+++ b/test-runner/src/main/kotlin/org/terst/nav/track/TrackStats.kt
@@ -0,0 +1,15 @@
+package org.terst.nav.track
+
+data class TrackStats(
+ val distanceNm: Double,
+ val durationMs: Long,
+ val avgSogKnots: Double
+) {
+ val durationFormatted: String get() {
+ val totalSec = durationMs / 1000
+ val h = totalSec / 3600
+ val m = (totalSec % 3600) / 60
+ val s = totalSec % 60
+ return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
+ }
+}
diff --git a/test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt b/test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt
new file mode 100644
index 0000000..3effb24
--- /dev/null
+++ b/test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt
@@ -0,0 +1,78 @@
+package org.terst.nav.ais
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class CpaAlertTest {
+
+ // --- CpaCalculator ---
+
+ @Test
+ fun `head-on convergence produces small CPA and positive TCPA`() {
+ // Own vessel heading north at 10 kt; target heading south at 10 kt, 1 nm ahead
+ val (cpa, tcpa) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 10.0, ownCog = 0.0,
+ tgtLat = 1.0 / 60.0, tgtLon = 0.0, tgtSog = 10.0, tgtCog = 180.0
+ )
+ assertTrue("CPA should be near zero, got $cpa", cpa < 0.1)
+ assertTrue("TCPA should be positive, got $tcpa", tcpa > 0)
+ assertTrue("TCPA should be < 5 min, got $tcpa", tcpa < 5.0)
+ }
+
+ @Test
+ fun `diverging vessels produce negative TCPA`() {
+ // Own heading north, target heading north ahead — both going same direction with target faster
+ val (_, tcpa) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 5.0, ownCog = 0.0,
+ tgtLat = 1.0 / 60.0, tgtLon = 0.0, tgtSog = 10.0, tgtCog = 0.0
+ )
+ assertTrue("TCPA should be negative for diverging vessels, got $tcpa", tcpa < 0)
+ }
+
+ @Test
+ fun `zero relative velocity returns current distance`() {
+ // Same velocity — parallel course, same speed
+ val (cpa, tcpa) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 5.0, ownCog = 90.0,
+ tgtLat = 1.0 / 60.0, tgtLon = 0.0, tgtSog = 5.0, tgtCog = 90.0
+ )
+ assertTrue("CPA should equal current distance, got $cpa", cpa > 0.9 && cpa < 1.1)
+ assertEquals(0.0, tcpa, 1e-9)
+ }
+
+ @Test
+ fun `well-separated crossing vessels produce large CPA`() {
+ // Perpendicular courses but far apart — should not alert
+ val (cpa, _) = CpaCalculator.compute(
+ ownLat = 0.0, ownLon = 0.0, ownSog = 8.0, ownCog = 0.0,
+ tgtLat = 0.0, tgtLon = 5.0, tgtSog = 8.0, tgtCog = 270.0
+ )
+ assertTrue("CPA should be > 2 nm for well-separated vessels, got $cpa", cpa > 2.0)
+ }
+
+ // --- Severity classification ---
+
+ @Test
+ fun `DANGER severity for CPA under 0_5 nm`() {
+ val alert = CpaAlert(123, "TARGET", 0.3, 10.0, CpaSeverity.DANGER)
+ assertEquals(CpaSeverity.DANGER, alert.severity)
+ }
+
+ @Test
+ fun `label formats correctly with vessel name`() {
+ val alert = CpaAlert(123456789, "STENA IMPERO", 0.8, 12.5, CpaSeverity.WARNING)
+ val lbl = alert.label()
+ assertTrue(lbl.contains("0.8"))
+ assertTrue(lbl.contains("13") || lbl.contains("12")) // rounded min
+ assertTrue(lbl.contains("STENA"))
+ }
+
+ @Test
+ fun `label uses MMSI prefix when name is blank`() {
+ val alert = CpaAlert(987654321, "", 1.5, 20.0, CpaSeverity.CAUTION)
+ // "MMSI 987654321" is 14 chars; label() takes the first 12 -> "MMSI 9876543"
+ val lbl = alert.label()
+ assertTrue("Expected MMSI prefix in label, got: $lbl", lbl.contains("MMSI"))
+ assertTrue("Expected partial MMSI in label, got: $lbl", lbl.contains("987654"))
+ }
+}
diff --git a/test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt b/test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt
new file mode 100644
index 0000000..7bce0a2
--- /dev/null
+++ b/test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt
@@ -0,0 +1,79 @@
+package org.terst.nav.track
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+class TrackStatsTest {
+
+ private lateinit var repo: TrackRepository
+
+ private fun pt(lat: Double, lon: Double, sog: Double = 5.0, ts: Long = 0L) =
+ TrackPoint(lat, lon, sog, 0.0, 0.0, 0.0, false, ts)
+
+ @Before
+ fun setup() {
+ repo = TrackRepository()
+ repo.startTrack()
+ }
+
+ @Test
+ fun `computeStats returns null for single point`() {
+ repo.addPoint(pt(41.0, -71.0, ts = 0))
+ assertNull(repo.computeStats())
+ }
+
+ @Test
+ fun `computeStats returns null when not recording and no points`() {
+ repo.stopTrack()
+ assertNull(repo.computeStats())
+ }
+
+ @Test
+ fun `haversineNm one degree of latitude is approximately 60 nm`() {
+ val d = TrackRepository.haversineNm(0.0, 0.0, 1.0, 0.0)
+ assertEquals(60.0, d, 0.5)
+ }
+
+ @Test
+ fun `haversineNm zero distance returns zero`() {
+ assertEquals(0.0, TrackRepository.haversineNm(41.0, -71.0, 41.0, -71.0), 1e-9)
+ }
+
+ @Test
+ fun `computeStats distance accumulates across legs`() {
+ repo.addPoint(pt(0.0, 0.0, ts = 0))
+ repo.addPoint(pt(1.0, 0.0, ts = 3_600_000)) // ~60 nm north
+ repo.addPoint(pt(1.0, 1.0, ts = 7_200_000)) // ~60 nm east at 1° lat
+ val stats = repo.computeStats()!!
+ assertTrue("Expected ~120 nm, got ${stats.distanceNm}", stats.distanceNm > 100.0)
+ }
+
+ @Test
+ fun `computeStats duration is last minus first timestamp`() {
+ repo.addPoint(pt(0.0, 0.0, ts = 1_000))
+ repo.addPoint(pt(1.0, 0.0, ts = 3_601_000))
+ val stats = repo.computeStats()!!
+ assertEquals(3_600_000L, stats.durationMs)
+ }
+
+ @Test
+ fun `computeStats avgSog is mean of all point sogs`() {
+ repo.addPoint(pt(0.0, 0.0, sog = 4.0, ts = 0))
+ repo.addPoint(pt(0.0, 0.1, sog = 6.0, ts = 1000))
+ val stats = repo.computeStats()!!
+ assertEquals(5.0, stats.avgSogKnots, 0.001)
+ }
+
+ @Test
+ fun `durationFormatted shows m_ss below one hour`() {
+ val stats = TrackStats(1.0, 65_000L, 5.0) // 1 min 5 sec
+ assertEquals("1:05", stats.durationFormatted)
+ }
+
+ @Test
+ fun `durationFormatted shows h_mm_ss above one hour`() {
+ val stats = TrackStats(1.0, 3_661_000L, 5.0) // 1h 1m 1s
+ assertEquals("1:01:01", stats.durationFormatted)
+ }
+}