summaryrefslogtreecommitdiff
path: root/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt
blob: e5615e93b73d8cb4188362fa3ecb8667c3acfc0f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
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])
    }
}