summaryrefslogtreecommitdiff
path: root/android-app/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt88
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt11
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt0
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt0
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt0
-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
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