summaryrefslogtreecommitdiff
path: root/android-app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-04-02 11:28:00 -1000
committerGitHub <noreply@github.com>2026-04-02 11:28:00 -1000
commit5416c8ccc9220bc36e7af3febcbdd9d86e88cf30 (patch)
tree46e990ae9dc43f4f7b6ea75cb4e5467d6fdb6359 /android-app
parenta9d87b600848178b03b85a06ccdfd53b11e38c38 (diff)
fix(ui): make record track FAB visible (#1)
* Add GpsPosition data class and NMEA RMC parser with tests - GpsPosition: latitude, longitude, sog (knots), cog (degrees true), timestampMs - NmeaParser.parseRmc: handles GP/GN talker IDs, void status, malformed input - SOG/COG default to 0.0 when fields absent; S/W coords are negative - 13 unit tests: GpsPositionTest (2), NmeaParserTest (11) — all GREEN Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * 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> * feat: implement isochrone-based weather routing (Section 3.4) * feat: implement PDF logbook export (Section 4.8) - LogbookEntry data class: timestampMs, lat/lon, SOG, COG, wind, baro, depth, event/notes - LogbookFormatter: UTC time, position (deg/dec-min), 16-pt compass, row/page builders - LogbookPdfExporter: landscape A4 PDF via android.graphics.pdf.PdfDocument with column headers, alternating row shading, and table border - 20 unit tests covering all formatting helpers and data model behaviour Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: offline GRIB staleness checker, ViewModel integration, and UI badge - Add GribRegion, GribFile data models and GribFileManager interface - Add InMemoryGribFileManager for testing and default use - Add GribStalenessChecker with FreshnessResult sealed class (Fresh/Stale/NoData) - Integrate weatherStaleness StateFlow into MainViewModel (checked after loadWeather) - Add yellow staleness banner TextView to fragment_map.xml - Wire staleness banner in MapFragment (shown on Stale, hidden on Fresh/NoData) - Add GribStalenessCheckerTest (4 TDD tests) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: satellite GRIB download with bandwidth optimisation (§9.1) Implements weather data download over Iridium satellite links: - GribParameter enum with SATELLITE_MINIMAL set (wind + pressure only) - SatelliteDownloadRequest data class (region, params, forecast hours, resolution) - SatelliteGribDownloader: size/time estimation, abort-on-oversized, pluggable fetcher - 8 unit tests covering estimation scaling, minimal param set, and download outcomes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add AnchorWatchHandler UI with Depth/Rode Out inputs and suggested radius - Add AnchorWatchState with calculateRecommendedWatchCircleRadius, which uses ScopeCalculator.watchCircleRadius (Pythagorean scope formula) and falls back to rode length when geometry is invalid - Add AnchorWatchHandler Fragment with EditText inputs for Depth (m) and Rode Out (m); updates suggested watch circle radius live via TextWatcher - Add fragment_anchor_watch.xml layout - Wire AnchorWatchHandler into bottom navigation (MainActivity + menu) - Add AnchorWatchStateTest covering valid geometry, short-rode fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(safety): log wind and current conditions at MOB activation (Section 4.6) Per COMPONENT_DESIGN.md Section 4.6, the MOB navigation view must display wind and current conditions at the time of the event. - MobEvent: add nullable windSpeedKt, windDirectionDeg, currentSpeedKt, currentDirectionDeg fields captured at the exact moment of activation - MobAlarmManager.activate(): accept optional wind/current params and forward them into MobEvent (defaults to null for backward compatibility) - LocationService (new): aggregates live SensorData (resolves true wind via TrueWindCalculator) and marine-forecast current conditions; snapshot() provides a point-in-time EnvironmentalSnapshot for safety-critical logging - MobAlarmManagerTest: add tests for wind/current storage and null defaults - LocationServiceTest (new): covers snapshot, true-wind resolution, current-condition updates, and the latestSensor flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(gps): implement NMEA/Android GPS sensor fusion in LocationService Adds priority-based selection between NMEA GPS (dedicated marine GPS, higher priority) and Android system GPS (fallback) within LocationService. Selection policy: 1. Prefer NMEA when its most recent fix is fresh (≤ nmeaStalenessThresholdMs, default 5 s) 2. Fall back to Android GPS when NMEA is stale 3. Use stale NMEA only when Android has never reported a fix 4. bestPosition is null until at least one source reports New public API: - GpsSource enum (NONE, NMEA, ANDROID) - LocationService.updateNmeaGps(GpsPosition) - LocationService.updateAndroidGps(GpsPosition) - LocationService.bestPosition: StateFlow<GpsPosition?> - LocationService.activeGpsSource: StateFlow<GpsSource> - Injectable clockMs parameter for deterministic unit tests Adds 7 unit tests covering: no-data state, fresh NMEA priority, stale NMEA fallback, only-NMEA/only-Android scenarios, exact-threshold edge case, and NMEA recovery after Android takeover. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(gps): add fix-quality (accuracy) tier to GPS sensor fusion Extend LocationService's source-selection policy with a quality-aware "marginal staleness" zone between the primary and a new extended staleness threshold (default 10 s): 1. Fresh NMEA (≤ primary threshold, 5 s) → always prefer NMEA 2. Marginally stale NMEA (5–10 s) → prefer NMEA only when GpsPosition.accuracyMeters is strictly better than Android's; fall back to Android conservatively when accuracy data is absent 3. Very stale NMEA (> 10 s) → always prefer Android 4. Only one source available → use it regardless of age Changes: - GpsPosition: add nullable accuracyMeters field (default null, no breaking change to existing callers) - LocationService: add nmeaExtendedThresholdMs constructor parameter; recomputeBestPosition() now implements three-tier logic; extract GpsPosition.hasStrictlyBetterAccuracyThan() helper - LocationServiceTest: expose nmeaExtendedThresholdMs in fusionService helper; add posWithAccuracy helper; add 4 new test cases covering accuracy-based NMEA preference, worse-accuracy fallback, no-accuracy conservative fallback, and very-stale unconditional fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add .gitignore, pull-crash-logs script, updated agent permissions - .gitignore: exclude agent artifacts (.claudomator-env, .agent-home/, crash-logs/) - scripts/pull-crash-logs: download Crashlytics crash reports via Firebase CLI - .claude/settings.local.json: add Android SDK/emulator/adb permission rules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: update CI workflow to target main branch Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve all Kotlin compilation errors blocking CI build - Migrate kapt → KSP (Kotlin 2.0 + kapt is broken; KSP is the supported path) - Fix duplicate onResume() override in MainActivity - Fix wrong package imports: com.example.androidapp.data.model → org.terst.nav.data.model across GribFileManager, GribStalenessChecker, SatelliteGribDownloader, LogbookFormatter, LogbookPdfExporter, IsochroneRouter, AnchorWatchHandler - Create missing SensorData, ApparentWind, TrueWindData, TrueWindCalculator classes - Inline missing ScopeCalculator formula (Pythagorean) in AnchorWatchState Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(track): implement GPS track recording with map overlay - TrackRepository + TrackPoint wired into MainViewModel: isRecording/trackPoints StateFlows, startTrack/stopTrack/addGpsPoint - MapHandler.updateTrackLayer(): lazily initialises a red LineLayer and updates GeoJSON polyline from List<TrackPoint> - fab_record_track FAB in activity_main.xml (top|end of bottom nav); icon toggles between ic_track_record and ic_close while recording - MainActivity feeds every GPS fix into viewModel.addGpsPoint() and observes trackPoints to redraw the polyline in real time - ic_track_record.xml vector drawable (red record dot) - 8 TrackRepositoryTest tests all GREEN Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(ci): share APKs between jobs and expand smoke tests CI — build job now uploads both APKs as the 'test-apks' artifact. smoke-test job downloads them and passes -x assembleDebug -x assembleDebugAndroidTest to skip recompilation (~4 min saved). Test results uploaded as 'smoke-test-results' artifact on every run. Smoke tests expanded from 1 → 11 tests covering: - MainActivity launches without crash - All 4 bottom-nav tabs are displayed - Safety tab: Safety Dashboard, ACTIVATE MOB, ANCHOR WATCH visible - Log tab: voice-log mic FAB visible - Instruments tab: bottom sheet displayed - Map tab: returns from overlay, mapView visible - MOB FAB: always visible, visible on Safety tab - Record Track FAB: displayed, toggles to Stop Recording, toggles back MainActivity: moved isRecording observer to initializeUI() so the FAB content description updates without requiring GPS permission (needed for emulator tests that run without location permission). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: address simplify review findings TrackRepository.addPoint() now returns Boolean (true if point was added). MainViewModel.addGpsPoint() only updates _trackPoints StateFlow when a point is actually appended — eliminates ~3,600 no-op flow emissions per hour when not recording. MainActivity: loadedStyle promoted from nullable field to MutableStateFlow<Style?>; trackPoints observer uses filterNotNull + combine so no track points are silently dropped if the style loads after the first GPS fix. Smoke tests: replaced 11× ActivityScenario.launch().use{} with a single @get:Rule ActivityScenarioRule — same isolation, less boilerplate. CI: removed redundant app-debug artifact upload (app-debug.apk is already included inside the test-apks artifact). Removed stale/placeholder comments from MainActivity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ui): anchor record track FAB to instrument sheet to prevent it being hidden The FAB was anchored to bottom_navigation, placing it in the peek zone of the instrument bottom sheet (120dp) and making it invisible to users. Re-anchoring to instrument_bottom_sheet keeps the button visible and makes it track naturally with sheet drag gestures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(ui): anchor MOB FAB to instrument sheet to match record-track FAB The fab_mob was still anchored to bottom_navigation using the same top|start pattern that caused fab_record_track to be hidden behind the instrument sheet's 16dp elevation. Apply the same fix so the safety-critical MOB button is not occluded. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claudomator Agent <agent@claudomator> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Agent <agent@example.com> Co-authored-by: Claude Agent <agent@claude.ai>
Diffstat (limited to 'android-app')
-rw-r--r--android-app/app/build.gradle4
-rw-r--r--android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt102
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt10
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt24
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt36
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt134
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt10
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt216
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt81
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt137
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt94
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt12
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt178
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt16
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt24
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt88
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt58
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt3
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt20
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt39
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt69
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt35
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt19
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt27
-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/WindForecast.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt26
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt33
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt29
-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/main/res/drawable/ic_track_record.xml17
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml15
-rw-r--r--android-app/app/src/main/res/layout/fragment_anchor_watch.xml79
-rw-r--r--android-app/app/src/main/res/layout/fragment_map.xml14
-rwxr-xr-xandroid-app/app/src/main/res/values/strings.xml10
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt91
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt180
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt33
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt317
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt178
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt105
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt169
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt32
-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/LogbookEntryTest.kt67
-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
-rw-r--r--android-app/build.gradle1
56 files changed, 3052 insertions, 73 deletions
diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle
index 0c1a012..f6ad111 100644
--- a/android-app/app/build.gradle
+++ b/android-app/app/build.gradle
@@ -4,7 +4,7 @@ plugins {
id 'com.google.gms.google-services'
id 'com.google.firebase.appdistribution'
id 'com.google.firebase.crashlytics'
- id 'kotlin-kapt'
+ id 'com.google.devtools.ksp'
}
android {
@@ -94,7 +94,7 @@ dependencies {
// JSON parsing
implementation 'com.squareup.moshi:moshi-kotlin:1.15.0'
- kapt 'com.squareup.moshi:moshi-kotlin-codegen:1.15.0'
+ ksp 'com.squareup.moshi:moshi-kotlin-codegen:1.15.0'
// Location
implementation 'com.google.android.gms:play-services-location:21.2.0'
diff --git a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt
index 0824abe..fecd9cc 100644
--- a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt
+++ b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt
@@ -1,35 +1,113 @@
package org.terst.nav
-import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
- * Smoke test: verifies MainActivity launches without crashing.
+ * Smoke tests: verify the main UI surfaces launch and respond correctly.
+ * Run without GPS permission — LocationService is not started.
*
- * Run on an emulator/device via:
- * ./gradlew connectedDebugAndroidTest
- *
- * In CI, requires an emulator step before the Gradle task.
+ * Locally: ./gradlew connectedDebugAndroidTest
+ * CI: smoke-test job via android-emulator-runner
*/
@RunWith(AndroidJUnit4::class)
class MainActivitySmokeTest {
+ @get:Rule
+ val activityRule = ActivityScenarioRule(MainActivity::class.java)
+
@Before
fun setup() {
NavApplication.isTesting = true
}
+ // ── Launch ─────────────────────────────────────────────────────────────
+
@Test
fun mainActivity_launches_withoutCrash() {
- ActivityScenario.launch(MainActivity::class.java).use { scenario ->
- // If we reach this line the activity started without throwing.
- // onActivity lets us assert it is in a resumed state.
- scenario.onActivity { activity ->
- assert(!activity.isFinishing) { "MainActivity finished immediately after launch" }
- }
+ activityRule.scenario.onActivity { activity ->
+ assert(!activity.isFinishing) { "MainActivity finished immediately after launch" }
}
}
+
+ // ── Bottom nav ─────────────────────────────────────────────────────────
+
+ @Test
+ fun bottomNav_allFourTabs_areDisplayed() {
+ onView(withText("Map")).check(matches(isDisplayed()))
+ onView(withText("Instruments")).check(matches(isDisplayed()))
+ onView(withText("Log")).check(matches(isDisplayed()))
+ onView(withText("Safety")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun bottomNav_safetyTab_showsSafetyDashboard() {
+ onView(withText("Safety")).perform(click())
+ onView(withText("Safety Dashboard")).check(matches(isDisplayed()))
+ onView(withText("ACTIVATE MOB")).check(matches(isDisplayed()))
+ onView(withText("ANCHOR WATCH")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun bottomNav_logTab_showsVoiceLogUi() {
+ onView(withText("Log")).perform(click())
+ onView(withContentDescription("Start voice recognition")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun bottomNav_instrumentsTab_isSelectable() {
+ onView(withText("Instruments")).perform(click())
+ onView(withId(R.id.instrument_bottom_sheet)).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun bottomNav_mapTab_returnsFromOverlay() {
+ onView(withText("Safety")).perform(click())
+ onView(withText("Map")).perform(click())
+ onView(withId(R.id.mapView)).check(matches(isDisplayed()))
+ }
+
+ // ── Persistent FABs ────────────────────────────────────────────────────
+
+ @Test
+ fun fabMob_isAlwaysVisible() {
+ onView(withContentDescription("Man Overboard")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun fabMob_remainsVisibleOnSafetyTab() {
+ onView(withText("Safety")).perform(click())
+ onView(withContentDescription("Man Overboard")).check(matches(isDisplayed()))
+ }
+
+ // ── Track recording ────────────────────────────────────────────────────
+
+ @Test
+ fun fabRecordTrack_isDisplayedWithRecordDescription() {
+ onView(withContentDescription("Record Track")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun fabRecordTrack_togglesToStopRecording_onFirstClick() {
+ onView(withContentDescription("Record Track")).perform(click())
+ onView(withContentDescription("Stop Recording")).check(matches(isDisplayed()))
+ }
+
+ @Test
+ fun fabRecordTrack_togglesBackToRecord_onSecondClick() {
+ onView(withContentDescription("Record Track")).perform(click())
+ onView(withContentDescription("Stop Recording")).perform(click())
+ onView(withContentDescription("Record Track")).check(matches(isDisplayed()))
+ }
}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt
new file mode 100644
index 0000000..d427a5d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt
@@ -0,0 +1,10 @@
+package com.example.androidapp.data.model
+
+data class SensorData(
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+ val headingTrueDeg: Double? = null,
+ val apparentWindSpeedKt: Double? = null,
+ val apparentWindAngleDeg: Double? = null,
+ val speedOverGroundKt: Double? = null
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt
new file mode 100644
index 0000000..d6f685a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt
@@ -0,0 +1,24 @@
+package com.example.androidapp.data.storage
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+interface GribFileManager {
+ fun saveMetadata(file: GribFile)
+ fun listFiles(region: GribRegion): List<GribFile>
+ fun latestFile(region: GribRegion): GribFile?
+ fun delete(file: GribFile): Boolean
+ fun purgeOlderThan(before: Instant): Int
+ fun totalSizeBytes(): Long
+}
+
+class InMemoryGribFileManager : GribFileManager {
+ private val files = mutableListOf<GribFile>()
+ override fun saveMetadata(file: GribFile) { files.add(file) }
+ override fun listFiles(region: GribRegion): List<GribFile> = files.filter { it.region.name == region.name }.sortedByDescending { it.downloadedAt }
+ override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull()
+ override fun delete(file: GribFile): Boolean = files.remove(file)
+ override fun purgeOlderThan(before: Instant): Int { val toRemove = files.filter { it.downloadedAt.isBefore(before) }; files.removeAll(toRemove); return toRemove.size }
+ override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt
new file mode 100644
index 0000000..70f36d9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt
@@ -0,0 +1,36 @@
+package com.example.androidapp.data.weather
+
+import org.terst.nav.data.model.GribFile
+import com.example.androidapp.data.storage.GribFileManager
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+/** Outcome of a freshness check. */
+sealed class FreshnessResult {
+ /** Data is current; no user action needed. */
+ object Fresh : FreshnessResult()
+ /** Data is stale; user should re-download. [message] is shown in the UI badge. */
+ data class Stale(val file: GribFile, val message: String) : FreshnessResult()
+ /** No local GRIB data exists for this region. */
+ object NoData : FreshnessResult()
+}
+
+/**
+ * Checks whether locally-stored GRIB data for a region is fresh or stale.
+ * Per design doc §6.3: GRIB weather valid until model run + forecast hour; stale after.
+ */
+class GribStalenessChecker(private val manager: GribFileManager) {
+
+ /**
+ * Check freshness of the most-recent GRIB file for [region] relative to [now].
+ */
+ fun check(region: GribRegion, now: Instant = Instant.now()): FreshnessResult {
+ val latest = manager.latestFile(region) ?: return FreshnessResult.NoData
+ return if (latest.isStale(now)) {
+ val hoursAgo = (now.epochSecond - latest.validUntil().epochSecond) / 3600
+ FreshnessResult.Stale(latest, "Weather data outdated by ${hoursAgo}h — tap to refresh")
+ } else {
+ FreshnessResult.Fresh
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt
new file mode 100644
index 0000000..6e565b7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt
@@ -0,0 +1,134 @@
+package com.example.androidapp.data.weather
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribParameter
+import org.terst.nav.data.model.GribRegion
+import org.terst.nav.data.model.SatelliteDownloadRequest
+import com.example.androidapp.data.storage.GribFileManager
+import java.time.Instant
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/**
+ * Downloads GRIB weather data over bandwidth-constrained satellite links (§9.1).
+ *
+ * Provides size and time estimates before fetching, and aborts if the download
+ * would exceed the configured size limit (default 2 MB — the upper bound stated
+ * in §9.1 for typical offshore GRIBs on satellite).
+ *
+ * The actual network fetch is supplied as a [fetcher] lambda so the class remains
+ * testable without network access.
+ */
+class SatelliteGribDownloader(private val fileManager: GribFileManager) {
+
+ companion object {
+ /** Iridium data link speed in bits per second. */
+ const val SATELLITE_BANDWIDTH_BPS = 2400L
+
+ /** GRIB2 packed grid value: ~2 bytes per grid point after packing. */
+ private const val BYTES_PER_GRID_POINT = 2L
+
+ /** Per-message header overhead in GRIB2 format (section 0-4). */
+ private const val HEADER_BYTES_PER_MESSAGE = 100L
+
+ /** Forecast time step used for size estimation (3-hourly is standard GFS output). */
+ private const val TIME_STEP_HOURS = 3
+
+ /** Default maximum download size; abort if estimate exceeds this. */
+ const val DEFAULT_SIZE_LIMIT_BYTES = 2_000_000L
+ }
+
+ /**
+ * Estimates the GRIB file size in bytes for [request].
+ *
+ * Formula: (gridPoints × timeSteps × paramCount × bytesPerPoint) + headerOverhead
+ * where gridPoints = ceil(latSpan/resolution + 1) × ceil(lonSpan/resolution + 1).
+ */
+ fun estimateSizeBytes(request: SatelliteDownloadRequest): Long {
+ val latPoints = floor((request.region.latMax - request.region.latMin) / request.resolutionDeg).toLong() + 1
+ val lonPoints = floor((request.region.lonMax - request.region.lonMin) / request.resolutionDeg).toLong() + 1
+ val gridPoints = latPoints * lonPoints
+ val timeSteps = ceil(request.forecastHours.toDouble() / TIME_STEP_HOURS).toLong()
+ val paramCount = request.parameters.size.toLong()
+ val dataBytes = gridPoints * timeSteps * paramCount * BYTES_PER_GRID_POINT
+ val headerBytes = paramCount * timeSteps * HEADER_BYTES_PER_MESSAGE
+ return dataBytes + headerBytes
+ }
+
+ /**
+ * Estimates how many seconds the download will take at [bandwidthBps] bits/second.
+ */
+ fun estimatedDownloadSeconds(
+ request: SatelliteDownloadRequest,
+ bandwidthBps: Long = SATELLITE_BANDWIDTH_BPS
+ ): Long = ceil(estimateSizeBytes(request) * 8.0 / bandwidthBps).toLong()
+
+ /**
+ * Convenience builder: creates a [SatelliteDownloadRequest] using the minimal
+ * satellite parameter set (wind speed + direction + surface pressure only).
+ */
+ fun buildMinimalRequest(
+ region: GribRegion,
+ forecastHours: Int,
+ resolutionDeg: Double = 1.0
+ ): SatelliteDownloadRequest = SatelliteDownloadRequest(
+ region = region,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = forecastHours,
+ resolutionDeg = resolutionDeg
+ )
+
+ /** Result of a satellite GRIB download attempt. */
+ sealed class DownloadResult {
+ /** Download succeeded; [file] metadata has been saved to [GribFileManager]. */
+ data class Success(val file: GribFile) : DownloadResult()
+ /** The [fetcher] returned no data or an unexpected error occurred. */
+ data class Failed(val reason: String) : DownloadResult()
+ /**
+ * Download was aborted before starting because the estimated size
+ * [estimatedBytes] exceeds the configured limit.
+ */
+ data class Aborted(val reason: String, val estimatedBytes: Long) : DownloadResult()
+ }
+
+ /**
+ * Downloads GRIB data for [request].
+ *
+ * 1. Estimates size; returns [DownloadResult.Aborted] if > [sizeLimitBytes].
+ * 2. Calls [fetcher] to retrieve raw bytes.
+ * 3. On success, saves metadata via [fileManager] and returns [DownloadResult.Success].
+ *
+ * @param request The bandwidth-optimised download request.
+ * @param fetcher Supplies raw GRIB bytes for the request; returns null on failure.
+ * @param outputPath Local file path where the caller will persist the bytes.
+ * @param sizeLimitBytes Abort threshold (default [DEFAULT_SIZE_LIMIT_BYTES]).
+ * @param now Timestamp injected for testing.
+ */
+ fun download(
+ request: SatelliteDownloadRequest,
+ fetcher: (SatelliteDownloadRequest) -> ByteArray?,
+ outputPath: String,
+ sizeLimitBytes: Long = DEFAULT_SIZE_LIMIT_BYTES,
+ now: Instant = Instant.now()
+ ): DownloadResult {
+ val estimated = estimateSizeBytes(request)
+ if (estimated > sizeLimitBytes) {
+ return DownloadResult.Aborted(
+ "Estimated size ${estimated / 1024}KB exceeds limit ${sizeLimitBytes / 1024}KB — " +
+ "reduce region, resolution, or forecast hours",
+ estimated
+ )
+ }
+ val bytes = fetcher(request) ?: return DownloadResult.Failed("Fetcher returned no data")
+ val gribFile = GribFile(
+ region = request.region,
+ modelRunTime = now,
+ forecastHours = request.forecastHours,
+ downloadedAt = now,
+ filePath = outputPath,
+ sizeBytes = bytes.size.toLong()
+ )
+ fileManager.saveMetadata(gribFile)
+ return DownloadResult.Success(gribFile)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt
new file mode 100644
index 0000000..cbe5c84
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt
@@ -0,0 +1,10 @@
+package com.example.androidapp.gps
+
+data class GpsPosition(
+ val latitude: Double, // degrees, positive = North
+ val longitude: Double, // degrees, positive = East
+ val sog: Double, // Speed Over Ground in knots
+ val cog: Double, // Course Over Ground in degrees true (0-360)
+ val timestampMs: Long, // Unix millis UTC
+ val accuracyMeters: Double? = null // estimated horizontal accuracy (1-sigma); null = unknown
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
new file mode 100644
index 0000000..0a315d4
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
@@ -0,0 +1,216 @@
+package com.example.androidapp.gps
+
+import com.example.androidapp.data.model.SensorData
+import com.example.androidapp.wind.TrueWindCalculator
+import com.example.androidapp.wind.ApparentWind
+import com.example.androidapp.wind.TrueWindData
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Source of the currently active GPS fix. */
+enum class GpsSource { NONE, NMEA, ANDROID }
+
+/**
+ * Aggregates real-time location and environmental sensor data for use throughout
+ * the safety subsystem (Section 4.6 of COMPONENT_DESIGN.md).
+ *
+ * ## GPS sensor fusion
+ * The service accepts fixes from two independent sources:
+ * - **NMEA GPS** — dedicated marine GPS received via [updateNmeaGps] (higher priority)
+ * - **Android GPS** — device built-in location via [updateAndroidGps] (fallback)
+ *
+ * Selection policy (evaluated on every new fix):
+ * 1. Prefer NMEA when its most recent fix is no older than [nmeaStalenessThresholdMs].
+ * 2. When NMEA is marginally stale (older than [nmeaStalenessThresholdMs] but within
+ * [nmeaExtendedThresholdMs]) **and** Android GPS is also available, compare
+ * [GpsPosition.accuracyMeters]: keep NMEA if its reported accuracy is strictly better
+ * (lower metres). Fall back to Android when accuracy is unavailable or Android wins.
+ * 3. Fall back to Android GPS when NMEA is very stale (beyond [nmeaExtendedThresholdMs]).
+ * 4. Use stale NMEA only when Android GPS has never provided a fix.
+ * 5. [bestPosition] is null until at least one source has reported.
+ *
+ * Call [updateSensorData] whenever new NMEA or Signal K sensor data arrives and
+ * [updateCurrentConditions] when a fresh marine-forecast response is received.
+ * Use [snapshot] to capture a point-in-time reading at safety-critical moments
+ * such as MOB activation.
+ *
+ * @param nmeaStalenessThresholdMs Maximum age (ms) of an NMEA fix before it enters the
+ * quality-comparison zone. Default: 5 000 ms.
+ * @param nmeaExtendedThresholdMs Maximum age (ms) up to which a marginally-stale NMEA fix
+ * can still win over Android if its [GpsPosition.accuracyMeters] is strictly better.
+ * Must be ≥ [nmeaStalenessThresholdMs]. Default: 10 000 ms.
+ * @param clockMs Injectable clock for unit-testable staleness checks.
+ */
+class LocationService(
+ private val windCalculator: TrueWindCalculator = TrueWindCalculator(),
+ private val nmeaStalenessThresholdMs: Long = 5_000L,
+ private val nmeaExtendedThresholdMs: Long = 10_000L,
+ private val clockMs: () -> Long = System::currentTimeMillis
+) {
+
+ private val _latestSensor = MutableStateFlow<SensorData?>(null)
+ /** The most recently received unified sensor reading. */
+ val latestSensor: StateFlow<SensorData?> = _latestSensor.asStateFlow()
+
+ private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null)
+ /** Most recent resolved true-wind vector, updated whenever a full sensor reading arrives. */
+ val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow()
+
+ private val _currentSpeedKt = MutableStateFlow<Double?>(null)
+ private val _currentDirectionDeg = MutableStateFlow<Double?>(null)
+
+ // ── GPS sensor fusion state ───────────────────────────────────────────────
+
+ private var lastNmeaPosition: GpsPosition? = null
+ private var lastAndroidPosition: GpsPosition? = null
+
+ private val _bestPosition = MutableStateFlow<GpsPosition?>(null)
+ /**
+ * The best available GPS fix, selected from NMEA and Android sources according
+ * to the fusion policy described in the class KDoc. Null until at least one
+ * source reports a fix.
+ */
+ val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow()
+
+ private val _activeGpsSource = MutableStateFlow(GpsSource.NONE)
+ /** The source that produced [bestPosition]. [GpsSource.NONE] before any fix arrives. */
+ val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow()
+
+ /**
+ * Ingest a new sensor reading. If the reading carries apparent wind, boat speed,
+ * and heading, true wind is resolved immediately via [TrueWindCalculator] and
+ * stored in [latestTrueWind].
+ */
+ fun updateSensorData(data: SensorData) {
+ _latestSensor.value = data
+
+ val aws = data.apparentWindSpeedKt
+ val awa = data.apparentWindAngleDeg
+ val bsp = data.speedOverGroundKt // use SOG as proxy when BSP is absent
+ val hdg = data.headingTrueDeg
+
+ if (aws != null && awa != null && bsp != null && hdg != null) {
+ _latestTrueWind.value = windCalculator.update(
+ apparent = ApparentWind(speedKt = aws, angleDeg = awa),
+ bsp = bsp,
+ hdgDeg = hdg
+ )
+ }
+ }
+
+ // ── GPS source ingestion ──────────────────────────────────────────────────
+
+ /**
+ * Ingest a new GPS fix from the NMEA source (e.g. a marine chartplotter or
+ * NMEA multiplexer). Triggers a fusion re-evaluation.
+ */
+ fun updateNmeaGps(position: GpsPosition) {
+ lastNmeaPosition = position
+ recomputeBestPosition()
+ }
+
+ /**
+ * Ingest a new GPS fix from the Android system location provider.
+ * Triggers a fusion re-evaluation.
+ */
+ fun updateAndroidGps(position: GpsPosition) {
+ lastAndroidPosition = position
+ recomputeBestPosition()
+ }
+
+ /**
+ * Selects the best GPS fix and updates [bestPosition] / [activeGpsSource].
+ *
+ * Priority tiers (in order):
+ * 1. Fresh NMEA (age ≤ [nmeaStalenessThresholdMs]) — always preferred.
+ * 2. Marginally-stale NMEA (age in (primary, extended] threshold) when Android is
+ * also available — keep NMEA only if its [GpsPosition.accuracyMeters] is strictly
+ * better than Android's; otherwise use Android.
+ * 3. Android GPS (any age) once NMEA is beyond the extended threshold.
+ * 4. Stale NMEA — used as last resort when Android has never reported.
+ */
+ private fun recomputeBestPosition() {
+ val now = clockMs()
+ val nmea = lastNmeaPosition
+ val android = lastAndroidPosition
+
+ val nmeaAge = nmea?.let { now - it.timestampMs }
+ val nmeaFresh = nmeaAge != null && nmeaAge <= nmeaStalenessThresholdMs
+ val nmeaMarginallyStale = nmeaAge != null &&
+ nmeaAge > nmeaStalenessThresholdMs &&
+ nmeaAge <= nmeaExtendedThresholdMs
+
+ val (best, source) = when {
+ nmeaFresh -> nmea!! to GpsSource.NMEA
+
+ nmeaMarginallyStale && android != null ->
+ // Quality tie-break: NMEA wins only when it has a strictly better accuracy.
+ if (nmea!!.hasStrictlyBetterAccuracyThan(android)) nmea to GpsSource.NMEA
+ else android to GpsSource.ANDROID
+
+ android != null -> android to GpsSource.ANDROID
+ nmea != null -> nmea to GpsSource.NMEA // only source, however stale
+ else -> null to GpsSource.NONE
+ }
+
+ _bestPosition.value = best
+ _activeGpsSource.value = source
+ }
+
+ // ── private helpers ───────────────────────────────────────────────────────
+
+ /**
+ * Returns true when this fix carries an accuracy estimate that is numerically
+ * smaller (i.e. better) than [other]'s. Returns false when either estimate is
+ * absent — conservatively preferring the other source when quality is unknown.
+ */
+ private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean {
+ val thisAccuracy = accuracyMeters ?: return false
+ val otherAccuracy = other.accuracyMeters ?: return true
+ return thisAccuracy < otherAccuracy
+ }
+
+ /**
+ * Update the ocean current conditions from the latest marine-forecast response.
+ *
+ * @param speedKt Current speed in knots (null to clear)
+ * @param directionDeg Direction the current flows TOWARD, in degrees (null to clear)
+ */
+ fun updateCurrentConditions(speedKt: Double?, directionDeg: Double?) {
+ _currentSpeedKt.value = speedKt
+ _currentDirectionDeg.value = directionDeg
+ }
+
+ /**
+ * Captures a snapshot of wind and current conditions at the current moment.
+ *
+ * All fields are nullable — only data that was available at snapshot time is
+ * populated. This snapshot is intended to be logged alongside a [MobEvent]
+ * at the instant of MOB activation.
+ */
+ fun snapshot(): EnvironmentalSnapshot {
+ val trueWind = _latestTrueWind.value
+ return EnvironmentalSnapshot(
+ windSpeedKt = trueWind?.speedKt,
+ windDirectionDeg = trueWind?.directionDeg,
+ currentSpeedKt = _currentSpeedKt.value,
+ currentDirectionDeg = _currentDirectionDeg.value
+ )
+ }
+}
+
+/**
+ * Point-in-time snapshot of wind and current conditions.
+ *
+ * @param windSpeedKt True Wind Speed in knots; null if sensors were unavailable.
+ * @param windDirectionDeg True Wind Direction (degrees true, wind comes FROM); null if unavailable.
+ * @param currentSpeedKt Ocean current speed in knots; null if forecast was unavailable.
+ * @param currentDirectionDeg Ocean current direction (degrees, flows TOWARD); null if unavailable.
+ */
+data class EnvironmentalSnapshot(
+ val windSpeedKt: Double?,
+ val windDirectionDeg: Double?,
+ val currentSpeedKt: Double?,
+ val currentDirectionDeg: Double?
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
new file mode 100644
index 0000000..d4cf50d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
@@ -0,0 +1,81 @@
+package com.example.androidapp.logbook
+
+import org.terst.nav.data.model.LogbookEntry
+import java.util.Calendar
+import java.util.TimeZone
+
+data class LogbookRow(
+ val time: String,
+ val position: String,
+ val sog: String,
+ val cog: String,
+ val wind: String,
+ val baro: String,
+ val depth: String,
+ val eventNotes: String
+)
+
+data class LogbookPage(
+ val title: String,
+ val columns: List<String>,
+ val rows: List<LogbookRow>
+)
+
+object LogbookFormatter {
+
+ val COLUMNS = listOf(
+ "Time (UTC)", "Position", "SOG", "COG", "Wind", "Baro", "Depth", "Event / Notes"
+ )
+
+ private val COMPASS_POINTS = arrayOf(
+ "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
+ )
+
+ fun formatTime(timestampMs: Long): String {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.timeInMillis = timestampMs
+ return "%02d:%02d".format(
+ cal.get(Calendar.HOUR_OF_DAY),
+ cal.get(Calendar.MINUTE)
+ )
+ }
+
+ fun formatPosition(lat: Double, lon: Double): String {
+ val latDir = if (lat >= 0) "N" else "S"
+ val lonDir = if (lon >= 0) "E" else "W"
+ val absLat = Math.abs(lat)
+ val absLon = Math.abs(lon)
+ val latDeg = absLat.toInt()
+ val lonDeg = absLon.toInt()
+ val latMin = (absLat - latDeg) * 60.0
+ val lonMin = (absLon - lonDeg) * 60.0
+ return "%d°%.1f%s %d°%.1f%s".format(latDeg, latMin, latDir, lonDeg, lonMin, lonDir)
+ }
+
+ fun toCompassPoint(degrees: Double): String {
+ val normalized = ((degrees % 360.0) + 360.0) % 360.0
+ val index = ((normalized + 11.25) / 22.5).toInt() % 16
+ return COMPASS_POINTS[index]
+ }
+
+ fun formatWind(knots: Double?, directionDeg: Double?): String {
+ if (knots == null) return ""
+ val knotsStr = "%.0fkt".format(knots)
+ return if (directionDeg == null) knotsStr else "$knotsStr ${toCompassPoint(directionDeg)}"
+ }
+
+ fun toRow(entry: LogbookEntry): LogbookRow = LogbookRow(
+ time = formatTime(entry.timestampMs),
+ position = formatPosition(entry.lat, entry.lon),
+ sog = "%.1f".format(entry.sogKnots),
+ cog = "%.0f".format(entry.cogDegrees),
+ wind = formatWind(entry.windKnots, entry.windDirectionDeg),
+ baro = entry.baroHpa?.let { "%.0f".format(it) } ?: "",
+ depth = entry.depthMeters?.let { "%.0fm".format(it) } ?: "",
+ eventNotes = listOfNotNull(entry.event, entry.notes).joinToString(": ")
+ )
+
+ fun toPage(entries: List<LogbookEntry>, title: String = "Trip Logbook"): LogbookPage =
+ LogbookPage(title = title, columns = COLUMNS, rows = entries.map { toRow(it) })
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt
new file mode 100644
index 0000000..78ea834
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt
@@ -0,0 +1,137 @@
+package com.example.androidapp.logbook
+
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.graphics.pdf.PdfDocument
+import org.terst.nav.data.model.LogbookEntry
+import java.io.OutputStream
+
+/**
+ * Renders trip logbook entries to a formatted PDF (landscape A4).
+ * Section 4.8 — Trip Logging and Electronic Logbook.
+ */
+object LogbookPdfExporter {
+
+ // Landscape A4 in points (1 point = 1/72 inch)
+ private const val PAGE_WIDTH = 842
+ private const val PAGE_HEIGHT = 595
+ private const val MARGIN = 36f
+ private const val ROW_HEIGHT = 22f
+ private const val HEADER_HEIGHT = 36f
+ private const val TITLE_SIZE = 16f
+ private const val CELL_TEXT_SIZE = 9f
+
+ // Column width fractions (must sum to 1.0)
+ private val COL_FRACTIONS = floatArrayOf(
+ 0.08f, // Time
+ 0.18f, // Position
+ 0.06f, // SOG
+ 0.06f, // COG
+ 0.10f, // Wind
+ 0.07f, // Baro
+ 0.07f, // Depth
+ 0.38f // Event / Notes
+ )
+
+ fun export(
+ entries: List<LogbookEntry>,
+ outputStream: OutputStream,
+ title: String = "Trip Logbook"
+ ) {
+ val page = LogbookFormatter.toPage(entries, title)
+ val document = PdfDocument()
+ try {
+ val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create()
+ val pdfPage = document.startPage(pageInfo)
+ drawPage(pdfPage.canvas, page)
+ document.finishPage(pdfPage)
+ document.writeTo(outputStream)
+ } finally {
+ document.close()
+ }
+ }
+
+ private fun drawPage(canvas: Canvas, page: LogbookPage) {
+ val usableWidth = PAGE_WIDTH - 2 * MARGIN
+ val colWidths = COL_FRACTIONS.map { it * usableWidth }
+
+ val titlePaint = Paint().apply {
+ textSize = TITLE_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.BLACK
+ }
+ val headerTextPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.WHITE
+ }
+ val cellPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ color = Color.BLACK
+ }
+ val linePaint = Paint().apply {
+ color = Color.LTGRAY
+ strokeWidth = 0.5f
+ }
+ val headerBgPaint = Paint().apply {
+ color = Color.rgb(41, 82, 123)
+ style = Paint.Style.FILL
+ }
+ val altBgPaint = Paint().apply {
+ color = Color.rgb(235, 242, 252)
+ style = Paint.Style.FILL
+ }
+ val borderPaint = Paint().apply {
+ color = Color.DKGRAY
+ strokeWidth = 1f
+ style = Paint.Style.STROKE
+ }
+
+ var y = MARGIN
+
+ // Title
+ canvas.drawText(page.title, MARGIN, y + TITLE_SIZE, titlePaint)
+ y += HEADER_HEIGHT
+
+ val tableTop = y
+
+ // Column header background
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, headerBgPaint)
+
+ // Column header text
+ var x = MARGIN + 3f
+ page.columns.forEachIndexed { i, col ->
+ canvas.drawText(col, x, y + ROW_HEIGHT - 6f, headerTextPaint)
+ x += colWidths[i]
+ }
+ y += ROW_HEIGHT
+
+ // Data rows
+ page.rows.forEach { row ->
+ if (y + ROW_HEIGHT > PAGE_HEIGHT - MARGIN) return@forEach
+
+ if (page.rows.indexOf(row) % 2 == 1) {
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, altBgPaint)
+ }
+
+ val cells = listOf(
+ row.time, row.position, row.sog, row.cog,
+ row.wind, row.baro, row.depth, row.eventNotes
+ )
+ x = MARGIN + 3f
+ cells.forEachIndexed { i, cell ->
+ val maxChars = (colWidths[i] / (CELL_TEXT_SIZE * 0.55)).toInt().coerceAtLeast(4)
+ canvas.drawText(cell.take(maxChars), x, y + ROW_HEIGHT - 6f, cellPaint)
+ x += colWidths[i]
+ }
+
+ canvas.drawLine(MARGIN, y + ROW_HEIGHT, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, linePaint)
+ y += ROW_HEIGHT
+ }
+
+ // Table border
+ canvas.drawRect(MARGIN, tableTop, PAGE_WIDTH - MARGIN, y, borderPaint)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
new file mode 100644
index 0000000..b1b186a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
@@ -0,0 +1,94 @@
+package com.example.androidapp.nmea
+
+import com.example.androidapp.gps.GpsPosition
+import java.util.Calendar
+import java.util.TimeZone
+
+class NmeaParser {
+
+ /**
+ * Parses an NMEA RMC sentence and returns a [GpsPosition], or null if the
+ * sentence is void (status=V), malformed, or cannot be parsed.
+ *
+ * Supported talker IDs: GP, GN, and any other standard prefix.
+ * SOG and COG default to 0.0 when the fields are absent.
+ */
+ fun parseRmc(sentence: String): GpsPosition? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 10) return null
+
+ if (!fields[0].endsWith("RMC")) return null
+ if (fields[2] != "A") return null
+
+ val latStr = fields.getOrNull(3) ?: return null
+ val latDir = fields.getOrNull(4) ?: return null
+ val lonStr = fields.getOrNull(5) ?: return null
+ val lonDir = fields.getOrNull(6) ?: return null
+
+ val latitude = parseNmeaDegrees(latStr) * if (latDir == "S") -1.0 else 1.0
+ val longitude = parseNmeaDegrees(lonStr) * if (lonDir == "W") -1.0 else 1.0
+
+ val sog = fields.getOrNull(7)?.toDoubleOrNull() ?: 0.0
+ val cog = fields.getOrNull(8)?.toDoubleOrNull() ?: 0.0
+
+ val timestampMs = parseTimestamp(
+ timeStr = fields.getOrNull(1) ?: "",
+ dateStr = fields.getOrNull(9) ?: ""
+ )
+ if (timestampMs == 0L) return null
+
+ return GpsPosition(latitude, longitude, sog, cog, timestampMs)
+ }
+
+ /**
+ * Converts NMEA degree-minutes format (DDDMM.MMMM) to decimal degrees.
+ */
+ private fun parseNmeaDegrees(value: String): Double {
+ val raw = value.toDoubleOrNull() ?: return 0.0
+ val degrees = (raw / 100.0).toInt()
+ val minutes = raw - degrees * 100.0
+ return degrees + minutes / 60.0
+ }
+
+ /**
+ * Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into Unix epoch millis UTC.
+ * Returns 0 on any parse failure.
+ */
+ private fun parseTimestamp(timeStr: String, dateStr: String): Long {
+ return try {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.isLenient = false
+
+ if (dateStr.length >= 6) {
+ val day = dateStr.substring(0, 2).toInt()
+ val month = dateStr.substring(2, 4).toInt() - 1
+ val yy = dateStr.substring(4, 6).toInt()
+ val year = if (yy < 70) 2000 + yy else 1900 + yy
+ cal.set(Calendar.YEAR, year)
+ cal.set(Calendar.MONTH, month)
+ cal.set(Calendar.DAY_OF_MONTH, day)
+ }
+
+ if (timeStr.length >= 6) {
+ val hours = timeStr.substring(0, 2).toInt()
+ val minutes = timeStr.substring(2, 4).toInt()
+ val seconds = timeStr.substring(4, 6).toInt()
+ val millis = if (timeStr.length > 7) {
+ val fracStr = timeStr.substring(7)
+ (("0.$fracStr").toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
+ } else 0
+ cal.set(Calendar.HOUR_OF_DAY, hours)
+ cal.set(Calendar.MINUTE, minutes)
+ cal.set(Calendar.SECOND, seconds)
+ cal.set(Calendar.MILLISECOND, millis)
+ }
+
+ cal.timeInMillis
+ } catch (e: Exception) {
+ 0L
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt
new file mode 100644
index 0000000..60a5918
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt
@@ -0,0 +1,12 @@
+package com.example.androidapp.routing
+
+/**
+ * The result of an isochrone weather routing computation.
+ *
+ * @param path Ordered list of [RoutePoint]s from the start to the destination.
+ * @param etaMs Estimated Time of Arrival as a UNIX timestamp in milliseconds.
+ */
+data class IsochroneResult(
+ val path: List<RoutePoint>,
+ val etaMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt
new file mode 100644
index 0000000..901fdbc
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt
@@ -0,0 +1,178 @@
+package com.example.androidapp.routing
+
+import org.terst.nav.data.model.BoatPolars
+import org.terst.nav.data.model.WindForecast
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.pow
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Isochrone-based weather routing engine (Section 3.4).
+ *
+ * Algorithm:
+ * 1. Start from a single point; expand a fan of headings at each time step.
+ * 2. For each candidate heading, compute BSP from [BoatPolars] at the local forecast wind.
+ * 3. Advance position by BSP × Δt using the spherical-Earth destination-point formula.
+ * 4. Check whether the destination has been reached (within [arrivalRadiusM]).
+ * 5. Prune candidates: for each angular sector around the start, keep only the point that
+ * advanced furthest (removes dominated points).
+ * 6. Repeat until the destination is reached or [maxSteps] is exhausted.
+ * 7. Backtrace parent pointers to produce the optimal path.
+ */
+object IsochroneRouter {
+
+ private const val EARTH_RADIUS_M = 6_371_000.0
+ internal const val NM_TO_M = 1_852.0
+ private const val KT_TO_M_PER_S = NM_TO_M / 3600.0
+
+ const val DEFAULT_HEADING_STEP_DEG = 5.0
+ const val DEFAULT_ARRIVAL_RADIUS_M = 1_852.0 // 1 NM
+ const val DEFAULT_PRUNE_SECTORS = 72 // 5° sectors
+ const val DEFAULT_MAX_STEPS = 200
+
+ /**
+ * Compute an optimised route from start to destination.
+ *
+ * @param startLat Start latitude (decimal degrees).
+ * @param startLon Start longitude (decimal degrees).
+ * @param destLat Destination latitude (decimal degrees).
+ * @param destLon Destination longitude (decimal degrees).
+ * @param startTimeMs Departure time as UNIX timestamp (ms).
+ * @param stepMs Time increment per isochrone step (ms). Typical: 1–3 hours.
+ * @param polars Boat polar table.
+ * @param windAt Function returning [WindForecast] for a given position and time.
+ * @param headingStepDeg Angular resolution of the heading fan (degrees). Default 5°.
+ * @param arrivalRadiusM Distance threshold to consider destination reached (metres).
+ * @param maxSteps Maximum number of isochrone expansions before giving up.
+ * @return [IsochroneResult] with the optimal path and ETA, or null if unreachable.
+ */
+ fun route(
+ startLat: Double,
+ startLon: Double,
+ destLat: Double,
+ destLon: Double,
+ startTimeMs: Long,
+ stepMs: Long,
+ polars: BoatPolars,
+ windAt: (lat: Double, lon: Double, timeMs: Long) -> WindForecast,
+ headingStepDeg: Double = DEFAULT_HEADING_STEP_DEG,
+ arrivalRadiusM: Double = DEFAULT_ARRIVAL_RADIUS_M,
+ maxSteps: Int = DEFAULT_MAX_STEPS
+ ): IsochroneResult? {
+ val start = RoutePoint(startLat, startLon, startTimeMs)
+ var isochrone = listOf(start)
+
+ repeat(maxSteps) { step ->
+ val nextTimeMs = startTimeMs + (step + 1).toLong() * stepMs
+ val candidates = mutableListOf<RoutePoint>()
+
+ for (point in isochrone) {
+ var heading = 0.0
+ while (heading < 360.0) {
+ val wind = windAt(point.lat, point.lon, point.timestampMs)
+ val twa = ((heading - wind.twdDeg + 360.0) % 360.0)
+ val bspKt = polars.bsp(twa, wind.twsKt)
+ if (bspKt > 0.0) {
+ val distM = bspKt * KT_TO_M_PER_S * (stepMs / 1000.0)
+ val (newLat, newLon) = destinationPoint(point.lat, point.lon, heading, distM)
+ val newPoint = RoutePoint(newLat, newLon, nextTimeMs, parent = point)
+
+ if (haversineM(newLat, newLon, destLat, destLon) <= arrivalRadiusM) {
+ return IsochroneResult(
+ path = backtrace(newPoint),
+ etaMs = nextTimeMs
+ )
+ }
+ candidates.add(newPoint)
+ }
+ heading += headingStepDeg
+ }
+ }
+
+ if (candidates.isEmpty()) return null
+ isochrone = prune(candidates, startLat, startLon, DEFAULT_PRUNE_SECTORS)
+ }
+
+ return null
+ }
+
+ /** Walk parent pointers from destination back to start, then reverse. */
+ internal fun backtrace(dest: RoutePoint): List<RoutePoint> {
+ val path = mutableListOf<RoutePoint>()
+ var current: RoutePoint? = dest
+ while (current != null) {
+ path.add(current)
+ current = current.parent
+ }
+ path.reverse()
+ return path
+ }
+
+ /**
+ * Angular-sector pruning: divide the plane into [sectors] equal angular sectors around the
+ * start. Within each sector keep only the candidate that is furthest from the start.
+ */
+ internal fun prune(
+ candidates: List<RoutePoint>,
+ startLat: Double,
+ startLon: Double,
+ sectors: Int
+ ): List<RoutePoint> {
+ val sectorSize = 360.0 / sectors
+ val best = mutableMapOf<Int, RoutePoint>()
+
+ for (point in candidates) {
+ val bearing = bearingDeg(startLat, startLon, point.lat, point.lon)
+ val sector = (bearing / sectorSize).toInt().coerceIn(0, sectors - 1)
+ val existing = best[sector]
+ if (existing == null ||
+ haversineM(startLat, startLon, point.lat, point.lon) >
+ haversineM(startLat, startLon, existing.lat, existing.lon)
+ ) {
+ best[sector] = point
+ }
+ }
+
+ return best.values.toList()
+ }
+
+ /** Haversine great-circle distance in metres. */
+ internal fun haversineM(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2).pow(2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2)
+ return 2.0 * EARTH_RADIUS_M * asin(sqrt(a))
+ }
+
+ /** Initial bearing from point 1 to point 2 (degrees, 0 = North, clockwise). */
+ internal fun bearingDeg(lat1Deg: Double, lon1Deg: Double, lat2Deg: Double, lon2Deg: Double): Double {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lat2 = Math.toRadians(lat2Deg)
+ val dLon = Math.toRadians(lon2Deg - lon1Deg)
+ val y = sin(dLon) * cos(lat2)
+ val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
+ return (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0
+ }
+
+ /** Spherical-Earth destination-point given start, bearing, and distance. */
+ internal fun destinationPoint(
+ lat1Deg: Double,
+ lon1Deg: Double,
+ bearingDeg: Double,
+ distM: Double
+ ): Pair<Double, Double> {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lon1 = Math.toRadians(lon1Deg)
+ val brng = Math.toRadians(bearingDeg)
+ val d = distM / EARTH_RADIUS_M
+
+ val lat2 = asin(sin(lat1) * cos(d) + cos(lat1) * sin(d) * cos(brng))
+ val lon2 = lon1 + atan2(sin(brng) * sin(d) * cos(lat1), cos(d) - sin(lat1) * sin(lat2))
+
+ return Pair(Math.toDegrees(lat2), Math.toDegrees(lon2))
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt
new file mode 100644
index 0000000..02988d1
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt
@@ -0,0 +1,16 @@
+package com.example.androidapp.routing
+
+/**
+ * A single point in the isochrone routing tree.
+ *
+ * @param lat Latitude (decimal degrees).
+ * @param lon Longitude (decimal degrees).
+ * @param timestampMs UNIX time in milliseconds when this position is reached.
+ * @param parent The previous [RoutePoint] (null for the start point).
+ */
+data class RoutePoint(
+ val lat: Double,
+ val lon: Double,
+ val timestampMs: Long,
+ val parent: RoutePoint? = null
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt
new file mode 100644
index 0000000..f544f63
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt
@@ -0,0 +1,24 @@
+package com.example.androidapp.safety
+
+import kotlin.math.sqrt
+
+/**
+ * Holds UI-facing state for the anchor watch setup screen and provides
+ * the suggested watch-circle radius derived from depth and rode out.
+ */
+class AnchorWatchState {
+
+ /**
+ * Returns the recommended watch-circle radius (metres) for the given depth
+ * and amount of rode deployed.
+ *
+ * Uses the Pythagorean formula sqrt(rode² - vertical²) when the geometry is
+ * valid (rode > depth + freeboard). Falls back to [rodeOutM] itself as the
+ * maximum possible swing radius when the rode is too short to form a catenary angle.
+ */
+ fun calculateRecommendedWatchCircleRadius(depthM: Double, rodeOutM: Double): Double {
+ val vertical = depthM + 2.0 // 2 m default freeboard
+ return if (rodeOutM > vertical) sqrt(rodeOutM * rodeOutM - vertical * vertical)
+ else rodeOutM
+ }
+}
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/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt
new file mode 100644
index 0000000..289a857
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt
@@ -0,0 +1,58 @@
+package com.example.androidapp.ui.anchorwatch
+
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import org.terst.nav.R
+import org.terst.nav.databinding.FragmentAnchorWatchBinding
+import com.example.androidapp.safety.AnchorWatchState
+
+class AnchorWatchHandler : Fragment() {
+
+ private var _binding: FragmentAnchorWatchBinding? = null
+ private val binding get() = _binding!!
+
+ private val anchorWatchState = AnchorWatchState()
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnchorWatchBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val watcher = object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
+ override fun afterTextChanged(s: Editable?) = updateSuggestedRadius()
+ }
+ binding.etDepth.addTextChangedListener(watcher)
+ binding.etRodeOut.addTextChangedListener(watcher)
+ }
+
+ private fun updateSuggestedRadius() {
+ val depth = binding.etDepth.text.toString().toDoubleOrNull()
+ val rode = binding.etRodeOut.text.toString().toDoubleOrNull()
+
+ if (depth != null && rode != null && depth >= 0.0 && rode > 0.0) {
+ val radius = anchorWatchState.calculateRecommendedWatchCircleRadius(depth, rode)
+ binding.tvSuggestedRadius.text =
+ getString(R.string.anchor_suggested_radius_fmt, radius)
+ } else {
+ binding.tvSuggestedRadius.text = getString(R.string.anchor_suggested_radius_empty)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt
new file mode 100644
index 0000000..01656a3
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt
@@ -0,0 +1,3 @@
+package com.example.androidapp.wind
+
+data class ApparentWind(val speedKt: Double, val angleDeg: Double)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt
new file mode 100644
index 0000000..db32163
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt
@@ -0,0 +1,20 @@
+package com.example.androidapp.wind
+
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+class TrueWindCalculator {
+ fun update(apparent: ApparentWind, bsp: Double, hdgDeg: Double): TrueWindData {
+ val awaRad = Math.toRadians(apparent.angleDeg)
+ val awX = apparent.speedKt * cos(awaRad)
+ val awY = apparent.speedKt * sin(awaRad)
+ val twX = awX - bsp
+ val twY = awY
+ val tws = sqrt(twX * twX + twY * twY)
+ val twaDeg = Math.toDegrees(atan2(twY, twX))
+ val twdDeg = ((hdgDeg + twaDeg) % 360 + 360) % 360
+ return TrueWindData(speedKt = tws, directionDeg = twdDeg)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt
new file mode 100644
index 0000000..78e9558
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt
@@ -0,0 +1,3 @@
+package com.example.androidapp.wind
+
+data class TrueWindData(val speedKt: Double, val directionDeg: Double)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index d9dba73..f9d4dbd 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -22,7 +22,10 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -45,9 +48,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private var instrumentHandler: InstrumentHandler? = null
private var mapHandler: MapHandler? = null
private var anchorWatchHandler: AnchorWatchHandler? = null
-
+ private val loadedStyleFlow = MutableStateFlow<Style?>(null)
+
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
private lateinit var fragmentContainer: FrameLayout
+ private lateinit var fabRecordTrack: FloatingActionButton
private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) }
private val viewModel: MainViewModel by viewModels()
@@ -55,6 +60,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
override fun onResume() {
super.onResume()
+ mapView?.onResume()
if (pendingServiceStart) {
pendingServiceStart = false
startServices()
@@ -80,6 +86,19 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
findViewById<FloatingActionButton>(R.id.fab_mob).setOnClickListener {
onActivateMob()
}
+
+ fabRecordTrack = findViewById(R.id.fab_record_track)
+ fabRecordTrack.setOnClickListener {
+ if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack()
+ }
+ // Observe immediately — pure UI state, not gated on GPS permission
+ lifecycleScope.launch {
+ viewModel.isRecording.collect { recording ->
+ val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record
+ fabRecordTrack.setImageResource(icon)
+ fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track"
+ }
+ }
}
private fun setupBottomSheet() {
@@ -131,17 +150,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private fun hideOverlays() {
fragmentContainer.visibility = View.GONE
- // Clear backstack if needed
}
override fun onActivateMob() {
lifecycleScope.launch {
LocationService.locationFlow.firstOrNull()?.let { gpsData ->
val mediaPlayer = MediaPlayer.create(this@MainActivity, R.raw.mob_alarm)
- // In a real redesign, we'd show a specialized MOB fragment
- // For now, keep existing handler logic but maybe toggle visibility
mobHandler?.activateMob(gpsData.latitude, gpsData.longitude, mediaPlayer)
- // Ensure MOB UI is visible - we might need to add it back to activity_main if removed
}
}
}
@@ -151,7 +166,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
private fun setupHandlers() {
- // ... (Keep existing handler initialization, just update view IDs as needed)
instrumentHandler = InstrumentHandler(
valueAws = findViewById(R.id.value_aws),
valueTws = findViewById(R.id.value_tws),
@@ -226,10 +240,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}, 256))
.withLayer(RasterLayer("openseamap-layer", "openseamap-source"))
- maplibreMap.setStyle(style) { loadedStyle ->
+ maplibreMap.setStyle(style) { style ->
+ loadedStyleFlow.value = style
val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor)
val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow)
- mapHandler?.setupLayers(loadedStyle, anchorBitmap, arrowBitmap)
+ mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap)
}
}
}
@@ -238,6 +253,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude)
+ val sogKnots = gpsData.speedOverGround * 1.94384
+ viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, gpsData.courseOverGround.toDouble())
}
}
lifecycleScope.launch {
@@ -245,6 +262,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
}
}
+ lifecycleScope.launch {
+ loadedStyleFlow.filterNotNull()
+ .combine(viewModel.trackPoints) { style, points -> style to points }
+ .collect { (style, points) -> mapHandler?.updateTrackLayer(style, points) }
+ }
}
private fun startInstrumentSimulation(polarTable: PolarTable) {
@@ -288,7 +310,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
override fun onStart() { super.onStart(); mapView?.onStart() }
- override fun onResume() { super.onResume(); mapView?.onResume() }
override fun onPause() { super.onPause(); mapView?.onPause() }
override fun onStop() { super.onStop(); mapView?.onStop() }
override fun onDestroy() { super.onDestroy(); mapView?.onDestroy() }
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt
new file mode 100644
index 0000000..0286ea8
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt
@@ -0,0 +1,69 @@
+package org.terst.nav.data.model
+
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+/**
+ * Boat polar speed table: maps (TWA, TWS) → BSP (boat speed through water, knots).
+ *
+ * Interpolation is bilinear — linear on TWA within a given TWS, then linear on TWS.
+ * Port-tack mirror: TWA > 180° is folded to 360° - TWA before lookup.
+ */
+data class BoatPolars(
+ /** Outer key: TWS in knots. Inner key: TWA in degrees [0, 180]. Value: BSP in knots. */
+ val table: Map<Double, Map<Double, Double>>
+) {
+ /**
+ * Returns boat speed (knots) for the given True Wind Angle and True Wind Speed.
+ * TWA outside [0, 360] is clamped; port/starboard symmetry is applied (>180° mirrored).
+ */
+ fun bsp(twaDeg: Double, twsKt: Double): Double {
+ val twa = if (twaDeg > 180.0) 360.0 - twaDeg else twaDeg.coerceIn(0.0, 180.0)
+
+ val twsKeys = table.keys.sorted()
+ if (twsKeys.isEmpty()) return 0.0
+
+ val twsClamped = twsKt.coerceIn(twsKeys.first(), twsKeys.last())
+ val twsLow = twsKeys.lastOrNull { it <= twsClamped } ?: twsKeys.first()
+ val twsHigh = twsKeys.firstOrNull { it >= twsClamped } ?: twsKeys.last()
+
+ val bspLow = bspAtTws(twa, table[twsLow] ?: return 0.0)
+ val bspHigh = bspAtTws(twa, table[twsHigh] ?: return 0.0)
+
+ return if (twsHigh == twsLow) bspLow
+ else {
+ val t = (twsClamped - twsLow) / (twsHigh - twsLow)
+ bspLow + t * (bspHigh - bspLow)
+ }
+ }
+
+ private fun bspAtTws(twaDeg: Double, twaMap: Map<Double, Double>): Double {
+ val twaKeys = twaMap.keys.sorted()
+ if (twaKeys.isEmpty()) return 0.0
+
+ val twaClamped = twaDeg.coerceIn(twaKeys.first(), twaKeys.last())
+ val twaLow = twaKeys.lastOrNull { it <= twaClamped } ?: twaKeys.first()
+ val twaHigh = twaKeys.firstOrNull { it >= twaClamped } ?: twaKeys.last()
+
+ val bspLow = twaMap[twaLow] ?: 0.0
+ val bspHigh = twaMap[twaHigh] ?: 0.0
+
+ return if (twaHigh == twaLow) bspLow
+ else {
+ val t = (twaClamped - twaLow) / (twaHigh - twaLow)
+ bspLow + t * (bspHigh - bspLow)
+ }
+ }
+
+ companion object {
+ /** Default polar for a typical 35-foot cruising sloop. */
+ val DEFAULT: BoatPolars = BoatPolars(
+ mapOf(
+ 5.0 to mapOf(45.0 to 3.5, 60.0 to 4.2, 90.0 to 4.8, 120.0 to 5.0, 150.0 to 4.5, 180.0 to 4.0),
+ 10.0 to mapOf(45.0 to 5.5, 60.0 to 6.5, 90.0 to 7.0, 120.0 to 7.2, 150.0 to 6.8, 180.0 to 6.0),
+ 15.0 to mapOf(45.0 to 6.5, 60.0 to 7.5, 90.0 to 8.0, 120.0 to 8.5, 150.0 to 8.0, 180.0 to 7.0),
+ 20.0 to mapOf(45.0 to 7.0, 60.0 to 8.0, 90.0 to 8.5, 120.0 to 9.0, 150.0 to 8.5, 180.0 to 7.5)
+ )
+ )
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
index 9d284b5..715c1db 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
@@ -2,39 +2,8 @@ package org.terst.nav.data.model
import java.time.Instant
-/**
- * Metadata record for a locally-stored GRIB2 file.
- *
- * @param region The geographic region this file covers.
- * @param modelRunTime When the NWP model run that produced this file started (UTC).
- * @param forecastHours Number of forecast hours included in this file.
- * @param downloadedAt Wall-clock time when the file was saved locally.
- * @param filePath Absolute path to the GRIB2 file on the device filesystem.
- * @param sizeBytes File size in bytes.
- */
-data class GribFile(
- val region: GribRegion,
- val modelRunTime: Instant,
- val forecastHours: Int,
- val downloadedAt: Instant,
- val filePath: String,
- val sizeBytes: Long
-) {
- /**
- * The wall-clock time at which this GRIB file's forecast data expires.
- * Per design doc §6.3: valid until model run + forecast hours.
- */
+data class GribFile(val region: GribRegion, val modelRunTime: Instant, val forecastHours: Int, val downloadedAt: Instant, val filePath: String, val sizeBytes: Long) {
fun validUntil(): Instant = modelRunTime.plusSeconds(forecastHours.toLong() * 3600)
-
- /**
- * Returns true if the data has expired relative to [now].
- * Per design doc §6.3: stale after model run + forecast hour.
- */
fun isStale(now: Instant = Instant.now()): Boolean = now.isAfter(validUntil())
-
- /**
- * Age of the download in seconds.
- */
- fun ageSeconds(now: Instant = Instant.now()): Long =
- now.epochSecond - downloadedAt.epochSecond
+ fun ageSeconds(now: Instant = Instant.now()): Long = now.epochSecond - downloadedAt.epochSecond
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt
new file mode 100644
index 0000000..a322ea9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt
@@ -0,0 +1,24 @@
+package org.terst.nav.data.model
+
+/** GRIB meteorological/oceanographic parameters that can be requested for download. */
+enum class GribParameter {
+ WIND_SPEED,
+ WIND_DIRECTION,
+ SURFACE_PRESSURE,
+ TEMPERATURE_2M,
+ PRECIPITATION,
+ WAVE_HEIGHT,
+ WAVE_DIRECTION;
+
+ companion object {
+ /**
+ * Minimal parameter set for satellite (Iridium) links: wind speed, wind direction,
+ * and surface pressure only. Per §9.1: skip temperature/clouds to minimise bandwidth.
+ */
+ val SATELLITE_MINIMAL: Set<GribParameter> = setOf(
+ WIND_SPEED,
+ WIND_DIRECTION,
+ SURFACE_PRESSURE
+ )
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
index 5e32d6c..f960bc3 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
@@ -1,20 +1,6 @@
package org.terst.nav.data.model
-/**
- * Geographic bounding box used to identify a GRIB download region.
- * @param name Human-readable region name (e.g. "North Atlantic", "English Channel")
- */
-data class GribRegion(
- val name: String,
- val latMin: Double,
- val latMax: Double,
- val lonMin: Double,
- val lonMax: Double
-) {
- /** True if [lat]/[lon] falls within this region's bounding box. */
- fun contains(lat: Double, lon: Double): Boolean =
- lat in latMin..latMax && lon in lonMin..lonMax
-
- /** Area in square degrees (rough proxy for download size estimate). */
+data class GribRegion(val name: String, val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double) {
+ fun contains(lat: Double, lon: Double): Boolean = lat in latMin..latMax && lon in lonMin..lonMax
fun areaDegrees(): Double = (latMax - latMin) * (lonMax - lonMin)
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt
new file mode 100644
index 0000000..f41e917
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt
@@ -0,0 +1,19 @@
+package org.terst.nav.data.model
+
+/**
+ * A single entry in the electronic trip logbook.
+ * Matches the log format from Section 4.8 of COMPONENT_DESIGN.md.
+ */
+data class LogbookEntry(
+ val timestampMs: Long,
+ val lat: Double,
+ val lon: Double,
+ val sogKnots: Double,
+ val cogDegrees: Double,
+ val windKnots: Double? = null,
+ val windDirectionDeg: Double? = null,
+ val baroHpa: Double? = null,
+ val depthMeters: Double? = null,
+ val event: String? = null,
+ val notes: String? = null
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt
new file mode 100644
index 0000000..d14c9da
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt
@@ -0,0 +1,27 @@
+package org.terst.nav.data.model
+
+/**
+ * A bandwidth-optimised GRIB download request for satellite (Iridium) links.
+ *
+ * Per §9.1: crop to needed region and request only essential parameters
+ * (wind, pressure) to fit within the ~2.4 Kbps Iridium budget.
+ *
+ * @param region Geographic area to download (cropped to route corridor + 200 nm buffer).
+ * @param parameters GRIB parameters to include. Use [GribParameter.SATELLITE_MINIMAL]
+ * for satellite links.
+ * @param forecastHours Number of forecast hours to request (e.g. 24, 48, 120).
+ * @param resolutionDeg Grid spacing in degrees. Coarser grids produce smaller files;
+ * 1.0° is typical for satellite; 0.25° for WiFi/cellular.
+ */
+data class SatelliteDownloadRequest(
+ val region: GribRegion,
+ val parameters: Set<GribParameter>,
+ val forecastHours: Int,
+ val resolutionDeg: Double = 1.0
+) {
+ init {
+ require(forecastHours > 0) { "forecastHours must be positive" }
+ require(resolutionDeg > 0.0) { "resolutionDeg must be positive" }
+ require(parameters.isNotEmpty()) { "parameters must not be empty" }
+ }
+}
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/WindForecast.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt
new file mode 100644
index 0000000..f009da8
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt
@@ -0,0 +1,18 @@
+package org.terst.nav.data.model
+
+/**
+ * Wind conditions at a specific location and time.
+ *
+ * @param lat Latitude (decimal degrees).
+ * @param lon Longitude (decimal degrees).
+ * @param timestampMs UNIX time in milliseconds.
+ * @param twsKt True Wind Speed in knots.
+ * @param twdDeg True Wind Direction in degrees (the direction FROM which the wind blows, 0–360).
+ */
+data class WindForecast(
+ val lat: Double,
+ val lon: Double,
+ val timestampMs: Long,
+ val twsKt: Double,
+ val twdDeg: Double
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
new file mode 100644
index 0000000..d803c8c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
@@ -0,0 +1,12 @@
+package org.terst.nav.track
+
+data class TrackPoint(
+ val lat: Double,
+ val lon: Double,
+ val sogKnots: Double,
+ val cogDeg: Double,
+ val windSpeedKnots: Double,
+ val windAngleDeg: Double,
+ val isTrueWind: Boolean,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
new file mode 100644
index 0000000..7953822
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -0,0 +1,26 @@
+package org.terst.nav.track
+
+class TrackRepository {
+
+ var isRecording: Boolean = false
+ private set
+
+ private val points = mutableListOf<TrackPoint>()
+
+ fun startTrack() {
+ points.clear()
+ isRecording = true
+ }
+
+ fun stopTrack() {
+ isRecording = false
+ }
+
+ fun addPoint(point: TrackPoint): Boolean {
+ if (!isRecording) return false
+ points.add(point)
+ return true
+ }
+
+ fun getPoints(): List<TrackPoint> = points.toList()
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 8e84e1e..0efff52 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -6,6 +6,8 @@ import org.terst.nav.ais.AisHubSource
import org.terst.nav.ais.AisRepository
import org.terst.nav.ais.AisVessel
import org.terst.nav.data.api.AisHubApiService
+import org.terst.nav.track.TrackPoint
+import org.terst.nav.track.TrackRepository
import org.terst.nav.data.model.ForecastItem
import org.terst.nav.data.model.WindArrow
import org.terst.nav.data.repository.WeatherRepository
@@ -43,6 +45,37 @@ class MainViewModel(
private val aisRepository = AisRepository()
+ private val trackRepository = TrackRepository()
+
+ private val _isRecording = MutableStateFlow(false)
+ val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
+
+ private val _trackPoints = MutableStateFlow<List<TrackPoint>>(emptyList())
+ val trackPoints: StateFlow<List<TrackPoint>> = _trackPoints.asStateFlow()
+
+ fun startTrack() {
+ trackRepository.startTrack()
+ _trackPoints.value = emptyList()
+ _isRecording.value = true
+ }
+
+ fun stopTrack() {
+ trackRepository.stopTrack()
+ _isRecording.value = false
+ }
+
+ fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
+ val point = TrackPoint(
+ lat = lat, lon = lon,
+ sogKnots = sogKnots, cogDeg = cogDeg,
+ windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false,
+ timestampMs = System.currentTimeMillis()
+ )
+ if (trackRepository.addPoint(point)) {
+ _trackPoints.value = trackRepository.getPoints()
+ }
+ }
+
private val aisHubApi: AisHubApiService by lazy {
Retrofit.Builder()
.baseUrl("https://data.aishub.net")
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
index 91569cf..cbc2e90 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
@@ -7,6 +7,7 @@ import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.CircleLayer
+import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory
import org.maplibre.android.style.layers.RasterLayer
import org.maplibre.android.style.layers.SymbolLayer
@@ -15,10 +16,12 @@ import org.maplibre.android.style.sources.RasterSource
import org.maplibre.android.style.sources.TileSet
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
import org.maplibre.geojson.Polygon
import org.terst.nav.AnchorWatchState
import org.terst.nav.TidalCurrentState
+import org.terst.nav.track.TrackPoint
import kotlin.math.cos
import kotlin.math.sin
@@ -37,9 +40,13 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private val TIDAL_CURRENT_LAYER_ID = "tidal-current-layer"
private val TIDAL_ARROW_ICON_ID = "tidal-arrow-icon"
+ private val TRACK_SOURCE_ID = "track-source"
+ private val TRACK_LAYER_ID = "track-line"
+
private var anchorPointSource: GeoJsonSource? = null
private var anchorCircleSource: GeoJsonSource? = null
private var tidalCurrentSource: GeoJsonSource? = null
+ private var trackSource: GeoJsonSource? = null
/**
* Initializes map layers for anchor watch and tidal currents.
@@ -127,6 +134,28 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
}
}
+ /**
+ * Updates the GPS track polyline on the map. Lazily initialises the layer on first call.
+ */
+ fun updateTrackLayer(style: Style, points: List<TrackPoint>) {
+ if (trackSource == null) {
+ trackSource = GeoJsonSource(TRACK_SOURCE_ID)
+ style.addSource(trackSource!!)
+ style.addLayer(LineLayer(TRACK_LAYER_ID, TRACK_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.lineColor("#E53935"),
+ PropertyFactory.lineWidth(3f)
+ )
+ })
+ }
+ if (points.size >= 2) {
+ val coords = points.map { Point.fromLngLat(it.lon, it.lat) }
+ trackSource?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(coords)))
+ } else {
+ trackSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+ }
+
private fun createCirclePolygon(lat: Double, lon: Double, radiusMeters: Double): Polygon {
val points = mutableListOf<Point>()
val degreesBetweenPoints = 8
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/main/res/drawable/ic_track_record.xml b/android-app/app/src/main/res/drawable/ic_track_record.xml
new file mode 100644
index 0000000..9016369
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_track_record.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <!-- Outer ring -->
+ <path
+ android:fillColor="#00000000"
+ android:strokeColor="@android:color/white"
+ android:strokeWidth="2"
+ android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"/>
+ <!-- Filled red dot -->
+ <path
+ android:fillColor="#E53935"
+ android:pathData="M12,12m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0"/>
+</vector>
diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
index 66d1abe..68abc60 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -65,7 +65,20 @@
android:contentDescription="Man Overboard"
app:srcCompat="@android:drawable/ic_dialog_alert"
app:backgroundTint="@color/mob_button_background"
- app:layout_anchor="@id/bottom_navigation"
+ app:layout_anchor="@id/instrument_bottom_sheet"
app:layout_anchorGravity="top|start" />
+ <!-- Record Track Button -->
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_record_track"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="Record Track"
+ app:srcCompat="@drawable/ic_track_record"
+ app:layout_anchor="@id/instrument_bottom_sheet"
+ app:layout_anchorGravity="top|end" />
+
</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_anchor_watch.xml b/android-app/app/src/main/res/layout/fragment_anchor_watch.xml
new file mode 100644
index 0000000..96b9969
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_anchor_watch.xml
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_watch_title"
+ android:textSize="20sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="24dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_depth_label"
+ android:textSize="14sp"
+ android:layout_marginBottom="4dp" />
+
+ <EditText
+ android:id="@+id/etDepth"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/anchor_depth_hint"
+ android:inputType="numberDecimal"
+ android:importantForAutofill="no"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_rode_label"
+ android:textSize="14sp"
+ android:layout_marginBottom="4dp" />
+
+ <EditText
+ android:id="@+id/etRodeOut"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/anchor_rode_hint"
+ android:inputType="numberDecimal"
+ android:importantForAutofill="no"
+ android:layout_marginBottom="24dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_suggested_radius_label"
+ android:textSize="14sp"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/tvSuggestedRadius"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_suggested_radius_empty"
+ android:textSize="18sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/tvSuggestedRadiusHint"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_suggested_radius_hint"
+ android:textSize="12sp"
+ android:alpha="0.6" />
+
+ </LinearLayout>
+
+</ScrollView>
diff --git a/android-app/app/src/main/res/layout/fragment_map.xml b/android-app/app/src/main/res/layout/fragment_map.xml
index e5b86b7..2b9b40d 100644
--- a/android-app/app/src/main/res/layout/fragment_map.xml
+++ b/android-app/app/src/main/res/layout/fragment_map.xml
@@ -27,4 +27,18 @@
android:textSize="14sp"
android:visibility="gone" />
+ <!-- Staleness banner -->
+ <TextView
+ android:id="@+id/tvStalenessWarning"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="bottom"
+ android:background="#FFCC00"
+ android:textColor="#000000"
+ android:textStyle="bold"
+ android:padding="8dp"
+ android:gravity="center"
+ android:visibility="gone"
+ android:text="" />
+
</FrameLayout>
diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml
index 499ba8d..756f5e3 100755
--- a/android-app/app/src/main/res/values/strings.xml
+++ b/android-app/app/src/main/res/values/strings.xml
@@ -58,4 +58,14 @@
<string name="temp_fmt">%.0f °C</string>
<string name="precip_fmt">%d%%</string>
<string name="permission_rationale">Location is needed to show weather for your current position.</string>
+ <string name="nav_anchor_watch">Anchor</string>
+ <string name="anchor_watch_title">Anchor Watch</string>
+ <string name="anchor_depth_label">Depth (m)</string>
+ <string name="anchor_depth_hint">e.g. 5.0</string>
+ <string name="anchor_rode_label">Rode Out (m)</string>
+ <string name="anchor_rode_hint">e.g. 30.0</string>
+ <string name="anchor_suggested_radius_label">Suggested Watch Radius</string>
+ <string name="anchor_suggested_radius_empty">—</string>
+ <string name="anchor_suggested_radius_fmt">%.1f m</string>
+ <string name="anchor_suggested_radius_hint">Calculated from rode and depth using Pythagorean scope formula (2 m freeboard assumed)</string>
</resources>
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt
new file mode 100644
index 0000000..535e46a
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt
@@ -0,0 +1,91 @@
+package com.example.androidapp.data.weather
+
+import com.example.androidapp.data.model.GribFile
+import com.example.androidapp.data.model.GribRegion
+import com.example.androidapp.data.storage.InMemoryGribFileManager
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import java.time.Instant
+
+class GribStalenessCheckerTest {
+
+ private lateinit var manager: InMemoryGribFileManager
+ private lateinit var checker: GribStalenessChecker
+ private val region = GribRegion("test", 35.0, 40.0, -125.0, -120.0)
+
+ @Before
+ fun setUp() {
+ manager = InMemoryGribFileManager()
+ checker = GribStalenessChecker(manager)
+ }
+
+ private fun makeFile(
+ modelRunTime: Instant,
+ forecastHours: Int,
+ downloadedAt: Instant = modelRunTime
+ ) = GribFile(
+ region = region,
+ modelRunTime = modelRunTime,
+ forecastHours = forecastHours,
+ downloadedAt = downloadedAt,
+ filePath = "/tmp/test.grib",
+ sizeBytes = 1024L
+ )
+
+ @Test
+ fun `check_returnsFresh_whenFileIsNotStale`() {
+ val now = Instant.parse("2026-03-16T12:00:00Z")
+ // model run at 06:00, 24h forecast → valid until 06:00 next day, well beyond now
+ val file = makeFile(
+ modelRunTime = Instant.parse("2026-03-16T06:00:00Z"),
+ forecastHours = 24,
+ downloadedAt = Instant.parse("2026-03-16T07:00:00Z")
+ )
+ manager.saveMetadata(file)
+
+ val result = checker.check(region, now)
+
+ assertTrue("Expected Fresh but got $result", result is FreshnessResult.Fresh)
+ }
+
+ @Test
+ fun `check_returnsStale_whenFileIsExpired`() {
+ val now = Instant.parse("2026-03-16T20:00:00Z")
+ // model run at 06:00, 6h forecast → valid until 12:00; now is 8h after that
+ val file = makeFile(
+ modelRunTime = Instant.parse("2026-03-16T06:00:00Z"),
+ forecastHours = 6,
+ downloadedAt = Instant.parse("2026-03-16T07:00:00Z")
+ )
+ manager.saveMetadata(file)
+
+ val result = checker.check(region, now)
+
+ assertTrue("Expected Stale but got $result", result is FreshnessResult.Stale)
+ val stale = result as FreshnessResult.Stale
+ assertTrue("Message should contain hours outdated", stale.message.contains("8h"))
+ assertEquals(file, stale.file)
+ }
+
+ @Test
+ fun `check_returnsNoData_whenNoFilesForRegion`() {
+ val otherRegion = GribRegion("other", 50.0, 55.0, -10.0, 0.0)
+ val file = makeFile(
+ modelRunTime = Instant.parse("2026-03-16T06:00:00Z"),
+ forecastHours = 24
+ )
+ manager.saveMetadata(file)
+
+ val result = checker.check(otherRegion, Instant.parse("2026-03-16T12:00:00Z"))
+
+ assertEquals(FreshnessResult.NoData, result)
+ }
+
+ @Test
+ fun `check_returnsNoData_whenManagerEmpty`() {
+ val result = checker.check(region, Instant.now())
+
+ assertEquals(FreshnessResult.NoData, result)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt
new file mode 100644
index 0000000..4bf7985
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt
@@ -0,0 +1,180 @@
+package com.example.androidapp.data.weather
+
+import com.example.androidapp.data.model.GribParameter
+import com.example.androidapp.data.model.GribRegion
+import com.example.androidapp.data.model.SatelliteDownloadRequest
+import com.example.androidapp.data.storage.InMemoryGribFileManager
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import java.time.Instant
+
+class SatelliteGribDownloaderTest {
+
+ private lateinit var manager: InMemoryGribFileManager
+ private lateinit var downloader: SatelliteGribDownloader
+
+ // 10°×10° region at 1°: 11×11 = 121 grid points
+ private val region10x10 = GribRegion("atlantic", 30.0, 40.0, -70.0, -60.0)
+
+ @Before
+ fun setUp() {
+ manager = InMemoryGribFileManager()
+ downloader = SatelliteGribDownloader(manager)
+ }
+
+ // ------------------------------------------------------------------ size estimation
+
+ @Test
+ fun `estimateSizeBytes_scalesWithRegionArea`() {
+ // 10°×10° region: 11×11 = 121 grid points
+ val req10 = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = 24
+ )
+ // 20°×20° region: 21×21 = 441 grid points — roughly 3.6× more grid points
+ val region20x20 = GribRegion("bigger", 20.0, 40.0, -80.0, -60.0)
+ val req20 = SatelliteDownloadRequest(
+ region = region20x20,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = 24
+ )
+
+ val size10 = downloader.estimateSizeBytes(req10)
+ val size20 = downloader.estimateSizeBytes(req20)
+
+ assertTrue("Larger region must produce larger estimate", size20 > size10)
+ }
+
+ @Test
+ fun `estimateSizeBytes_scalesWithParameterCount`() {
+ val minimalReq = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.SATELLITE_MINIMAL, // 3 params
+ forecastHours = 24
+ )
+ val fullReq = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.values().toSet(), // all 7 params
+ forecastHours = 24
+ )
+
+ val sizeMinimal = downloader.estimateSizeBytes(minimalReq)
+ val sizeFull = downloader.estimateSizeBytes(fullReq)
+
+ assertTrue("More parameters must produce larger estimate", sizeFull > sizeMinimal)
+ }
+
+ @Test
+ fun `estimateSizeBytes_coarserResolutionProducesSmallerFile`() {
+ val finReq = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = 24,
+ resolutionDeg = 1.0
+ )
+ val coarseReq = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = 24,
+ resolutionDeg = 2.0
+ )
+
+ val sizeFine = downloader.estimateSizeBytes(finReq)
+ val sizeCoarse = downloader.estimateSizeBytes(coarseReq)
+
+ assertTrue("Coarser resolution must produce smaller estimate", sizeCoarse < sizeFine)
+ }
+
+ @Test
+ fun `estimatedDownloadSeconds_atIridiumBandwidth`() {
+ // 10°×10°, 3 params, 24h at 1° → known estimate
+ val req = SatelliteDownloadRequest(
+ region = region10x10,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = 24
+ )
+ val estBytes = downloader.estimateSizeBytes(req)
+ val expectedSeconds = Math.ceil(estBytes * 8.0 / SatelliteGribDownloader.SATELLITE_BANDWIDTH_BPS).toLong()
+
+ val actualSeconds = downloader.estimatedDownloadSeconds(req)
+
+ assertEquals(expectedSeconds, actualSeconds)
+ // Sanity: should be > 0 seconds and less than 10 minutes for a small region
+ assertTrue("Download estimate must be positive", actualSeconds > 0)
+ assertTrue("Small 10°×10° should complete in under 10 min at 2.4kbps", actualSeconds < 600)
+ }
+
+ // ------------------------------------------------------------------ buildMinimalRequest
+
+ @Test
+ fun `buildMinimalRequest_containsOnlyWindAndPressure`() {
+ val req = downloader.buildMinimalRequest(region10x10, 48)
+
+ assertEquals(GribParameter.SATELLITE_MINIMAL, req.parameters)
+ assertTrue(req.parameters.contains(GribParameter.WIND_SPEED))
+ assertTrue(req.parameters.contains(GribParameter.WIND_DIRECTION))
+ assertTrue(req.parameters.contains(GribParameter.SURFACE_PRESSURE))
+ assertFalse(req.parameters.contains(GribParameter.TEMPERATURE_2M))
+ assertFalse(req.parameters.contains(GribParameter.PRECIPITATION))
+ assertEquals(region10x10, req.region)
+ assertEquals(48, req.forecastHours)
+ }
+
+ // ------------------------------------------------------------------ download()
+
+ @Test
+ fun `download_abortsWhenEstimatedSizeExceedsLimit`() {
+ val req = downloader.buildMinimalRequest(region10x10, 24)
+ var fetcherCalled = false
+
+ val result = downloader.download(
+ request = req,
+ fetcher = { fetcherCalled = true; ByteArray(100) },
+ outputPath = "/tmp/test.grib",
+ sizeLimitBytes = 1L // ridiculously small limit
+ )
+
+ assertTrue("Should abort without calling fetcher", result is SatelliteGribDownloader.DownloadResult.Aborted)
+ assertFalse("Fetcher must not be called when aborting", fetcherCalled)
+ val aborted = result as SatelliteGribDownloader.DownloadResult.Aborted
+ assertTrue("Should report estimated bytes", aborted.estimatedBytes > 0)
+ }
+
+ @Test
+ fun `download_returnsFailedWhenFetcherReturnsNull`() {
+ val req = downloader.buildMinimalRequest(region10x10, 24)
+
+ val result = downloader.download(
+ request = req,
+ fetcher = { null },
+ outputPath = "/tmp/test.grib"
+ )
+
+ assertTrue("Should fail when fetcher returns null", result is SatelliteGribDownloader.DownloadResult.Failed)
+ }
+
+ @Test
+ fun `download_savesMetadataAndReturnsSuccessOnValidFetch`() {
+ val req = downloader.buildMinimalRequest(region10x10, 24)
+ val fakeBytes = ByteArray(8208) { 0x00 }
+ val now = Instant.parse("2026-03-16T12:00:00Z")
+
+ val result = downloader.download(
+ request = req,
+ fetcher = { fakeBytes },
+ outputPath = "/tmp/atlantic.grib",
+ now = now
+ )
+
+ assertTrue("Should succeed", result is SatelliteGribDownloader.DownloadResult.Success)
+ val success = result as SatelliteGribDownloader.DownloadResult.Success
+ assertEquals(region10x10, success.file.region)
+ assertEquals(24, success.file.forecastHours)
+ assertEquals(fakeBytes.size.toLong(), success.file.sizeBytes)
+ assertEquals("/tmp/atlantic.grib", success.file.filePath)
+ // Metadata must be persisted in the manager
+ assertNotNull(manager.latestFile(region10x10))
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt
new file mode 100644
index 0000000..8b2753c
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt
@@ -0,0 +1,33 @@
+package com.example.androidapp.gps
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class GpsPositionTest {
+
+ @Test
+ fun `GpsPosition holds correct values`() {
+ val pos = GpsPosition(
+ latitude = 41.5,
+ longitude = -71.0,
+ sog = 5.2,
+ cog = 180.0,
+ timestampMs = 1_000L
+ )
+ assertEquals(41.5, pos.latitude, 0.0)
+ assertEquals(-71.0, pos.longitude, 0.0)
+ assertEquals(5.2, pos.sog, 0.0)
+ assertEquals(180.0, pos.cog, 0.0)
+ assertEquals(1_000L, pos.timestampMs)
+ }
+
+ @Test
+ fun `GpsPosition equality works as expected for data class`() {
+ val pos1 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L)
+ val pos2 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L)
+ val pos3 = GpsPosition(42.0, -70.0, 3.0, 90.0, 2_000L)
+
+ assertEquals(pos1, pos2)
+ assertNotEquals(pos1, pos3)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
new file mode 100644
index 0000000..4eb9898
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
@@ -0,0 +1,317 @@
+package com.example.androidapp.gps
+
+import com.example.androidapp.data.model.SensorData
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.*
+import org.junit.Test
+
+class LocationServiceTest {
+
+ private fun service() = LocationService()
+
+ // ── snapshot with no data ─────────────────────────────────────────────────
+
+ @Test
+ fun snapshot_noData_allFieldsNull() {
+ val snap = service().snapshot()
+ assertNull(snap.windSpeedKt)
+ assertNull(snap.windDirectionDeg)
+ assertNull(snap.currentSpeedKt)
+ assertNull(snap.currentDirectionDeg)
+ }
+
+ // ── true-wind resolution ──────────────────────────────────────────────────
+
+ @Test
+ fun updateSensorData_withFullReading_resolvesTrueWind() = runBlocking {
+ val svc = service()
+ // Head north (hdg = 0°), AWS = 10 kt coming from ahead (AWA = 0°), BSP = 5 kt
+ // → TW comes FROM ahead at 5 kt
+ svc.updateSensorData(
+ SensorData(
+ headingTrueDeg = 0.0,
+ apparentWindSpeedKt = 10.0,
+ apparentWindAngleDeg = 0.0,
+ speedOverGroundKt = 5.0
+ )
+ )
+ val tw = svc.latestTrueWind.first()
+ assertNotNull(tw)
+ assertTrue("Expected TWS > 0", tw!!.speedKt > 0.0)
+ }
+
+ @Test
+ fun updateSensorData_missingHeading_doesNotResolveTrueWind() = runBlocking {
+ val svc = service()
+ svc.updateSensorData(
+ SensorData(
+ apparentWindSpeedKt = 10.0,
+ apparentWindAngleDeg = 45.0,
+ speedOverGroundKt = 5.0
+ // headingTrueDeg omitted
+ )
+ )
+ assertNull(svc.latestTrueWind.first())
+ }
+
+ // ── current conditions ────────────────────────────────────────────────────
+
+ @Test
+ fun updateCurrentConditions_reflectedInSnapshot() {
+ val svc = service()
+ svc.updateCurrentConditions(speedKt = 1.5, directionDeg = 135.0)
+
+ val snap = svc.snapshot()
+ assertEquals(1.5, snap.currentSpeedKt!!, 0.001)
+ assertEquals(135.0, snap.currentDirectionDeg!!, 0.001)
+ }
+
+ @Test
+ fun updateCurrentConditions_nullClears() {
+ val svc = service()
+ svc.updateCurrentConditions(speedKt = 2.0, directionDeg = 90.0)
+ svc.updateCurrentConditions(speedKt = null, directionDeg = null)
+
+ val snap = svc.snapshot()
+ assertNull(snap.currentSpeedKt)
+ assertNull(snap.currentDirectionDeg)
+ }
+
+ // ── combined snapshot ─────────────────────────────────────────────────────
+
+ @Test
+ fun snapshot_afterFullUpdate_populatesAllFields() = runBlocking {
+ val svc = service()
+
+ // Head east (hdg = 90°), wind from starboard bow, BSP proxy = 6 kt
+ svc.updateSensorData(
+ SensorData(
+ headingTrueDeg = 90.0,
+ apparentWindSpeedKt = 12.0,
+ apparentWindAngleDeg = 45.0,
+ speedOverGroundKt = 6.0
+ )
+ )
+ svc.updateCurrentConditions(speedKt = 0.8, directionDeg = 270.0)
+
+ val snap = svc.snapshot()
+ assertNotNull(snap.windSpeedKt)
+ assertNotNull(snap.windDirectionDeg)
+ assertEquals(0.8, snap.currentSpeedKt!!, 0.001)
+ assertEquals(270.0, snap.currentDirectionDeg!!, 0.001)
+ }
+
+ // ── latestSensor flow ─────────────────────────────────────────────────────
+
+ @Test
+ fun updateSensorData_updatesLatestSensorFlow() = runBlocking {
+ val svc = service()
+ assertNull(svc.latestSensor.first())
+
+ val data = SensorData(latitude = 41.5, longitude = -71.3)
+ svc.updateSensorData(data)
+
+ assertEquals(data, svc.latestSensor.first())
+ }
+
+ // ── GPS sensor fusion ─────────────────────────────────────────────────────
+
+ private fun fusionService(
+ nmeaStalenessThresholdMs: Long = 5_000L,
+ nmeaExtendedThresholdMs: Long = 10_000L,
+ clockMs: () -> Long = System::currentTimeMillis
+ ) = LocationService(
+ nmeaStalenessThresholdMs = nmeaStalenessThresholdMs,
+ nmeaExtendedThresholdMs = nmeaExtendedThresholdMs,
+ clockMs = clockMs
+ )
+
+ private fun pos(lat: Double, lon: Double, timestampMs: Long) =
+ GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs)
+
+ private fun posWithAccuracy(lat: Double, lon: Double, timestampMs: Long, accuracyMeters: Double) =
+ GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs, accuracyMeters = accuracyMeters)
+
+ @Test
+ fun noGpsData_bestPositionNullAndSourceNone() = runBlocking {
+ val svc = fusionService()
+ assertNull(svc.bestPosition.first())
+ assertEquals(GpsSource.NONE, svc.activeGpsSource.first())
+ }
+
+ @Test
+ fun freshNmea_preferredOverAndroid() = runBlocking {
+ val now = 10_000L
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, now)
+ val androidFix = pos(42.0, -72.0, now - 1_000L)
+
+ svc.updateAndroidGps(androidFix)
+ svc.updateNmeaGps(nmeaFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ assertEquals(nmeaFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun staleNmea_androidFallback() = runBlocking {
+ val nmeaTime = 0L
+ val now = 10_000L // 10 s later — NMEA is stale (threshold 5 s)
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, nmeaTime)
+ val androidFix = pos(42.0, -72.0, now)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ assertEquals(androidFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun onlyNmeaAvailable_usedEvenWhenStale() = runBlocking {
+ val now = 60_000L // 60 s after fix — very stale
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, 0L)
+ svc.updateNmeaGps(nmeaFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ assertEquals(nmeaFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun onlyAndroidAvailable_isUsed() = runBlocking {
+ val svc = fusionService()
+ val androidFix = pos(42.0, -72.0, System.currentTimeMillis())
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ assertEquals(androidFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun nmeaAtExactThreshold_isConsideredFresh() = runBlocking {
+ val fixTime = 0L
+ val now = 5_000L // exactly at threshold
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ val nmeaFix = pos(41.0, -71.0, fixTime)
+ val androidFix = pos(42.0, -72.0, now)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ }
+
+ // ── fix-quality (accuracy) tie-breaking ──────────────────────────────────
+
+ @Test
+ fun marginallyStaleNmea_betterAccuracy_preferredOverAndroid() = runBlocking {
+ // NMEA is 7 s old (> primary 5 s, ≤ extended 10 s) but has accuracy 3 m vs Android 15 m.
+ val nmeaTime = 0L
+ val now = 7_000L
+ val svc = fusionService(
+ nmeaStalenessThresholdMs = 5_000L,
+ nmeaExtendedThresholdMs = 10_000L,
+ clockMs = { now }
+ )
+
+ val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 3.0)
+ val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 15.0)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.first())
+ assertEquals(nmeaFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun marginallyStaleNmea_worseAccuracy_fallsBackToAndroid() = runBlocking {
+ // NMEA is 7 s old with accuracy 15 m; Android has accuracy 3 m → Android wins.
+ val nmeaTime = 0L
+ val now = 7_000L
+ val svc = fusionService(
+ nmeaStalenessThresholdMs = 5_000L,
+ nmeaExtendedThresholdMs = 10_000L,
+ clockMs = { now }
+ )
+
+ val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 15.0)
+ val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 3.0)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ assertEquals(androidFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun marginallyStaleNmea_noAccuracyData_fallsBackToAndroid() = runBlocking {
+ // Neither source has accuracy metadata — conservative: prefer Android.
+ val nmeaTime = 0L
+ val now = 7_000L
+ val svc = fusionService(
+ nmeaStalenessThresholdMs = 5_000L,
+ nmeaExtendedThresholdMs = 10_000L,
+ clockMs = { now }
+ )
+
+ val nmeaFix = pos(41.0, -71.0, nmeaTime)
+ val androidFix = pos(42.0, -72.0, now)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ }
+
+ @Test
+ fun veryStaleNmea_beyondExtendedThreshold_androidPreferred() = runBlocking {
+ // NMEA is 15 s old (beyond extended 10 s); Android wins even if NMEA has better accuracy.
+ val nmeaTime = 0L
+ val now = 15_000L
+ val svc = fusionService(
+ nmeaStalenessThresholdMs = 5_000L,
+ nmeaExtendedThresholdMs = 10_000L,
+ clockMs = { now }
+ )
+
+ val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 2.0)
+ val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 20.0)
+
+ svc.updateNmeaGps(nmeaFix)
+ svc.updateAndroidGps(androidFix)
+
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first())
+ assertEquals(androidFix, svc.bestPosition.first())
+ }
+
+ @Test
+ fun nmeaRecovery_switchesBackFromAndroid() = runBlocking {
+ var now = 0L
+ val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now })
+
+ // Fresh NMEA
+ svc.updateNmeaGps(pos(41.0, -71.0, 0L))
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.value)
+
+ // NMEA goes stale; Android takes over
+ now = 10_000L
+ val androidFix = pos(42.0, -72.0, 10_000L)
+ svc.updateAndroidGps(androidFix)
+ assertEquals(GpsSource.ANDROID, svc.activeGpsSource.value)
+
+ // NMEA recovers with a fresh fix
+ val freshNmea = pos(41.1, -71.1, 10_000L)
+ svc.updateNmeaGps(freshNmea)
+ assertEquals(GpsSource.NMEA, svc.activeGpsSource.value)
+ assertEquals(freshNmea, svc.bestPosition.value)
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt
new file mode 100644
index 0000000..30b421f
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt
@@ -0,0 +1,178 @@
+package com.example.androidapp.logbook
+
+import com.example.androidapp.data.model.LogbookEntry
+import org.junit.Assert.*
+import org.junit.Test
+
+class LogbookFormatterTest {
+
+ // 2021-06-15 08:00:00 UTC = 1623744000000 ms
+ private val t0 = 1_623_744_000_000L
+
+ private fun entry(
+ ts: Long = t0,
+ lat: Double = 41.39,
+ lon: Double = -71.202,
+ sog: Double = 6.2,
+ cog: Double = 225.0,
+ windKt: Double? = 15.0,
+ windDir: Double? = 225.0,
+ baro: Double? = 1018.0,
+ depth: Double? = 14.0,
+ event: String? = "Departed slip",
+ notes: String? = null
+ ) = LogbookEntry(ts, lat, lon, sog, cog, windKt, windDir, baro, depth, event, notes)
+
+ // --- formatTime ---
+
+ @Test
+ fun `formatTime returns HH_MM for UTC midnight`() {
+ // 2021-06-15 00:00:00 UTC
+ val ts = 1_623_715_200_000L
+ assertEquals("00:00", LogbookFormatter.formatTime(ts))
+ }
+
+ @Test
+ fun `formatTime returns correct UTC hour for known timestamp`() {
+ // t0 = 2021-06-15 08:00:00 UTC
+ assertEquals("08:00", LogbookFormatter.formatTime(t0))
+ }
+
+ @Test
+ fun `formatTime pads single-digit hour and minute`() {
+ // 2021-06-15 01:05:00 UTC = 1623715200000 + 65*60*1000 = 1623715200000 + 3900000
+ val ts = 1_623_715_200_000L + 65 * 60_000L
+ assertEquals("01:05", LogbookFormatter.formatTime(ts))
+ }
+
+ // --- formatPosition ---
+
+ @Test
+ fun `formatPosition north east`() {
+ // 41.39°N → 41°23.4N, 71.202°E → 71°12.1E
+ val result = LogbookFormatter.formatPosition(41.39, 71.202)
+ assertEquals("41°23.4N 71°12.1E", result)
+ }
+
+ @Test
+ fun `formatPosition south west`() {
+ // -41.39°S → 41°23.4S, -71.202°W → 71°12.1W
+ val result = LogbookFormatter.formatPosition(-41.39, -71.202)
+ assertEquals("41°23.4S 71°12.1W", result)
+ }
+
+ @Test
+ fun `formatPosition zero zero`() {
+ val result = LogbookFormatter.formatPosition(0.0, 0.0)
+ assertEquals("0°0.0N 0°0.0E", result)
+ }
+
+ // --- formatWind ---
+
+ @Test
+ fun `formatWind null knots returns empty string`() {
+ assertEquals("", LogbookFormatter.formatWind(null, null))
+ }
+
+ @Test
+ fun `formatWind with knots and null direction returns knots only`() {
+ assertEquals("15kt", LogbookFormatter.formatWind(15.0, null))
+ }
+
+ @Test
+ fun `formatWind 225 degrees is SW`() {
+ assertEquals("15kt SW", LogbookFormatter.formatWind(15.0, 225.0))
+ }
+
+ @Test
+ fun `formatWind 0 degrees is N`() {
+ assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 0.0))
+ }
+
+ @Test
+ fun `formatWind 360 degrees is N`() {
+ assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 360.0))
+ }
+
+ @Test
+ fun `formatWind 90 degrees is E`() {
+ assertEquals("8kt E", LogbookFormatter.formatWind(8.0, 90.0))
+ }
+
+ // --- toCompassPoint ---
+
+ @Test
+ fun `toCompassPoint covers all 16 cardinal and intercardinal points`() {
+ val expected = listOf("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW")
+ expected.forEachIndexed { i, dir ->
+ val degrees = i * 22.5
+ assertEquals("degrees=$degrees", dir, LogbookFormatter.toCompassPoint(degrees))
+ }
+ }
+
+ // --- toRow ---
+
+ @Test
+ fun `toRow formats all fields correctly`() {
+ val row = LogbookFormatter.toRow(entry())
+ assertEquals("08:00", row.time)
+ assertEquals("41°23.4N 71°12.1W", row.position)
+ assertEquals("6.2", row.sog)
+ assertEquals("225", row.cog)
+ assertEquals("15kt SW", row.wind)
+ assertEquals("1018", row.baro)
+ assertEquals("14m", row.depth)
+ assertEquals("Departed slip", row.eventNotes)
+ }
+
+ @Test
+ fun `toRow combines event and notes with colon`() {
+ val row = LogbookFormatter.toRow(entry(event = "Reef #1", notes = "Strong gusts"))
+ assertEquals("Reef #1: Strong gusts", row.eventNotes)
+ }
+
+ @Test
+ fun `toRow with only notes has no colon prefix`() {
+ val row = LogbookFormatter.toRow(entry(event = null, notes = "Calm seas"))
+ assertEquals("Calm seas", row.eventNotes)
+ }
+
+ @Test
+ fun `toRow with null optional fields uses empty strings`() {
+ val e = LogbookEntry(t0, 0.0, 0.0, 0.0, 0.0)
+ val row = LogbookFormatter.toRow(e)
+ assertEquals("", row.wind)
+ assertEquals("", row.baro)
+ assertEquals("", row.depth)
+ assertEquals("", row.eventNotes)
+ }
+
+ // --- toPage ---
+
+ @Test
+ fun `toPage returns page with default title and correct column count`() {
+ val page = LogbookFormatter.toPage(emptyList())
+ assertEquals("Trip Logbook", page.title)
+ assertEquals(8, page.columns.size)
+ }
+
+ @Test
+ fun `toPage maps entries to rows in order`() {
+ val entries = listOf(
+ entry(ts = t0, event = "First"),
+ entry(ts = t0 + 3_600_000L, event = "Second")
+ )
+ val page = LogbookFormatter.toPage(entries, "Voyage Log")
+ assertEquals("Voyage Log", page.title)
+ assertEquals(2, page.rows.size)
+ assertEquals("First", page.rows[0].eventNotes)
+ assertEquals("Second", page.rows[1].eventNotes)
+ }
+
+ @Test
+ fun `toPage empty entries produces empty rows`() {
+ val page = LogbookFormatter.toPage(emptyList())
+ assertTrue(page.rows.isEmpty())
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt
new file mode 100644
index 0000000..b8a878a
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt
@@ -0,0 +1,105 @@
+package com.example.androidapp.nmea
+
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+
+class NmeaParserTest {
+
+ private lateinit var parser: NmeaParser
+
+ @Before
+ fun setUp() {
+ parser = NmeaParser()
+ }
+
+ // $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A
+ // lat: 48 + 7.038/60 = 48.1173°N, lon: 11 + 31.000/60 = 11.51667°E
+ // SOG 22.4 kn, COG 84.4°
+
+ @Test
+ fun `valid RMC sentence parses latitude and longitude`() {
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(48.1173, pos!!.latitude, 0.0001)
+ assertEquals(11.51667, pos.longitude, 0.0001)
+ }
+
+ @Test
+ fun `valid RMC sentence parses SOG and COG`() {
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(22.4, pos!!.sog, 0.001)
+ assertEquals(84.4, pos.cog, 0.001)
+ }
+
+ @Test
+ fun `void status V returns null`() {
+ val sentence = "\$GPRMC,123519,V,4807.038,N,01131.000,E,,,230394,003.1,W"
+ assertNull(parser.parseRmc(sentence))
+ }
+
+ @Test
+ fun `malformed sentence with too few fields returns null`() {
+ assertNull(parser.parseRmc("\$GPRMC,123519,A"))
+ }
+
+ @Test
+ fun `empty string returns null`() {
+ assertNull(parser.parseRmc(""))
+ }
+
+ @Test
+ fun `non-RMC sentence returns null`() {
+ val sentence = "\$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,"
+ assertNull(parser.parseRmc(sentence))
+ }
+
+ @Test
+ fun `south latitude is negative`() {
+ // lat: -(42 + 50.5589/60) = -42.84265
+ val sentence = "\$GPRMC,092204.999,A,4250.5589,S,14718.5084,E,0.00,89.68,211200,,"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertTrue("South latitude must be negative", pos!!.latitude < 0)
+ assertEquals(-42.84265, pos.latitude, 0.0001)
+ }
+
+ @Test
+ fun `west longitude is negative`() {
+ // lon: -(11 + 31.000/60) = -11.51667
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,W,022.4,084.4,230394,003.1,E"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertTrue("West longitude must be negative", pos!!.longitude < 0)
+ assertEquals(-11.51667, pos.longitude, 0.0001)
+ }
+
+ @Test
+ fun `SOG and COG parse with decimal precision`() {
+ val sentence = "\$GPRMC,093456,A,3352.1234,N,11801.5678,W,12.345,270.5,140326,,"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(12.345, pos!!.sog, 0.0001)
+ assertEquals(270.5, pos.cog, 0.0001)
+ }
+
+ @Test
+ fun `empty SOG and COG fields default to zero`() {
+ val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,,,230394,003.1,W"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(0.0, pos!!.sog, 0.001)
+ assertEquals(0.0, pos.cog, 0.001)
+ }
+
+ @Test
+ fun `GNRMC talker ID is also accepted`() {
+ val sentence = "\$GNRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W"
+ val pos = parser.parseRmc(sentence)
+ assertNotNull(pos)
+ assertEquals(48.1173, pos!!.latitude, 0.0001)
+ }
+}
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])
+ }
+}
diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt
new file mode 100644
index 0000000..40f7df0
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt
@@ -0,0 +1,32 @@
+package com.example.androidapp.safety
+
+import org.junit.Assert.*
+import org.junit.Test
+import kotlin.math.sqrt
+
+class AnchorWatchStateTest {
+
+ private val state = AnchorWatchState()
+
+ @Test
+ fun calculateRecommendedWatchCircleRadius_validGeometry() {
+ // depth=6m, rode=50m → vertical=8m, radius=sqrt(50²-8²)=sqrt(2436)
+ val expected = sqrt(2436.0)
+ val actual = state.calculateRecommendedWatchCircleRadius(depthM = 6.0, rodeOutM = 50.0)
+ assertEquals(expected, actual, 0.001)
+ }
+
+ @Test
+ fun calculateRecommendedWatchCircleRadius_rodeShorterThanVertical_fallsBackToRode() {
+ // depth=10m, rode=5m → vertical=12m > rode, fallback returns rode
+ val actual = state.calculateRecommendedWatchCircleRadius(depthM = 10.0, rodeOutM = 5.0)
+ assertEquals(5.0, actual, 0.001)
+ }
+
+ @Test
+ fun calculateRecommendedWatchCircleRadius_rodeEqualsVertical_fallsBackToRode() {
+ // depth=8m, rode=10m → vertical=10m == rode, fallback returns rode
+ val actual = state.calculateRecommendedWatchCircleRadius(depthM = 8.0, rodeOutM = 10.0)
+ assertEquals(10.0, actual, 0.001)
+ }
+}
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/LogbookEntryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt
new file mode 100644
index 0000000..fc4580c
--- /dev/null
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/LogbookEntryTest.kt
@@ -0,0 +1,67 @@
+package org.terst.nav.data.model
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class LogbookEntryTest {
+
+ @Test
+ fun `LogbookEntry holds all required fields`() {
+ val entry = LogbookEntry(
+ timestampMs = 1_000_000L,
+ lat = 41.39,
+ lon = -71.202,
+ sogKnots = 6.2,
+ cogDegrees = 225.0,
+ windKnots = 15.0,
+ windDirectionDeg = 225.0,
+ baroHpa = 1018.0,
+ depthMeters = 14.0,
+ event = "Departed slip",
+ notes = "Crew ready"
+ )
+ assertEquals(1_000_000L, entry.timestampMs)
+ assertEquals(41.39, entry.lat, 1e-9)
+ assertEquals(-71.202, entry.lon, 1e-9)
+ assertEquals(6.2, entry.sogKnots, 1e-9)
+ assertEquals(225.0, entry.cogDegrees, 1e-9)
+ assertEquals(15.0, entry.windKnots)
+ assertEquals(225.0, entry.windDirectionDeg)
+ assertEquals(1018.0, entry.baroHpa)
+ assertEquals(14.0, entry.depthMeters)
+ assertEquals("Departed slip", entry.event)
+ assertEquals("Crew ready", entry.notes)
+ }
+
+ @Test
+ fun `LogbookEntry optional fields default to null`() {
+ val entry = LogbookEntry(
+ timestampMs = 0L,
+ lat = 0.0,
+ lon = 0.0,
+ sogKnots = 0.0,
+ cogDegrees = 0.0
+ )
+ assertNull(entry.windKnots)
+ assertNull(entry.windDirectionDeg)
+ assertNull(entry.baroHpa)
+ assertNull(entry.depthMeters)
+ assertNull(entry.event)
+ assertNull(entry.notes)
+ }
+
+ @Test
+ fun `LogbookEntry data class equality`() {
+ val a = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0)
+ val b = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0)
+ assertEquals(a, b)
+ }
+
+ @Test
+ fun `LogbookEntry data class copy`() {
+ val original = LogbookEntry(100L, 10.0, 20.0, 5.0, 90.0, event = "anchor")
+ val copy = original.copy(sogKnots = 3.0)
+ assertEquals(3.0, copy.sogKnots, 1e-9)
+ assertEquals("anchor", copy.event)
+ }
+}
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
diff --git a/android-app/build.gradle b/android-app/build.gradle
index edacb05..195123b 100644
--- a/android-app/build.gradle
+++ b/android-app/build.gradle
@@ -3,6 +3,7 @@ plugins {
id 'com.android.application' version '8.3.2' apply false
id 'com.android.library' version '8.3.2' apply false
id 'org.jetbrains.kotlin.android' version '2.0.0' apply false
+ id 'com.google.devtools.ksp' version '2.0.0-1.0.21' apply false
id 'com.google.gms.google-services' version '4.4.1' apply false
id 'com.google.firebase.appdistribution' version '4.0.1' apply false
id 'com.google.firebase.crashlytics' version '3.0.2' apply false