summaryrefslogtreecommitdiff
path: root/android-app/app/src/test
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-15 05:55:47 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-25 04:54:49 +0000
commit984f915525184a9aaff87f3d5687ef46ebb00702 (patch)
treee22e374260e40eced792cd155829359d500df502 /android-app/app/src/test
parent826d56ede2c59cad19748f61d8b5d75d08a702d9 (diff)
feat: implement isochrone-based weather routing (Section 3.4)
Diffstat (limited to 'android-app/app/src/test')
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt169
1 files changed, 169 insertions, 0 deletions
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt
new file mode 100644
index 0000000..e5615e9
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt
@@ -0,0 +1,169 @@
+package com.example.androidapp.routing
+
+import com.example.androidapp.data.model.BoatPolars
+import com.example.androidapp.data.model.WindForecast
+import org.junit.Assert.*
+import org.junit.Test
+
+class IsochroneRouterTest {
+
+ private val startTimeMs = 1_000_000_000L
+ private val oneHourMs = 3_600_000L
+
+ // ── BoatPolars ────────────────────────────────────────────────────────────
+
+ @Test
+ fun `bsp returns exact value for exact twa and tws entry`() {
+ val polars = BoatPolars.DEFAULT
+ // At TWS=10, TWA=90 the table has 7.0 kt
+ assertEquals(7.0, polars.bsp(90.0, 10.0), 1e-9)
+ }
+
+ @Test
+ fun `bsp interpolates between twa entries`() {
+ val polars = BoatPolars.DEFAULT
+ // At TWS=10: TWA=60 → 6.5, TWA=90 → 7.0; midpoint TWA=75 → 6.75
+ assertEquals(6.75, polars.bsp(75.0, 10.0), 1e-9)
+ }
+
+ @Test
+ fun `bsp interpolates between tws entries`() {
+ val polars = BoatPolars.DEFAULT
+ // At TWA=90: TWS=10 → 7.0, TWS=15 → 8.0; midpoint TWS=12.5 → 7.5
+ assertEquals(7.5, polars.bsp(90.0, 12.5), 1e-9)
+ }
+
+ @Test
+ fun `bsp mirrors port tack twa to starboard`() {
+ val polars = BoatPolars.DEFAULT
+ // TWA=270 should mirror to 360-270=90, giving same as TWA=90
+ assertEquals(polars.bsp(90.0, 10.0), polars.bsp(270.0, 10.0), 1e-9)
+ }
+
+ @Test
+ fun `bsp clamps tws below table minimum`() {
+ val polars = BoatPolars.DEFAULT
+ // TWS=0 clamps to minimum TWS=5
+ assertEquals(polars.bsp(90.0, 5.0), polars.bsp(90.0, 0.0), 1e-9)
+ }
+
+ @Test
+ fun `bsp clamps tws above table maximum`() {
+ val polars = BoatPolars.DEFAULT
+ // TWS=100 clamps to maximum TWS=20
+ assertEquals(polars.bsp(90.0, 20.0), polars.bsp(90.0, 100.0), 1e-9)
+ }
+
+ // ── IsochroneRouter geometry helpers ─────────────────────────────────────
+
+ @Test
+ fun `haversineM returns zero for same point`() {
+ assertEquals(0.0, IsochroneRouter.haversineM(10.0, 20.0, 10.0, 20.0), 1e-3)
+ }
+
+ @Test
+ fun `haversineM one degree of latitude is approximately 111_195 m`() {
+ val dist = IsochroneRouter.haversineM(0.0, 0.0, 1.0, 0.0)
+ assertEquals(111_195.0, dist, 50.0)
+ }
+
+ @Test
+ fun `bearingDeg returns 0 for due north`() {
+ val bearing = IsochroneRouter.bearingDeg(0.0, 0.0, 1.0, 0.0)
+ assertEquals(0.0, bearing, 1e-6)
+ }
+
+ @Test
+ fun `bearingDeg returns 90 for due east`() {
+ val bearing = IsochroneRouter.bearingDeg(0.0, 0.0, 0.0, 1.0)
+ assertEquals(90.0, bearing, 1e-4)
+ }
+
+ @Test
+ fun `destinationPoint due north by 1 NM moves latitude by expected amount`() {
+ val (lat, lon) = IsochroneRouter.destinationPoint(0.0, 0.0, 0.0, IsochroneRouter.NM_TO_M)
+ assertTrue("latitude should increase", lat > 0.0)
+ assertEquals(0.0, lon, 1e-9)
+ // 1 NM ≈ 1/60 degree of latitude
+ assertEquals(1.0 / 60.0, lat, 1e-4)
+ }
+
+ // ── Pruning ───────────────────────────────────────────────────────────────
+
+ @Test
+ fun `prune keeps only furthest point per sector`() {
+ // Two points both due north of origin at different distances
+ val close = RoutePoint(1.0, 0.0, startTimeMs)
+ val far = RoutePoint(2.0, 0.0, startTimeMs)
+ val result = IsochroneRouter.prune(listOf(close, far), 0.0, 0.0, 72)
+ assertEquals(1, result.size)
+ assertEquals(far, result[0])
+ }
+
+ @Test
+ fun `prune keeps points in different sectors separately`() {
+ // One point north, one point east — different sectors
+ val north = RoutePoint(1.0, 0.0, startTimeMs)
+ val east = RoutePoint(0.0, 1.0, startTimeMs)
+ val result = IsochroneRouter.prune(listOf(north, east), 0.0, 0.0, 72)
+ assertEquals(2, result.size)
+ }
+
+ // ── Full routing ──────────────────────────────────────────────────────────
+
+ @Test
+ fun `route finds path to destination with constant wind`() {
+ // Destination is ~5 NM due east of start; constant 10kt easterly (FROM east = 90°)
+ // A 10kt boat sailing downwind (TWA=180) = 6.0 kt; ~5 NM / 6 kt ≈ 50 min → 1 step
+ val destLat = 0.0
+ val destLon = 0.0 + (5.0 / 60.0) // ~5 NM east
+ val constantWind = { _: Double, _: Double, _: Long ->
+ WindForecast(0.0, 0.0, startTimeMs, twsKt = 10.0, twdDeg = 90.0)
+ }
+ val result = IsochroneRouter.route(
+ startLat = 0.0,
+ startLon = 0.0,
+ destLat = destLat,
+ destLon = destLon,
+ startTimeMs = startTimeMs,
+ stepMs = oneHourMs,
+ polars = BoatPolars.DEFAULT,
+ windAt = constantWind,
+ arrivalRadiusM = 2_000.0 // 2 km arrival radius
+ )
+ assertNotNull("Should find a route", result)
+ result!!
+ assertTrue("Path should have at least 2 points (start + arrival)", result.path.size >= 2)
+ assertEquals("Path should start at origin", 0.0, result.path.first().lat, 1e-6)
+ assertEquals("ETA should be after start", startTimeMs, result.etaMs - oneHourMs)
+ }
+
+ @Test
+ fun `route returns null when polars produce zero speed`() {
+ val zeroPolar = BoatPolars(emptyMap())
+ val result = IsochroneRouter.route(
+ startLat = 0.0,
+ startLon = 0.0,
+ destLat = 1.0,
+ destLon = 0.0,
+ startTimeMs = startTimeMs,
+ stepMs = oneHourMs,
+ polars = zeroPolar,
+ windAt = { _, _, _ -> WindForecast(0.0, 0.0, startTimeMs, 10.0, 0.0) },
+ maxSteps = 3
+ )
+ assertNull("Should return null when no progress is possible", result)
+ }
+
+ @Test
+ fun `backtrace returns path from start to arrival in order`() {
+ val p0 = RoutePoint(0.0, 0.0, 0L)
+ val p1 = RoutePoint(1.0, 0.0, 1L, parent = p0)
+ val p2 = RoutePoint(2.0, 0.0, 2L, parent = p1)
+ val path = IsochroneRouter.backtrace(p2)
+ assertEquals(3, path.size)
+ assertEquals(p0, path[0])
+ assertEquals(p1, path[1])
+ assertEquals(p2, path[2])
+ }
+}