From 87f9cb753da3325d13fdbc442d526567e056d6e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 08:45:55 +0000 Subject: feat: CPA/TCPA collision alerts and live track stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/main/kotlin/org/terst/nav/MainActivity.kt | 21 ++++++ .../src/main/kotlin/org/terst/nav/ais/CpaAlert.kt | 23 +++++++ .../kotlin/org/terst/nav/track/TrackRepository.kt | 30 ++++++++ .../main/kotlin/org/terst/nav/track/TrackStats.kt | 15 ++++ .../main/kotlin/org/terst/nav/ui/MainViewModel.kt | 41 +++++++++++ .../org/terst/nav/ui/safety/SafetyFragment.kt | 20 ++++++ .../app/src/main/res/layout/activity_main.xml | 19 ++++++ .../app/src/main/res/layout/fragment_safety.xml | 37 +++++++++- .../src/main/kotlin/org/terst/nav/ais/CpaAlert.kt | 23 +++++++ .../main/kotlin/org/terst/nav/ais/CpaCalculator.kt | 1 - .../kotlin/org/terst/nav/track/TrackRepository.kt | 30 ++++++++ .../main/kotlin/org/terst/nav/track/TrackStats.kt | 15 ++++ .../test/kotlin/org/terst/nav/ais/CpaAlertTest.kt | 78 +++++++++++++++++++++ .../kotlin/org/terst/nav/track/TrackStatsTest.kt | 79 ++++++++++++++++++++++ 14 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt create mode 100644 android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt create mode 100644 test-runner/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt create mode 100644 test-runner/src/main/kotlin/org/terst/nav/track/TrackStats.kt create mode 100644 test-runner/src/test/kotlin/org/terst/nav/ais/CpaAlertTest.kt create mode 100644 test-runner/src/test/kotlin/org/terst/nav/track/TrackStatsTest.kt diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index ac2272c..3880671 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -11,6 +11,7 @@ import android.os.Bundle import android.util.Log import android.view.View import android.widget.FrameLayout +import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels @@ -98,12 +99,27 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fabRecordTrack.setOnClickListener { if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack() } + val tvTrackStats = findViewById(R.id.tv_track_stats) + // Observe immediately — pure UI state, not gated on GPS permission lifecycleScope.launch { viewModel.isRecording.collect { recording -> val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record fabRecordTrack.setImageResource(icon) fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track" + if (!recording) tvTrackStats.visibility = View.GONE + } + } + lifecycleScope.launch { + viewModel.trackStats.collect { stats -> + if (stats != null) { + tvTrackStats.text = "● REC %.2fnm %s %.1fkt avg".format( + stats.distanceNm, stats.durationFormatted, stats.avgSogKnots + ) + tvTrackStats.visibility = View.VISIBLE + } else { + tvTrackStats.visibility = View.GONE + } } } } @@ -351,6 +367,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive") } } + lifecycleScope.launch { + viewModel.cpaAlerts.collect { alerts -> + safetyFragment.updateCpaAlerts(alerts) + } + } lifecycleScope.launch { loadedStyleFlow.filterNotNull() .combine(viewModel.trackPoints) { style, points -> style to points } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt b/android-app/app/src/main/kotlin/org/terst/nav/ais/CpaAlert.kt new file mode 100644 index 0000000..1d635c0 --- /dev/null +++ b/android-app/app/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/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 7953822..a68e315 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 @@ -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 = 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/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackStats.kt new file mode 100644 index 0000000..39153c6 --- /dev/null +++ b/android-app/app/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/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 86bfe32..bf57c1f 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 @@ -5,11 +5,16 @@ import androidx.lifecycle.viewModelScope import org.terst.nav.ais.AisHubSource import org.terst.nav.ais.AisRepository import org.terst.nav.ais.AisVessel +import org.terst.nav.ais.CpaAlert +import org.terst.nav.ais.CpaCalculator +import org.terst.nav.ais.CpaSeverity +import org.terst.nav.ais.CpaThresholds import org.terst.nav.data.api.AisHubApiService import org.terst.nav.thermal.ThermalReading import org.terst.nav.thermal.ThermalState import org.terst.nav.track.TrackPoint import org.terst.nav.track.TrackRepository +import org.terst.nav.track.TrackStats import org.terst.nav.vessel.CrewMember import org.terst.nav.vessel.Vessel import org.terst.nav.vessel.VesselRepository @@ -51,6 +56,14 @@ class MainViewModel( private val _aisTargets = MutableStateFlow>(emptyList()) val aisTargets: StateFlow> = _aisTargets.asStateFlow() + private val _cpaAlerts = MutableStateFlow>(emptyList()) + val cpaAlerts: StateFlow> = _cpaAlerts.asStateFlow() + + private var lastLat: Double = 0.0 + private var lastLon: Double = 0.0 + private var lastSog: Double = 0.0 + private var lastCog: Double = 0.0 + private val aisRepository = AisRepository() private val trackRepository = TrackRepository() @@ -74,6 +87,9 @@ class MainViewModel( private val _trackPoints = MutableStateFlow>(emptyList()) val trackPoints: StateFlow> = _trackPoints.asStateFlow() + private val _trackStats = MutableStateFlow(null) + val trackStats: StateFlow = _trackStats.asStateFlow() + fun startTrack() { trackRepository.startTrack() _trackPoints.value = emptyList() @@ -83,9 +99,11 @@ class MainViewModel( fun stopTrack() { trackRepository.stopTrack() _isRecording.value = false + _trackStats.value = null } fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) { + lastLat = lat; lastLon = lon; lastSog = sogKnots; lastCog = cogDeg val point = TrackPoint( lat = lat, lon = lon, sogKnots = sogKnots, cogDeg = cogDeg, @@ -94,7 +112,29 @@ class MainViewModel( ) if (trackRepository.addPoint(point)) { _trackPoints.value = trackRepository.getPoints() + _trackStats.value = trackRepository.computeStats() } + updateCpaAlerts() + } + + private fun updateCpaAlerts() { + if (lastSog < 0.1) return // stationary — skip + val alerts = aisRepository.getTargets().mapNotNull { target -> + if (target.sog < 0.1 && target.cog == 0.0) return@mapNotNull null + val (cpa, tcpa) = CpaCalculator.compute( + lastLat, lastLon, lastSog, lastCog, + target.lat, target.lon, target.sog, target.cog.toDouble() + ) + if (tcpa < 0 || tcpa > CpaThresholds.MAX_TCPA_MIN) return@mapNotNull null + val severity = when { + cpa <= CpaThresholds.DANGER_CPA_NM -> CpaSeverity.DANGER + cpa <= CpaThresholds.WARNING_CPA_NM -> CpaSeverity.WARNING + cpa <= CpaThresholds.CAUTION_CPA_NM -> CpaSeverity.CAUTION + else -> return@mapNotNull null + } + CpaAlert(target.mmsi, target.name, cpa, tcpa, severity) + }.sortedBy { it.tcpaMin } + _cpaAlerts.value = alerts } private val aisHubApi: AisHubApiService by lazy { @@ -174,6 +214,7 @@ class MainViewModel( aisRepository.processSentence(sentence) aisRepository.evictStale() _aisTargets.value = aisRepository.getTargets() + updateCpaAlerts() } /** diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt index e950b5d..b248e8d 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/safety/SafetyFragment.kt @@ -5,9 +5,13 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import com.google.android.material.button.MaterialButton import org.terst.nav.R +import org.terst.nav.ais.CpaAlert +import org.terst.nav.ais.CpaSeverity +import org.terst.nav.ais.label import org.terst.nav.ui.doc.DocFragment class SafetyFragment : Fragment() { @@ -53,4 +57,20 @@ class SafetyFragment : Fragment() { fun updateAnchorStatus(statusText: String) { view?.findViewById(R.id.anchor_status_summary)?.text = statusText } + + fun updateCpaAlerts(alerts: List) { + val tv = view?.findViewById(R.id.tv_cpa_status) ?: return + if (alerts.isEmpty()) { + tv.text = "No traffic alerts" + tv.setTextColor(ContextCompat.getColor(requireContext(), android.R.color.darker_gray)) + return + } + tv.text = alerts.joinToString("\n") { it.label() } + val worstColor = when (alerts.first().severity) { + CpaSeverity.DANGER -> ContextCompat.getColor(requireContext(), android.R.color.holo_red_light) + CpaSeverity.WARNING -> ContextCompat.getColor(requireContext(), android.R.color.holo_orange_light) + CpaSeverity.CAUTION -> ContextCompat.getColor(requireContext(), android.R.color.holo_blue_light) + } + tv.setTextColor(worstColor) + } } diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index feeb43d..a99a8cb 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -32,6 +32,25 @@ android:visibility="gone" android:background="?attr/colorSurface" /> + + + diff --git a/android-app/app/src/main/res/layout/fragment_safety.xml b/android-app/app/src/main/res/layout/fragment_safety.xml index 5b2397e..c270d1f 100644 --- a/android-app/app/src/main/res/layout/fragment_safety.xml +++ b/android-app/app/src/main/res/layout/fragment_safety.xml @@ -63,6 +63,41 @@ + + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/card_cpa"> 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 = 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) + } +} -- cgit v1.2.3