From 984f915525184a9aaff87f3d5687ef46ebb00702 Mon Sep 17 00:00:00 2001 From: Claudomator Agent Date: Sun, 15 Mar 2026 05:55:47 +0000 Subject: feat: implement isochrone-based weather routing (Section 3.4) --- .../androidapp/routing/IsochroneRouterTest.kt | 169 +++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt (limited to 'android-app/app/src/test/kotlin/com') 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]) + } +} -- cgit v1.2.3