summaryrefslogtreecommitdiff
path: root/android-app/app/src/test
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-15 03:44:25 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-25 04:54:31 +0000
commit826d56ede2c59cad19748f61d8b5d75d08a702d9 (patch)
tree6cdb3bbcc50ba6fc3a5c4f1ec077b6a1fa4a8384 /android-app/app/src/test
parentc943c22954132b21f3067b526b3c13f3300113dd (diff)
feat: add harmonic tide height predictions (Section 3.2 / 4.2)
Implement offline harmonic tide prediction as specified in COMPONENT_DESIGN.md: - TideConstituent: name, speedDegPerHour, amplitudeMeters, phaseDeg - TidePrediction: timestampMs, heightMeters - TideStation: id, name, lat, lon, datumOffsetMeters, constituents - HarmonicTideCalculator: predictHeight(), predictRange(), findHighLow() Formula: h(t) = Z0 + Σ [ Hi × cos( ωi × (t − t0) − φi ) ] - 15 unit tests covering all calculation paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app/src/test')
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt135
-rw-r--r--android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt56
-rw-r--r--android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt0
3 files changed, 191 insertions, 0 deletions
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt
new file mode 100644
index 0000000..612ae34
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt
@@ -0,0 +1,135 @@
+package com.example.androidapp.tide
+
+import com.example.androidapp.data.model.TideConstituent
+import com.example.androidapp.data.model.TideStation
+import org.junit.Assert.*
+import org.junit.Test
+
+class HarmonicTideCalculatorTest {
+
+ // Reference epoch: 2000-01-01 00:00:00 UTC = 946_684_800_000 ms
+ private val epochMs = HarmonicTideCalculator.EPOCH_MS
+ private val oneHourMs = 3_600_000L
+
+ private fun stationWith(
+ speed: Double = 30.0,
+ amplitude: Double = 1.0,
+ phase: Double = 0.0,
+ datum: Double = 0.0
+ ) = TideStation(
+ id = "test", name = "Test", lat = 0.0, lon = 0.0,
+ datumOffsetMeters = datum,
+ constituents = listOf(TideConstituent("S2", speed, amplitude, phase))
+ )
+
+ @Test
+ fun `predictHeight at epoch gives datum plus amplitude for zero-phase constituent`() {
+ val station = stationWith(speed = 30.0, amplitude = 1.5, phase = 0.0, datum = 0.5)
+ val height = HarmonicTideCalculator.predictHeight(station, epochMs)
+ assertEquals(0.5 + 1.5, height, 1e-9) // cos(0°) = 1.0
+ }
+
+ @Test
+ fun `predictHeight at half period gives datum minus amplitude`() {
+ // speed = 30 deg/hr → half period = 6 hours → cos(180°) = -1.0
+ val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0)
+ val height = HarmonicTideCalculator.predictHeight(station, epochMs + 6 * oneHourMs)
+ assertEquals(-1.0, height, 1e-9)
+ }
+
+ @Test
+ fun `predictHeight at quarter period is near zero`() {
+ // speed = 30 deg/hr → quarter period = 3 hours → cos(90°) ≈ 0.0
+ val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0)
+ val height = HarmonicTideCalculator.predictHeight(station, epochMs + 3 * oneHourMs)
+ assertEquals(0.0, height, 1e-9)
+ }
+
+ @Test
+ fun `predictHeight applies phase offset correctly`() {
+ // phase = 90 → cos(0 - 90°) = cos(-90°) ≈ 0.0 at epoch
+ val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 90.0, datum = 0.0)
+ val height = HarmonicTideCalculator.predictHeight(station, epochMs)
+ assertEquals(0.0, height, 1e-9)
+ }
+
+ @Test
+ fun `predictHeight sums multiple constituents at epoch`() {
+ val station = TideStation(
+ id = "test", name = "Test", lat = 0.0, lon = 0.0,
+ datumOffsetMeters = 2.0,
+ constituents = listOf(
+ TideConstituent("S2", 30.0, 1.0, 0.0), // +1.0 at epoch
+ TideConstituent("K1", 30.0, 0.5, 0.0) // +0.5 at epoch
+ )
+ )
+ val height = HarmonicTideCalculator.predictHeight(station, epochMs)
+ assertEquals(3.5, height, 1e-9) // 2.0 + 1.0 + 0.5
+ }
+
+ @Test
+ fun `predictHeight with empty constituents returns datum offset only`() {
+ val station = TideStation("t", "T", 0.0, 0.0, 3.14, emptyList())
+ assertEquals(3.14, HarmonicTideCalculator.predictHeight(station, epochMs), 1e-9)
+ }
+
+ @Test
+ fun `predictRange returns correct number of predictions`() {
+ val station = stationWith()
+ val predictions = HarmonicTideCalculator.predictRange(
+ station, epochMs, epochMs + 3 * oneHourMs, oneHourMs
+ )
+ assertEquals(4, predictions.size) // t=0h, 1h, 2h, 3h
+ }
+
+ @Test
+ fun `predictRange timestamps are evenly spaced`() {
+ val station = stationWith()
+ val predictions = HarmonicTideCalculator.predictRange(
+ station, epochMs, epochMs + 2 * oneHourMs, oneHourMs
+ )
+ assertEquals(epochMs, predictions[0].timestampMs)
+ assertEquals(epochMs + oneHourMs, predictions[1].timestampMs)
+ assertEquals(epochMs + 2 * oneHourMs, predictions[2].timestampMs)
+ }
+
+ @Test
+ fun `predictRange with equal from and to returns single prediction`() {
+ val station = stationWith()
+ val predictions = HarmonicTideCalculator.predictRange(station, epochMs, epochMs, oneHourMs)
+ assertEquals(1, predictions.size)
+ assertEquals(epochMs, predictions[0].timestampMs)
+ }
+
+ @Test
+ fun `findHighLow returns empty list for fewer than 3 predictions`() {
+ val station = stationWith()
+ val predictions = HarmonicTideCalculator.predictRange(
+ station, epochMs, epochMs + oneHourMs, oneHourMs
+ )
+ assertEquals(2, predictions.size)
+ assertTrue(HarmonicTideCalculator.findHighLow(predictions).isEmpty())
+ }
+
+ @Test
+ fun `findHighLow detects high and low water events`() {
+ // speed = 30 deg/hr, 3-hour samples over 24 hours
+ // Heights: 1.0, 0.0, -1.0, 0.0, 1.0, 0.0, -1.0, 0.0, 1.0
+ // Turning points at t=6h(low), t=12h(high), t=18h(low)
+ val station = stationWith(speed = 30.0, amplitude = 1.0, phase = 0.0, datum = 0.0)
+ val predictions = HarmonicTideCalculator.predictRange(
+ station,
+ epochMs,
+ epochMs + 24 * oneHourMs,
+ 3 * oneHourMs
+ )
+ val highLow = HarmonicTideCalculator.findHighLow(predictions)
+ assertEquals(3, highLow.size)
+ assertEquals(epochMs + 6 * oneHourMs, highLow[0].timestampMs)
+ assertEquals(-1.0, highLow[0].heightMeters, 1e-9)
+ assertEquals(epochMs + 12 * oneHourMs, highLow[1].timestampMs)
+ assertEquals(1.0, highLow[1].heightMeters, 1e-9)
+ assertEquals(epochMs + 18 * oneHourMs, highLow[2].timestampMs)
+ assertEquals(-1.0, highLow[2].heightMeters, 1e-9)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt
new file mode 100644
index 0000000..0a6f4bb
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt
@@ -0,0 +1,56 @@
+package com.example.androidapp.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class TideModelTest {
+
+ @Test
+ fun `TideConstituent holds all fields`() {
+ val c = TideConstituent("M2", 28.9841042, 0.85, 120.0)
+ assertEquals("M2", c.name)
+ assertEquals(28.9841042, c.speedDegPerHour, 1e-7)
+ assertEquals(0.85, c.amplitudeMeters, 1e-9)
+ assertEquals(120.0, c.phaseDeg, 1e-9)
+ }
+
+ @Test
+ fun `TidePrediction holds timestamp and height`() {
+ val p = TidePrediction(1_700_000_000_000L, 2.34)
+ assertEquals(1_700_000_000_000L, p.timestampMs)
+ assertEquals(2.34, p.heightMeters, 1e-9)
+ }
+
+ @Test
+ fun `TidePrediction data class equality`() {
+ val p1 = TidePrediction(1_000L, 1.5)
+ val p2 = TidePrediction(1_000L, 1.5)
+ assertEquals(p1, p2)
+ }
+
+ @Test
+ fun `TideStation holds all fields and constituents`() {
+ val c = TideConstituent("K1", 15.0410686, 0.3, 45.0)
+ val station = TideStation(
+ id = "9447130",
+ name = "Seattle, WA",
+ lat = 47.602,
+ lon = -122.339,
+ datumOffsetMeters = 1.8,
+ constituents = listOf(c)
+ )
+ assertEquals("9447130", station.id)
+ assertEquals("Seattle, WA", station.name)
+ assertEquals(47.602, station.lat, 1e-9)
+ assertEquals(-122.339, station.lon, 1e-9)
+ assertEquals(1.8, station.datumOffsetMeters, 1e-9)
+ assertEquals(1, station.constituents.size)
+ assertEquals("K1", station.constituents[0].name)
+ }
+
+ @Test
+ fun `TideStation with empty constituents is valid`() {
+ val station = TideStation("test", "Test", 0.0, 0.0, 0.0, emptyList())
+ assertTrue(station.constituents.isEmpty())
+ }
+}
diff --git a/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org\/terst\/nav/data/model/TideModelTest.kt