diff options
| author | Claudomator Agent <agent@claudomator> | 2026-03-15 03:44:25 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-25 04:54:31 +0000 |
| commit | 826d56ede2c59cad19748f61d8b5d75d08a702d9 (patch) | |
| tree | 6cdb3bbcc50ba6fc3a5c4f1ec077b6a1fa4a8384 /android-app/app/src | |
| parent | c943c22954132b21f3067b526b3c13f3300113dd (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')
10 files changed, 306 insertions, 0 deletions
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt new file mode 100644 index 0000000..2bdbf6c --- /dev/null +++ b/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt @@ -0,0 +1,88 @@ +package com.example.androidapp.tide + +import com.example.androidapp.data.model.TidePrediction +import com.example.androidapp.data.model.TideStation +import kotlin.math.cos + +/** + * Computes harmonic tide predictions using the standard formula: + * h(t) = Z0 + Σ [ Hi × cos( ωi × (t − t0) − φi ) ] + * + * where: + * Z0 = datum offset (mean water level above chart datum, metres) + * Hi = amplitude of constituent i (metres) + * ωi = angular speed of constituent i (degrees / hour) + * t = hours elapsed since [EPOCH_MS] (2000-01-01 00:00 UTC) + * φi = phase lag (degrees) + */ +object HarmonicTideCalculator { + + /** Reference epoch: 2000-01-01 00:00:00 UTC in Unix milliseconds. */ + internal const val EPOCH_MS = 946_684_800_000L + + /** + * Predict the tide height at a single moment. + * + * @param station Tide station with harmonic constituents. + * @param timestampMs Unix epoch milliseconds for the desired time. + * @return Predicted height in metres above chart datum. + */ + fun predictHeight(station: TideStation, timestampMs: Long): Double { + val hoursFromEpoch = (timestampMs - EPOCH_MS) / 3_600_000.0 + var height = station.datumOffsetMeters + for (c in station.constituents) { + val angleDeg = c.speedDegPerHour * hoursFromEpoch - c.phaseDeg + height += c.amplitudeMeters * cos(Math.toRadians(angleDeg)) + } + return height + } + + /** + * Predict tide heights over a time range at regular intervals. + * + * @param station Tide station. + * @param fromMs Start of range (Unix milliseconds, inclusive). + * @param toMs End of range (Unix milliseconds, inclusive). + * @param intervalMs Time step in milliseconds (must be positive). + * @return List of [TidePrediction] ordered by ascending timestamp. + */ + fun predictRange( + station: TideStation, + fromMs: Long, + toMs: Long, + intervalMs: Long + ): List<TidePrediction> { + require(intervalMs > 0) { "intervalMs must be positive" } + require(fromMs <= toMs) { "fromMs must not exceed toMs" } + val predictions = mutableListOf<TidePrediction>() + var t = fromMs + while (t <= toMs) { + predictions += TidePrediction(t, predictHeight(station, t)) + t += intervalMs + } + return predictions + } + + /** + * Find high and low water events from a pre-computed prediction series. + * + * Detects local maxima (high water) and minima (low water) by comparing + * each interior sample with its immediate neighbours. + * + * @param predictions Ordered list of tide predictions (at least 3 points). + * @return Subset list containing only high/low turning points. + */ + fun findHighLow(predictions: List<TidePrediction>): List<TidePrediction> { + if (predictions.size < 3) return emptyList() + val result = mutableListOf<TidePrediction>() + for (i in 1 until predictions.size - 1) { + val prev = predictions[i - 1].heightMeters + val curr = predictions[i].heightMeters + val next = predictions[i + 1].heightMeters + val isMax = curr >= prev && curr >= next + val isMin = curr <= prev && curr <= next + if (isMax || isMin) result += predictions[i] + } + return result + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt new file mode 100644 index 0000000..deb73d6 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt @@ -0,0 +1,9 @@ +package com.example.androidapp.data.model + +/** A single harmonic tidal constituent used in harmonic tide prediction. */ +data class TideConstituent( + val name: String, // e.g. "M2", "S2", "K1" + val speedDegPerHour: Double, // angular speed in degrees per hour + val amplitudeMeters: Double, // amplitude in metres + val phaseDeg: Double // phase lag (kappa) in degrees +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt new file mode 100644 index 0000000..51eea44 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt @@ -0,0 +1,7 @@ +package com.example.androidapp.data.model + +/** A predicted tide height at a specific point in time. */ +data class TidePrediction( + val timestampMs: Long, // Unix epoch milliseconds + val heightMeters: Double // predicted water height above chart datum in metres +) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt new file mode 100644 index 0000000..c9f96a6 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt @@ -0,0 +1,11 @@ +package com.example.androidapp.data.model + +/** A tide station with harmonic constituents for offline tide prediction. */ +data class TideStation( + val id: String, + val name: String, + val lat: Double, + val lon: Double, + val datumOffsetMeters: Double, // mean water level above chart datum (Z0) + val constituents: List<TideConstituent> +) diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt 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 |
