summaryrefslogtreecommitdiff
path: root/android-app/app
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-04-04 07:45:41 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-04-04 07:45:41 +0000
commit97715ab4007ff3101f58edf4385cef1fc3d1615b (patch)
tree464bdb1df8cfed31402f5316fe84df974c0e59e2 /android-app/app
parent9f01ddfba17dda7fb386e83f007c671fec6d5b8e (diff)
refactor: unify core models and finish org.terst.nav migration
Diffstat (limited to 'android-app/app')
-rw-r--r--android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt9
-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/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/nmea/NmeaParser.kt94
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt57
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt290
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt15
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt22
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt)4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt)4
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt17
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt5
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt40
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt99
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt)6
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt)2
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt (renamed from android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt)2
-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/repository/WeatherRepositoryTest.kt31
38 files changed, 265 insertions, 1964 deletions
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 30841c7..2d75cf4 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
@@ -65,7 +65,7 @@ class MainActivitySmokeTest {
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()))
+ onView(withText("CONFIGURE ANCHOR WATCH")).check(matches(isDisplayed()))
}
@Test
@@ -81,6 +81,13 @@ class MainActivitySmokeTest {
}
@Test
+ fun instrumentSheet_surfacedReportButtons_areDisplayed() {
+ onView(withText("Instruments")).perform(click())
+ onView(withText("PRE-TRIP PLAN")).check(matches(isDisplayed()))
+ onView(withText("GENERATE REPORT")).check(matches(isDisplayed()))
+ }
+
+ @Test
fun bottomNav_mapTab_returnsFromOverlay() {
onView(withText("Safety")).perform(click())
onView(withText("Map")).perform(click())
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
deleted file mode 100644
index d6f685a..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-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/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt
deleted file mode 100644
index cbe5c84..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-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
deleted file mode 100644
index 0a315d4..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
+++ /dev/null
@@ -1,216 +0,0 @@
-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/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
deleted file mode 100644
index b1b186a..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-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/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt
deleted file mode 100644
index f544f63..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-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/org/terst/nav/AnchorWatchData.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
deleted file mode 100644
index 0c63662..0000000
--- a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package org.terst.nav
-
-import android.location.Location
-import kotlin.math.*
-
-data class AnchorWatchState(
- val anchorLocation: Location? = null,
- val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS,
- val setTimeMillis: Long = 0L,
- val isActive: Boolean = false
-) {
- companion object {
- const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 // Default 50 meters
-
- /**
- * Calculates the recommended watch circle radius based on depth, freeboard, and rode out.
- * Formula from docs/COMPONENT_DESIGN.md: Rode Out × cos(asin((Depth + Freeboard) / Rode Out))
- *
- * @param depthMeters Depth from surface to seabed in meters.
- * @param freeboardMeters Distance from surface to anchor attachment point on boat in meters.
- * @param rodeOutMeters Length of chain/rode deployed in meters.
- * @return Recommended watch circle radius in meters. Returns 0.0 if inputs are invalid.
- */
- fun calculateRecommendedWatchCircleRadius(
- depthMeters: Double,
- freeboardMeters: Double,
- rodeOutMeters: Double
- ): Double {
- if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) {
- return 0.0 // Invalid inputs
- }
-
- val totalVerticalDistance = depthMeters + freeboardMeters
-
- // Ensure we don't take asin of a value > 1 or < -1
- if (totalVerticalDistance > rodeOutMeters) {
- // Rode is too short for the depth+freeboard, effectively boat is directly above anchor
- // In this case, the watch circle radius is 0, or very small.
- return 0.0
- }
-
- // angle = asin( (Depth + Freeboard) / Rode Out )
- val angle = asin(totalVerticalDistance / rodeOutMeters)
-
- // Watch circle radius = Rode Out * cos(angle)
- return rodeOutMeters * cos(angle)
- }
- }
-
- fun isDragging(currentLocation: Location): Boolean {
- anchorLocation ?: return false // Cannot drag if anchor not set
- if (!isActive) return false // Not active, so not dragging
-
- val distance = anchorLocation.distanceTo(currentLocation)
- return distance > watchCircleRadiusMeters
- }
-}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
index 138fc6c..b18db8d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt
@@ -20,12 +20,18 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.asStateFlow
import org.terst.nav.nmea.NmeaParser
import org.terst.nav.nmea.NmeaStreamManager
import org.terst.nav.sensors.DepthData
import org.terst.nav.sensors.HeadingData
import org.terst.nav.sensors.WindData
import org.terst.nav.gps.GpsPosition
+import org.terst.nav.data.model.SensorData
+import org.terst.nav.safety.AnchorWatchState
+import org.terst.nav.wind.TrueWindCalculator
+import org.terst.nav.wind.ApparentWind
+import org.terst.nav.wind.TrueWindData
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.CoroutineScope
@@ -34,22 +40,23 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
-data class GpsData(
- val latitude: Double,
- val longitude: Double,
- val speedOverGround: Float, // m/s
- val courseOverGround: Float // degrees
-) {
- fun toLocation(): Location {
- val location = Location("GpsData")
- location.latitude = latitude
- location.longitude = longitude
- location.speed = speedOverGround
- location.bearing = courseOverGround
- return location
- }
-}
-
+/** Source of the currently active GPS fix. */
+enum class GpsSource { NONE, NMEA, ANDROID }
+
+/**
+ * Point-in-time snapshot of wind and current conditions.
+ */
+data class EnvironmentalSnapshot(
+ val windSpeedKt: Double?,
+ val windDirectionDeg: Double?,
+ val currentSpeedKt: Double?,
+ val currentDirectionDeg: Double?
+)
+
+/**
+ * Aggregates real-time location and environmental sensor data for use throughout
+ * the navigation subsystem.
+ */
class LocationService : Service() {
private lateinit var fusedLocationClient: FusedLocationProviderClient
@@ -60,22 +67,30 @@ class LocationService : Service() {
private lateinit var nmeaStreamManager: NmeaStreamManager
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val windCalculator = TrueWindCalculator()
+
+ // GPS sensor fusion state
+ private var lastNmeaPosition: GpsPosition? = null
+ private var lastAndroidPosition: GpsPosition? = null
+ private val nmeaStalenessThresholdMs: Long = 5_000L
+ private val nmeaExtendedThresholdMs: Long = 10_000L
+
private val NOTIFICATION_CHANNEL_ID = "location_service_channel"
private val NOTIFICATION_ID = 123
- private var isAlarmTriggered = false // To prevent repeated alarm triggering
+ private var isAlarmTriggered = false
override fun onCreate() {
super.onCreate()
Log.d("LocationService", "Service created")
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
- anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context
+ anchorAlarmManager = AnchorAlarmManager(this)
barometerSensorManager = BarometerSensorManager(this)
nmeaParser = NmeaParser()
nmeaStreamManager = NmeaStreamManager(nmeaParser, serviceScope)
createNotificationChannel()
- // Observe barometer status and update our public state
+ // Observe barometer status
serviceScope.launch {
barometerSensorManager.barometerStatus.collect { status ->
_barometerStatus.value = status
@@ -84,14 +99,17 @@ class LocationService : Service() {
// Collect NMEA GPS positions
serviceScope.launch {
- nmeaStreamManager.nmeaGpsPosition.collectLatest { gpsPosition ->
- _nmeaGpsPositionFlow.emit(gpsPosition)
+ nmeaStreamManager.nmeaGpsPosition.collectLatest { position ->
+ lastNmeaPosition = position
+ recomputeBestPosition()
+ _nmeaGpsPositionFlow.emit(position)
}
}
// Collect NMEA Wind Data
serviceScope.launch {
nmeaStreamManager.nmeaWindData.collectLatest { windData ->
+ updateTrueWindFromNmea(windData)
_nmeaWindDataFlow.emit(windData)
}
}
@@ -110,26 +128,22 @@ class LocationService : Service() {
}
}
- // Mock tidal current data generator
- serviceScope.launch {
- while (true) {
- val currents = MockTidalCurrentGenerator.generateMockCurrents()
- _tidalCurrentState.update { it.copy(currents = currents) }
- kotlinx.coroutines.delay(60000) // Update every minute
- }
- }
-
locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
locationResult.lastLocation?.let { location ->
- val gpsData = GpsData(
+ val position = GpsPosition(
latitude = location.latitude,
longitude = location.longitude,
- speedOverGround = location.speed,
- courseOverGround = location.bearing
+ sog = location.speed * 1.94384, // m/s to knots
+ cog = location.bearing.toDouble(),
+ timestampMs = location.time,
+ accuracyMeters = if (location.hasAccuracy()) location.accuracy.toDouble() else null
)
+ lastAndroidPosition = position
+ recomputeBestPosition()
+
serviceScope.launch {
- _locationFlow.emit(gpsData) // Emit to shared flow (Android system GPS)
+ _locationFlow.emit(position)
}
// Check for anchor drag if anchor watch is active
@@ -139,32 +153,71 @@ class LocationService : Service() {
}
}
- /**
- * Checks if the current location is outside the anchor watch circle.
- */
+ private fun recomputeBestPosition() {
+ val now = System.currentTimeMillis()
+ 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 ->
+ 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
+ else -> null to GpsSource.NONE
+ }
+
+ _bestPosition.value = best
+ _activeGpsSource.value = source
+ }
+
+ private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean {
+ val thisAccuracy = accuracyMeters ?: return false
+ val otherAccuracy = other.accuracyMeters ?: return true
+ return thisAccuracy < otherAccuracy
+ }
+
+ private fun updateTrueWindFromNmea(wind: WindData) {
+ val sog = _bestPosition.value?.sog
+ val hdg = _nmeaHeadingDataFlow.replayCache.firstOrNull()?.headingDegreesTrue
+
+ if (sog != null && hdg != null) {
+ _latestTrueWind.value = windCalculator.update(
+ apparent = ApparentWind(speedKt = wind.windSpeed, angleDeg = wind.windAngle),
+ bsp = sog, // Use SOG as proxy for BSP if BSP is not available
+ hdgDeg = hdg
+ )
+ }
+ }
+
private fun checkAnchorDrag(location: Location) {
_anchorWatchState.update { currentState ->
if (currentState.isActive && currentState.anchorLocation != null) {
val isDragging = currentState.isDragging(location)
if (isDragging) {
- Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
+ Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!!")
if (!isAlarmTriggered) {
anchorAlarmManager.startAlarm()
isAlarmTriggered = true
}
} else {
- Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m")
if (isAlarmTriggered) {
anchorAlarmManager.stopAlarm()
isAlarmTriggered = false
}
}
- } else {
- // If anchor watch is not active, ensure alarm is stopped
- if (isAlarmTriggered) {
- anchorAlarmManager.stopAlarm()
- isAlarmTriggered = false
- }
+ } else if (isAlarmTriggered) {
+ anchorAlarmManager.stopAlarm()
+ isAlarmTriggered = false
}
currentState
}
@@ -173,24 +226,21 @@ class LocationService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START_FOREGROUND_SERVICE -> {
- Log.d("LocationService", "Starting foreground service")
startForeground(NOTIFICATION_ID, createNotification())
serviceScope.launch {
- _currentPowerMode.emit(PowerMode.FULL) // Set initial power mode to FULL
+ _currentPowerMode.emit(PowerMode.FULL)
startLocationUpdatesInternal(PowerMode.FULL)
}
barometerSensorManager.start()
nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT)
}
ACTION_STOP_FOREGROUND_SERVICE -> {
- Log.d("LocationService", "Stopping foreground service")
stopLocationUpdatesInternal()
barometerSensorManager.stop()
nmeaStreamManager.stop()
stopSelf()
}
ACTION_START_ANCHOR_WATCH -> {
- Log.d("LocationService", "Received ACTION_START_ANCHOR_WATCH")
val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
serviceScope.launch {
startAnchorWatch(radius)
@@ -198,45 +248,34 @@ class LocationService : Service() {
}
}
ACTION_STOP_ANCHOR_WATCH -> {
- Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH")
stopAnchorWatch()
- setPowerMode(PowerMode.FULL) // Revert to full power mode after stopping anchor watch
+ setPowerMode(PowerMode.FULL)
}
ACTION_UPDATE_WATCH_RADIUS -> {
- Log.d("LocationService", "Received ACTION_UPDATE_WATCH_RADIUS")
val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS)
updateWatchCircleRadius(radius)
}
- ACTION_TOGGLE_TIDAL_VISIBILITY -> {
- val isVisible = intent.getBooleanExtra(EXTRA_TIDAL_VISIBILITY, false)
- _tidalCurrentState.update { it.copy(isVisible = isVisible) }
- }
}
return START_NOT_STICKY
}
- override fun onBind(intent: Intent?): IBinder? {
- return null // Not a bound service
- }
+ override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
- Log.d("LocationService", "Service destroyed")
stopLocationUpdatesInternal()
anchorAlarmManager.stopAlarm()
barometerSensorManager.stop()
- nmeaStreamManager.stop() // Stop NMEA stream when service is destroyed
+ nmeaStreamManager.stop()
_anchorWatchState.value = AnchorWatchState(isActive = false)
- isAlarmTriggered = false // Reset alarm trigger state
- serviceScope.cancel() // Cancel the coroutine scope
+ isAlarmTriggered = false
+ serviceScope.cancel()
}
-
@SuppressLint("MissingPermission")
private fun startLocationUpdatesInternal(powerMode: PowerMode) {
- Log.d("LocationService", "Requesting location updates with PowerMode: ${powerMode.name}, interval: ${powerMode.gpsUpdateIntervalMillis}ms")
val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, powerMode.gpsUpdateIntervalMillis)
- .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2) // Half the interval for minUpdateInterval
+ .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2)
.build()
fusedLocationClient.requestLocationUpdates(
locationRequest,
@@ -246,22 +285,15 @@ class LocationService : Service() {
}
private fun stopLocationUpdatesInternal() {
- Log.d("LocationService", "Removing location updates")
fusedLocationClient.removeLocationUpdates(locationCallback)
}
fun setPowerMode(powerMode: PowerMode) {
serviceScope.launch {
if (_currentPowerMode.value != powerMode) {
- // Emit the new power mode first
_currentPowerMode.emit(powerMode)
- Log.d("LocationService", "Power mode changing to ${powerMode.name}. Restarting location updates.")
- // Stop current updates if running
stopLocationUpdatesInternal()
- // Start new updates with the new power mode's interval
startLocationUpdatesInternal(powerMode)
- } else {
- Log.d("LocationService", "Power mode already ${powerMode.name}. No change needed.")
}
}
}
@@ -278,25 +310,15 @@ class LocationService : Service() {
private fun createNotification(): Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(
- this,
- 0,
- notificationIntent,
- PendingIntent.FLAG_IMMUTABLE
- )
-
+ val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("Sailing Companion")
- .setContentText("Tracking your location in the background...")
+ .setContentText("Tracking your location...")
.setSmallIcon(R.drawable.ic_anchor)
.setContentIntent(pendingIntent)
.build()
}
- /**
- * Starts the anchor watch with the current location as the anchor point.
- * @param radiusMeters The watch circle radius in meters.
- */
@SuppressLint("MissingPermission")
suspend fun startAnchorWatch(radiusMeters: Double = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) {
val lastLocation = fusedLocationClient.lastLocation.await()
@@ -307,29 +329,27 @@ class LocationService : Service() {
setTimeMillis = System.currentTimeMillis(),
isActive = true
) }
- Log.i("AnchorWatch", "Anchor watch started at lat: ${location.latitude}, lon: ${location.longitude} with radius: ${radiusMeters}m")
- } ?: run {
- Log.e("AnchorWatch", "Could not start anchor watch: Last known location is null.")
- // Handle error, e.g., show a toast to the user
}
}
- /**
- * Stops the anchor watch.
- */
fun stopAnchorWatch() {
_anchorWatchState.update { AnchorWatchState(isActive = false) }
- Log.i("AnchorWatch", "Anchor watch stopped.")
anchorAlarmManager.stopAlarm()
isAlarmTriggered = false
}
- /**
- * Updates the watch circle radius.
- */
fun updateWatchCircleRadius(radiusMeters: Double) {
_anchorWatchState.update { it.copy(watchCircleRadiusMeters = radiusMeters) }
- Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.")
+ }
+
+ fun snapshot(): EnvironmentalSnapshot {
+ val trueWind = _latestTrueWind.value
+ return EnvironmentalSnapshot(
+ windSpeedKt = trueWind?.speedKt,
+ windDirectionDeg = trueWind?.directionDeg,
+ currentSpeedKt = null, // TODO: Pull from latest forecast
+ currentDirectionDeg = null
+ )
}
companion object {
@@ -338,56 +358,42 @@ class LocationService : Service() {
const val ACTION_START_ANCHOR_WATCH = "ACTION_START_ANCHOR_WATCH"
const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH"
const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS"
- const val ACTION_TOGGLE_TIDAL_VISIBILITY = "ACTION_TOGGLE_TIDAL_VISIBILITY"
const val EXTRA_WATCH_RADIUS = "extra_watch_radius"
- const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility"
-
- // NMEA Gateway configuration (example values - these should ideally be configurable by the user)
- private const val NMEA_GATEWAY_IP = "192.168.1.1" // Placeholder IP address
- private const val NMEA_GATEWAY_PORT = 10110 // Default NMEA port
-
- // Publicly accessible flows
- val locationFlow: SharedFlow<GpsData>
- get() = _locationFlow
- val anchorWatchState: StateFlow<AnchorWatchState>
- get() = _anchorWatchState
- val tidalCurrentState: StateFlow<TidalCurrentState>
- get() = _tidalCurrentState
- val barometerStatus: StateFlow<BarometerStatus>
- get() = _barometerStatus
-
- // NMEA Data Flows
- val nmeaGpsPositionFlow: SharedFlow<GpsPosition>
- get() = _nmeaGpsPositionFlow
- val nmeaWindDataFlow: SharedFlow<WindData>
- get() = _nmeaWindDataFlow
- val nmeaDepthDataFlow: SharedFlow<DepthData>
- get() = _nmeaDepthDataFlow
- val nmeaHeadingDataFlow: SharedFlow<HeadingData>
- get() = _nmeaHeadingDataFlow
-
- private val _locationFlow = MutableSharedFlow<GpsData>(replay = 1)
+
+ private const val NMEA_GATEWAY_IP = "192.168.1.1"
+ private const val NMEA_GATEWAY_PORT = 10110
+
+ private val _locationFlow = MutableSharedFlow<GpsPosition>(replay = 1)
+ val locationFlow: SharedFlow<GpsPosition> get() = _locationFlow
+
+ private val _bestPosition = MutableStateFlow<GpsPosition?>(null)
+ val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow()
+
+ private val _activeGpsSource = MutableStateFlow(GpsSource.NONE)
+ val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow()
+
private val _anchorWatchState = MutableStateFlow(AnchorWatchState())
- private val _tidalCurrentState = MutableStateFlow(TidalCurrentState())
+ val anchorWatchState: StateFlow<AnchorWatchState> get() = _anchorWatchState
+
private val _barometerStatus = MutableStateFlow(BarometerStatus())
+ val barometerStatus: StateFlow<BarometerStatus> get() = _barometerStatus
- // Private NMEA Data Flows
- private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
- private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
- private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
- private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(
- replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST
- )
+ private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null)
+ val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow()
+
+ private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(replay = 1)
+ val nmeaGpsPositionFlow: SharedFlow<GpsPosition> get() = _nmeaGpsPositionFlow
+
+ private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(replay = 1)
+ val nmeaWindDataFlow: SharedFlow<WindData> get() = _nmeaWindDataFlow
+
+ private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(replay = 1)
+ val nmeaDepthDataFlow: SharedFlow<DepthData> get() = _nmeaDepthDataFlow
+
+ private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(replay = 1)
+ val nmeaHeadingDataFlow: SharedFlow<HeadingData> get() = _nmeaHeadingDataFlow
private val _currentPowerMode = MutableStateFlow(PowerMode.FULL)
- val currentPowerMode: StateFlow<PowerMode>
- get() = _currentPowerMode
+ val currentPowerMode: StateFlow<PowerMode> get() = _currentPowerMode
}
}
-
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 3f09309..fd2cf61 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
@@ -39,6 +39,7 @@ import org.terst.nav.ui.doc.DocFragment
import org.terst.nav.ui.safety.SafetyFragment
import org.terst.nav.ui.voicelog.VoiceLogFragment
import java.util.*
+import org.terst.nav.safety.AnchorWatchState
class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
@@ -46,7 +47,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private var mobHandler: MobHandler? = null
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>
@@ -186,7 +186,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
override fun onConfigureAnchor() {
- anchorWatchHandler?.toggleVisibility()
+ // Now handled via fragment navigation from SafetyFragment
}
private fun setupHandlers() {
@@ -305,13 +305,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude)
- mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.courseOverGround)
- val sogKnots = gpsData.speedOverGround * 1.94384
- val cogDeg = gpsData.courseOverGround
- viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, cogDeg.toDouble())
+ mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.cog.toFloat())
+
+ viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog)
instrumentHandler?.updateDisplay(
- sog = "%.1f".format(Locale.getDefault(), sogKnots),
- cog = "%.0f°".format(Locale.getDefault(), cogDeg)
+ sog = "%.1f".format(Locale.getDefault(), gpsData.sog),
+ cog = "%.0f°".format(Locale.getDefault(), gpsData.cog)
)
if (!conditionsLoaded) {
conditionsLoaded = true
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt
index d427a5d..fc1d79d 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp.data.model
+package org.terst.nav.data.model
data class SensorData(
val latitude: Double? = null,
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
index e17e5ca..6a976f6 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt
@@ -5,38 +5,20 @@ import org.terst.nav.data.model.GribRegion
import java.time.Instant
interface GribFileManager {
- /** Save metadata for a newly-downloaded GRIB file. */
fun saveMetadata(file: GribFile)
- /** Return all stored GRIB files for [region], newest first. */
fun listFiles(region: GribRegion): List<GribFile>
- /** Return the most-recently-downloaded GRIB file for [region], or null if none. */
fun latestFile(region: GribRegion): GribFile?
- /** Delete a specific GRIB file's metadata and from disk. Returns true if deleted. */
fun delete(file: GribFile): Boolean
- /** Delete all GRIB files older than [before]. Returns count of deleted files. */
fun purgeOlderThan(before: Instant): Int
- /** Total size in bytes of all stored GRIB files. */
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 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 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/org/terst/nav/data/weather/GribStalenessChecker.kt
index 70f36d9..f39957b 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt
@@ -1,7 +1,7 @@
-package com.example.androidapp.data.weather
+package org.terst.nav.data.weather
import org.terst.nav.data.model.GribFile
-import com.example.androidapp.data.storage.GribFileManager
+import org.terst.nav.data.storage.GribFileManager
import org.terst.nav.data.model.GribRegion
import java.time.Instant
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt
index 6e565b7..875d971 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt
@@ -1,10 +1,10 @@
-package com.example.androidapp.data.weather
+package org.terst.nav.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 org.terst.nav.data.storage.GribFileManager
import java.time.Instant
import kotlin.math.ceil
import kotlin.math.floor
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
index 5faf30c..99cef2d 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt
@@ -1,9 +1,20 @@
package org.terst.nav.gps
+/**
+ * Represents a single GPS fix.
+ *
+ * @param latitude Degrees, positive = North, negative = South.
+ * @param longitude Degrees, positive = East, negative = West.
+ * @param sog Speed Over Ground in knots.
+ * @param cog Course Over Ground in degrees true (0-360).
+ * @param timestampMs Unix epoch milliseconds UTC.
+ * @param accuracyMeters Estimated horizontal accuracy (1-sigma) in meters; null if unknown.
+ */
data class GpsPosition(
val latitude: Double,
val longitude: Double,
- val sog: Double, // knots
- val cog: Double, // degrees true
- val timestampMs: Long
+ val sog: Double,
+ val cog: Double,
+ val timestampMs: Long,
+ val accuracyMeters: Double? = null
)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt
index d4cf50d..67cfcce 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp.logbook
+package org.terst.nav.logbook
import org.terst.nav.data.model.LogbookEntry
import java.util.Calendar
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt
index 78ea834..6417db9 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp.logbook
+package org.terst.nav.logbook
import android.graphics.Canvas
import android.graphics.Color
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
index 453c758..6a470b8 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt
@@ -273,8 +273,9 @@ class NmeaParser {
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) {
- (timeStr.substring(7).toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
+ val millis = if (timeStr.contains('.')) {
+ val fracStr = timeStr.substringAfter('.')
+ ("0.$fracStr".toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
} else 0
cal.set(Calendar.HOUR_OF_DAY, hours)
cal.set(Calendar.MINUTE, minutes)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt
index 60a5918..13fb132 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp.routing
+package org.terst.nav.routing
/**
* The result of an isochrone weather routing computation.
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt
index 901fdbc..8ac73cf 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp.routing
+package org.terst.nav.routing
import org.terst.nav.data.model.BoatPolars
import org.terst.nav.data.model.WindForecast
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt
index 02988d1..a6562d9 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp.routing
+package org.terst.nav.routing
/**
* A single point in the isochrone routing tree.
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt
new file mode 100644
index 0000000..9121ce6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt
@@ -0,0 +1,40 @@
+package org.terst.nav.safety
+
+import android.location.Location
+import kotlin.math.*
+
+/**
+ * Holds state for the anchor watch and provides the suggested watch-circle radius.
+ */
+data class AnchorWatchState(
+ val anchorLocation: Location? = null,
+ val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS,
+ val setTimeMillis: Long = 0L,
+ val isActive: Boolean = false
+) {
+ companion object {
+ const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0
+
+ /**
+ * Calculates the recommended watch circle radius based on depth, freeboard, and rode out.
+ */
+ fun calculateRecommendedWatchCircleRadius(
+ depthMeters: Double,
+ freeboardMeters: Double,
+ rodeOutMeters: Double
+ ): Double {
+ if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) return 0.0
+ val totalVerticalDistance = depthMeters + freeboardMeters
+ if (totalVerticalDistance > rodeOutMeters) return 0.0
+ val angle = asin(totalVerticalDistance / rodeOutMeters)
+ return rodeOutMeters * cos(angle)
+ }
+ }
+
+ fun isDragging(currentLocation: Location): Boolean {
+ anchorLocation ?: return false
+ if (!isActive) return false
+ val distance = anchorLocation.distanceTo(currentLocation)
+ return distance > watchCircleRadiusMeters
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt
index 2bdbf6c..b1e5652 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp.tide
+package org.terst.nav.tide
import com.example.androidapp.data.model.TidePrediction
import com.example.androidapp.data.model.TideStation
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt
deleted file mode 100644
index d55de90..0000000
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt
+++ /dev/null
@@ -1,99 +0,0 @@
-package org.terst.nav.ui
-
-import android.content.Context
-import android.content.Intent
-import android.view.View
-import android.widget.Button
-import android.widget.TextView
-import android.widget.Toast
-import androidx.constraintlayout.widget.ConstraintLayout
-import org.terst.nav.AnchorWatchState
-import org.terst.nav.LocationService
-import java.util.Locale
-
-/**
- * Handles the Anchor Watch UI interactions and state updates.
- */
-class AnchorWatchHandler(
- private val context: Context,
- private val container: ConstraintLayout,
- private val statusText: TextView,
- private val radiusText: TextView,
- private val buttonDecrease: Button,
- private val buttonIncrease: Button,
- private val buttonSet: Button,
- private val buttonStop: Button
-) {
- private var currentRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS
-
- init {
- updateRadiusDisplay()
-
- buttonDecrease.setOnClickListener {
- updateRadius((currentRadius - 5).coerceAtLeast(10.0))
- }
-
- buttonIncrease.setOnClickListener {
- updateRadius((currentRadius + 5).coerceAtMost(200.0))
- }
-
- buttonSet.setOnClickListener {
- startWatch()
- }
-
- buttonStop.setOnClickListener {
- stopWatch()
- }
- }
-
- private fun updateRadius(newRadius: Double) {
- currentRadius = newRadius
- updateRadiusDisplay()
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_UPDATE_WATCH_RADIUS
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius)
- }
- context.startService(intent)
- }
-
- private fun updateRadiusDisplay() {
- radiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentRadius)
- }
-
- private fun startWatch() {
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_START_ANCHOR_WATCH
- putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius)
- }
- context.startService(intent)
- Toast.makeText(context, "Anchor watch set!", Toast.LENGTH_SHORT).show()
- }
-
- private fun stopWatch() {
- val intent = Intent(context, LocationService::class.java).apply {
- action = LocationService.ACTION_STOP_ANCHOR_WATCH
- }
- context.startService(intent)
- Toast.makeText(context, "Anchor watch stopped.", Toast.LENGTH_SHORT).show()
- }
-
- /**
- * Updates the UI based on the current anchor watch state.
- */
- fun updateUI(state: AnchorWatchState) {
- statusText.text = if (state.isActive) {
- "STATUS: ACTIVE" // Simple status for UI
- } else {
- "STATUS: INACTIVE"
- }
- currentRadius = state.watchCircleRadiusMeters
- updateRadiusDisplay()
- }
-
- /**
- * Toggles the visibility of the anchor configuration container.
- */
- fun toggleVisibility() {
- container.visibility = if (container.visibility == View.VISIBLE) View.GONE else View.VISIBLE
- }
-}
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 4f08de7..bfefb6f 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
@@ -19,7 +19,7 @@ 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.safety.AnchorWatchState
import org.terst.nav.TidalCurrentState
import org.terst.nav.track.TrackPoint
import kotlin.math.cos
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt
index 289a857..d435f00 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp.ui.anchorwatch
+package org.terst.nav.ui.anchorwatch
import android.os.Bundle
import android.text.Editable
@@ -9,7 +9,7 @@ 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
+import org.terst.nav.safety.AnchorWatchState
class AnchorWatchHandler : Fragment() {
@@ -43,7 +43,7 @@ class AnchorWatchHandler : Fragment() {
val rode = binding.etRodeOut.text.toString().toDoubleOrNull()
if (depth != null && rode != null && depth >= 0.0 && rode > 0.0) {
- val radius = anchorWatchState.calculateRecommendedWatchCircleRadius(depth, rode)
+ val radius = AnchorWatchState.calculateRecommendedWatchCircleRadius(depth, 2.0, rode)
binding.tvSuggestedRadius.text =
getString(R.string.anchor_suggested_radius_fmt, radius)
} else {
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt
index 01656a3..fd504cb 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt
@@ -1,3 +1,3 @@
-package com.example.androidapp.wind
+package org.terst.nav.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/org/terst/nav/wind/TrueWindCalculator.kt
index db32163..dc3117c 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt
@@ -1,4 +1,4 @@
-package com.example.androidapp.wind
+package org.terst.nav.wind
import kotlin.math.atan2
import kotlin.math.cos
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt
index 78e9558..8c3ac56 100644
--- a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt
@@ -1,3 +1,3 @@
-package com.example.androidapp.wind
+package org.terst.nav.wind
data class TrueWindData(val speedKt: Double, val directionDeg: Double)
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
deleted file mode 100644
index 535e46a..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-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
deleted file mode 100644
index 4bf7985..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt
+++ /dev/null
@@ -1,180 +0,0 @@
-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
deleted file mode 100644
index 8b2753c..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-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
deleted file mode 100644
index 4eb9898..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
+++ /dev/null
@@ -1,317 +0,0 @@
-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
deleted file mode 100644
index 30b421f..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt
+++ /dev/null
@@ -1,178 +0,0 @@
-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
deleted file mode 100644
index b8a878a..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt
+++ /dev/null
@@ -1,105 +0,0 @@
-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
deleted file mode 100644
index e5615e9..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt
+++ /dev/null
@@ -1,169 +0,0 @@
-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
deleted file mode 100644
index 40f7df0..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-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
deleted file mode 100644
index 612ae34..0000000
--- a/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt
+++ /dev/null
@@ -1,135 +0,0 @@
-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/repository/WeatherRepositoryTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt
index 749630f..c455085 100644
--- a/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt
+++ b/android-app/app/src/test/kotlin/org/terst/nav/data/repository/WeatherRepositoryTest.kt
@@ -3,8 +3,7 @@ package org.terst.nav.data.repository
import org.terst.nav.data.api.MarineApiService
import org.terst.nav.data.api.WeatherApiService
import org.terst.nav.data.model.*
-import io.mockk.coEvery
-import io.mockk.mockk
+import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Before
@@ -36,6 +35,9 @@ class WeatherRepositoryTest {
time = listOf("2026-03-13T00:00", "2026-03-13T01:00"),
waveHeight = listOf(1.2, 1.1),
waveDirection = listOf(250.0, 255.0),
+ swellWaveHeight = emptyList(),
+ swellWaveDirection = emptyList(),
+ swellWavePeriod = emptyList(),
oceanCurrentVelocity = listOf(0.3, 0.4),
oceanCurrentDirection = listOf(180.0, 185.0)
)
@@ -48,7 +50,7 @@ class WeatherRepositoryTest {
@Test
fun `fetchForecastItems maps weather response to ForecastItem list`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } returns weatherResponse
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), any(), any()) } returns weatherResponse
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchForecastItems(37.5, -122.3)
@@ -65,8 +67,25 @@ class WeatherRepositoryTest {
}
@Test
+ fun `fetchCurrentConditions maps responses to MarineConditions`() = runTest {
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), eq(1), any()) } returns weatherResponse
+ coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
+
+ val result = repo.fetchCurrentConditions(37.5, -122.3)
+
+ if (result.isFailure) {
+ fail("fetchCurrentConditions failed with: ${result.exceptionOrNull()}")
+ }
+ val cond = result.getOrThrow()
+ assertEquals(15.0, cond.windSpeedKt!!, 0.001)
+ assertEquals(1.2, cond.waveHeightM!!, 0.001)
+ assertEquals(0.3 * 1.94384, cond.currentSpeedKt!!, 0.001)
+ assertEquals(180.0, cond.currentDirDeg!!, 0.001)
+ }
+
+ @Test
fun `fetchWindArrow returns WindArrow for first (current) hour`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } returns weatherResponse
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), eq(1), any()) } returns weatherResponse
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchWindArrow(37.5, -122.3)
@@ -81,7 +100,7 @@ class WeatherRepositoryTest {
@Test
fun `fetchForecastItems returns failure when weather API throws`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } throws RuntimeException("Network error")
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), any(), any()) } throws RuntimeException("Network error")
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchForecastItems(37.5, -122.3)
@@ -91,7 +110,7 @@ class WeatherRepositoryTest {
@Test
fun `fetchWindArrow returns failure when API throws`() = runTest {
- coEvery { weatherApi.getWeatherForecast(any(), any()) } throws RuntimeException("Timeout")
+ coEvery { weatherApi.getWeatherForecast(any(), any(), any(), eq(1), any()) } throws RuntimeException("Timeout")
coEvery { marineApi.getMarineForecast(any(), any()) } returns marineResponse
val result = repo.fetchWindArrow(37.5, -122.3)