summaryrefslogtreecommitdiff
path: root/android-app/app/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'android-app/app/src/main')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt10
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt24
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt36
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt134
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt10
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt216
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt81
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt137
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt94
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt12
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt178
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt16
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt24
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt88
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt58
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt3
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt20
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt3
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt39
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt69
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt35
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt24
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt19
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt27
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt9
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt7
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt11
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt18
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt12
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt26
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt33
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt29
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt0
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt0
-rw-r--r--android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt0
-rw-r--r--android-app/app/src/main/res/drawable/ic_track_record.xml17
-rw-r--r--android-app/app/src/main/res/layout/activity_main.xml15
-rw-r--r--android-app/app/src/main/res/layout/fragment_anchor_watch.xml79
-rw-r--r--android-app/app/src/main/res/layout/fragment_map.xml14
-rwxr-xr-xandroid-app/app/src/main/res/values/strings.xml10
41 files changed, 1596 insertions, 59 deletions
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt
new file mode 100644
index 0000000..d427a5d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt
@@ -0,0 +1,10 @@
+package com.example.androidapp.data.model
+
+data class SensorData(
+ val latitude: Double? = null,
+ val longitude: Double? = null,
+ val headingTrueDeg: Double? = null,
+ val apparentWindSpeedKt: Double? = null,
+ val apparentWindAngleDeg: Double? = null,
+ val speedOverGroundKt: Double? = null
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt
new file mode 100644
index 0000000..d6f685a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/storage/GribFileManager.kt
@@ -0,0 +1,24 @@
+package com.example.androidapp.data.storage
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+interface GribFileManager {
+ fun saveMetadata(file: GribFile)
+ fun listFiles(region: GribRegion): List<GribFile>
+ fun latestFile(region: GribRegion): GribFile?
+ fun delete(file: GribFile): Boolean
+ fun purgeOlderThan(before: Instant): Int
+ fun totalSizeBytes(): Long
+}
+
+class InMemoryGribFileManager : GribFileManager {
+ private val files = mutableListOf<GribFile>()
+ override fun saveMetadata(file: GribFile) { files.add(file) }
+ override fun listFiles(region: GribRegion): List<GribFile> = files.filter { it.region.name == region.name }.sortedByDescending { it.downloadedAt }
+ override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull()
+ override fun delete(file: GribFile): Boolean = files.remove(file)
+ override fun purgeOlderThan(before: Instant): Int { val toRemove = files.filter { it.downloadedAt.isBefore(before) }; files.removeAll(toRemove); return toRemove.size }
+ override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt
new file mode 100644
index 0000000..70f36d9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt
@@ -0,0 +1,36 @@
+package com.example.androidapp.data.weather
+
+import org.terst.nav.data.model.GribFile
+import com.example.androidapp.data.storage.GribFileManager
+import org.terst.nav.data.model.GribRegion
+import java.time.Instant
+
+/** Outcome of a freshness check. */
+sealed class FreshnessResult {
+ /** Data is current; no user action needed. */
+ object Fresh : FreshnessResult()
+ /** Data is stale; user should re-download. [message] is shown in the UI badge. */
+ data class Stale(val file: GribFile, val message: String) : FreshnessResult()
+ /** No local GRIB data exists for this region. */
+ object NoData : FreshnessResult()
+}
+
+/**
+ * Checks whether locally-stored GRIB data for a region is fresh or stale.
+ * Per design doc §6.3: GRIB weather valid until model run + forecast hour; stale after.
+ */
+class GribStalenessChecker(private val manager: GribFileManager) {
+
+ /**
+ * Check freshness of the most-recent GRIB file for [region] relative to [now].
+ */
+ fun check(region: GribRegion, now: Instant = Instant.now()): FreshnessResult {
+ val latest = manager.latestFile(region) ?: return FreshnessResult.NoData
+ return if (latest.isStale(now)) {
+ val hoursAgo = (now.epochSecond - latest.validUntil().epochSecond) / 3600
+ FreshnessResult.Stale(latest, "Weather data outdated by ${hoursAgo}h — tap to refresh")
+ } else {
+ FreshnessResult.Fresh
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt
new file mode 100644
index 0000000..6e565b7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt
@@ -0,0 +1,134 @@
+package com.example.androidapp.data.weather
+
+import org.terst.nav.data.model.GribFile
+import org.terst.nav.data.model.GribParameter
+import org.terst.nav.data.model.GribRegion
+import org.terst.nav.data.model.SatelliteDownloadRequest
+import com.example.androidapp.data.storage.GribFileManager
+import java.time.Instant
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/**
+ * Downloads GRIB weather data over bandwidth-constrained satellite links (§9.1).
+ *
+ * Provides size and time estimates before fetching, and aborts if the download
+ * would exceed the configured size limit (default 2 MB — the upper bound stated
+ * in §9.1 for typical offshore GRIBs on satellite).
+ *
+ * The actual network fetch is supplied as a [fetcher] lambda so the class remains
+ * testable without network access.
+ */
+class SatelliteGribDownloader(private val fileManager: GribFileManager) {
+
+ companion object {
+ /** Iridium data link speed in bits per second. */
+ const val SATELLITE_BANDWIDTH_BPS = 2400L
+
+ /** GRIB2 packed grid value: ~2 bytes per grid point after packing. */
+ private const val BYTES_PER_GRID_POINT = 2L
+
+ /** Per-message header overhead in GRIB2 format (section 0-4). */
+ private const val HEADER_BYTES_PER_MESSAGE = 100L
+
+ /** Forecast time step used for size estimation (3-hourly is standard GFS output). */
+ private const val TIME_STEP_HOURS = 3
+
+ /** Default maximum download size; abort if estimate exceeds this. */
+ const val DEFAULT_SIZE_LIMIT_BYTES = 2_000_000L
+ }
+
+ /**
+ * Estimates the GRIB file size in bytes for [request].
+ *
+ * Formula: (gridPoints × timeSteps × paramCount × bytesPerPoint) + headerOverhead
+ * where gridPoints = ceil(latSpan/resolution + 1) × ceil(lonSpan/resolution + 1).
+ */
+ fun estimateSizeBytes(request: SatelliteDownloadRequest): Long {
+ val latPoints = floor((request.region.latMax - request.region.latMin) / request.resolutionDeg).toLong() + 1
+ val lonPoints = floor((request.region.lonMax - request.region.lonMin) / request.resolutionDeg).toLong() + 1
+ val gridPoints = latPoints * lonPoints
+ val timeSteps = ceil(request.forecastHours.toDouble() / TIME_STEP_HOURS).toLong()
+ val paramCount = request.parameters.size.toLong()
+ val dataBytes = gridPoints * timeSteps * paramCount * BYTES_PER_GRID_POINT
+ val headerBytes = paramCount * timeSteps * HEADER_BYTES_PER_MESSAGE
+ return dataBytes + headerBytes
+ }
+
+ /**
+ * Estimates how many seconds the download will take at [bandwidthBps] bits/second.
+ */
+ fun estimatedDownloadSeconds(
+ request: SatelliteDownloadRequest,
+ bandwidthBps: Long = SATELLITE_BANDWIDTH_BPS
+ ): Long = ceil(estimateSizeBytes(request) * 8.0 / bandwidthBps).toLong()
+
+ /**
+ * Convenience builder: creates a [SatelliteDownloadRequest] using the minimal
+ * satellite parameter set (wind speed + direction + surface pressure only).
+ */
+ fun buildMinimalRequest(
+ region: GribRegion,
+ forecastHours: Int,
+ resolutionDeg: Double = 1.0
+ ): SatelliteDownloadRequest = SatelliteDownloadRequest(
+ region = region,
+ parameters = GribParameter.SATELLITE_MINIMAL,
+ forecastHours = forecastHours,
+ resolutionDeg = resolutionDeg
+ )
+
+ /** Result of a satellite GRIB download attempt. */
+ sealed class DownloadResult {
+ /** Download succeeded; [file] metadata has been saved to [GribFileManager]. */
+ data class Success(val file: GribFile) : DownloadResult()
+ /** The [fetcher] returned no data or an unexpected error occurred. */
+ data class Failed(val reason: String) : DownloadResult()
+ /**
+ * Download was aborted before starting because the estimated size
+ * [estimatedBytes] exceeds the configured limit.
+ */
+ data class Aborted(val reason: String, val estimatedBytes: Long) : DownloadResult()
+ }
+
+ /**
+ * Downloads GRIB data for [request].
+ *
+ * 1. Estimates size; returns [DownloadResult.Aborted] if > [sizeLimitBytes].
+ * 2. Calls [fetcher] to retrieve raw bytes.
+ * 3. On success, saves metadata via [fileManager] and returns [DownloadResult.Success].
+ *
+ * @param request The bandwidth-optimised download request.
+ * @param fetcher Supplies raw GRIB bytes for the request; returns null on failure.
+ * @param outputPath Local file path where the caller will persist the bytes.
+ * @param sizeLimitBytes Abort threshold (default [DEFAULT_SIZE_LIMIT_BYTES]).
+ * @param now Timestamp injected for testing.
+ */
+ fun download(
+ request: SatelliteDownloadRequest,
+ fetcher: (SatelliteDownloadRequest) -> ByteArray?,
+ outputPath: String,
+ sizeLimitBytes: Long = DEFAULT_SIZE_LIMIT_BYTES,
+ now: Instant = Instant.now()
+ ): DownloadResult {
+ val estimated = estimateSizeBytes(request)
+ if (estimated > sizeLimitBytes) {
+ return DownloadResult.Aborted(
+ "Estimated size ${estimated / 1024}KB exceeds limit ${sizeLimitBytes / 1024}KB — " +
+ "reduce region, resolution, or forecast hours",
+ estimated
+ )
+ }
+ val bytes = fetcher(request) ?: return DownloadResult.Failed("Fetcher returned no data")
+ val gribFile = GribFile(
+ region = request.region,
+ modelRunTime = now,
+ forecastHours = request.forecastHours,
+ downloadedAt = now,
+ filePath = outputPath,
+ sizeBytes = bytes.size.toLong()
+ )
+ fileManager.saveMetadata(gribFile)
+ return DownloadResult.Success(gribFile)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt
new file mode 100644
index 0000000..cbe5c84
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt
@@ -0,0 +1,10 @@
+package com.example.androidapp.gps
+
+data class GpsPosition(
+ val latitude: Double, // degrees, positive = North
+ val longitude: Double, // degrees, positive = East
+ val sog: Double, // Speed Over Ground in knots
+ val cog: Double, // Course Over Ground in degrees true (0-360)
+ val timestampMs: Long, // Unix millis UTC
+ val accuracyMeters: Double? = null // estimated horizontal accuracy (1-sigma); null = unknown
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
new file mode 100644
index 0000000..0a315d4
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
@@ -0,0 +1,216 @@
+package com.example.androidapp.gps
+
+import com.example.androidapp.data.model.SensorData
+import com.example.androidapp.wind.TrueWindCalculator
+import com.example.androidapp.wind.ApparentWind
+import com.example.androidapp.wind.TrueWindData
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/** Source of the currently active GPS fix. */
+enum class GpsSource { NONE, NMEA, ANDROID }
+
+/**
+ * Aggregates real-time location and environmental sensor data for use throughout
+ * the safety subsystem (Section 4.6 of COMPONENT_DESIGN.md).
+ *
+ * ## GPS sensor fusion
+ * The service accepts fixes from two independent sources:
+ * - **NMEA GPS** — dedicated marine GPS received via [updateNmeaGps] (higher priority)
+ * - **Android GPS** — device built-in location via [updateAndroidGps] (fallback)
+ *
+ * Selection policy (evaluated on every new fix):
+ * 1. Prefer NMEA when its most recent fix is no older than [nmeaStalenessThresholdMs].
+ * 2. When NMEA is marginally stale (older than [nmeaStalenessThresholdMs] but within
+ * [nmeaExtendedThresholdMs]) **and** Android GPS is also available, compare
+ * [GpsPosition.accuracyMeters]: keep NMEA if its reported accuracy is strictly better
+ * (lower metres). Fall back to Android when accuracy is unavailable or Android wins.
+ * 3. Fall back to Android GPS when NMEA is very stale (beyond [nmeaExtendedThresholdMs]).
+ * 4. Use stale NMEA only when Android GPS has never provided a fix.
+ * 5. [bestPosition] is null until at least one source has reported.
+ *
+ * Call [updateSensorData] whenever new NMEA or Signal K sensor data arrives and
+ * [updateCurrentConditions] when a fresh marine-forecast response is received.
+ * Use [snapshot] to capture a point-in-time reading at safety-critical moments
+ * such as MOB activation.
+ *
+ * @param nmeaStalenessThresholdMs Maximum age (ms) of an NMEA fix before it enters the
+ * quality-comparison zone. Default: 5 000 ms.
+ * @param nmeaExtendedThresholdMs Maximum age (ms) up to which a marginally-stale NMEA fix
+ * can still win over Android if its [GpsPosition.accuracyMeters] is strictly better.
+ * Must be ≥ [nmeaStalenessThresholdMs]. Default: 10 000 ms.
+ * @param clockMs Injectable clock for unit-testable staleness checks.
+ */
+class LocationService(
+ private val windCalculator: TrueWindCalculator = TrueWindCalculator(),
+ private val nmeaStalenessThresholdMs: Long = 5_000L,
+ private val nmeaExtendedThresholdMs: Long = 10_000L,
+ private val clockMs: () -> Long = System::currentTimeMillis
+) {
+
+ private val _latestSensor = MutableStateFlow<SensorData?>(null)
+ /** The most recently received unified sensor reading. */
+ val latestSensor: StateFlow<SensorData?> = _latestSensor.asStateFlow()
+
+ private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null)
+ /** Most recent resolved true-wind vector, updated whenever a full sensor reading arrives. */
+ val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow()
+
+ private val _currentSpeedKt = MutableStateFlow<Double?>(null)
+ private val _currentDirectionDeg = MutableStateFlow<Double?>(null)
+
+ // ── GPS sensor fusion state ───────────────────────────────────────────────
+
+ private var lastNmeaPosition: GpsPosition? = null
+ private var lastAndroidPosition: GpsPosition? = null
+
+ private val _bestPosition = MutableStateFlow<GpsPosition?>(null)
+ /**
+ * The best available GPS fix, selected from NMEA and Android sources according
+ * to the fusion policy described in the class KDoc. Null until at least one
+ * source reports a fix.
+ */
+ val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow()
+
+ private val _activeGpsSource = MutableStateFlow(GpsSource.NONE)
+ /** The source that produced [bestPosition]. [GpsSource.NONE] before any fix arrives. */
+ val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow()
+
+ /**
+ * Ingest a new sensor reading. If the reading carries apparent wind, boat speed,
+ * and heading, true wind is resolved immediately via [TrueWindCalculator] and
+ * stored in [latestTrueWind].
+ */
+ fun updateSensorData(data: SensorData) {
+ _latestSensor.value = data
+
+ val aws = data.apparentWindSpeedKt
+ val awa = data.apparentWindAngleDeg
+ val bsp = data.speedOverGroundKt // use SOG as proxy when BSP is absent
+ val hdg = data.headingTrueDeg
+
+ if (aws != null && awa != null && bsp != null && hdg != null) {
+ _latestTrueWind.value = windCalculator.update(
+ apparent = ApparentWind(speedKt = aws, angleDeg = awa),
+ bsp = bsp,
+ hdgDeg = hdg
+ )
+ }
+ }
+
+ // ── GPS source ingestion ──────────────────────────────────────────────────
+
+ /**
+ * Ingest a new GPS fix from the NMEA source (e.g. a marine chartplotter or
+ * NMEA multiplexer). Triggers a fusion re-evaluation.
+ */
+ fun updateNmeaGps(position: GpsPosition) {
+ lastNmeaPosition = position
+ recomputeBestPosition()
+ }
+
+ /**
+ * Ingest a new GPS fix from the Android system location provider.
+ * Triggers a fusion re-evaluation.
+ */
+ fun updateAndroidGps(position: GpsPosition) {
+ lastAndroidPosition = position
+ recomputeBestPosition()
+ }
+
+ /**
+ * Selects the best GPS fix and updates [bestPosition] / [activeGpsSource].
+ *
+ * Priority tiers (in order):
+ * 1. Fresh NMEA (age ≤ [nmeaStalenessThresholdMs]) — always preferred.
+ * 2. Marginally-stale NMEA (age in (primary, extended] threshold) when Android is
+ * also available — keep NMEA only if its [GpsPosition.accuracyMeters] is strictly
+ * better than Android's; otherwise use Android.
+ * 3. Android GPS (any age) once NMEA is beyond the extended threshold.
+ * 4. Stale NMEA — used as last resort when Android has never reported.
+ */
+ private fun recomputeBestPosition() {
+ val now = clockMs()
+ val nmea = lastNmeaPosition
+ val android = lastAndroidPosition
+
+ val nmeaAge = nmea?.let { now - it.timestampMs }
+ val nmeaFresh = nmeaAge != null && nmeaAge <= nmeaStalenessThresholdMs
+ val nmeaMarginallyStale = nmeaAge != null &&
+ nmeaAge > nmeaStalenessThresholdMs &&
+ nmeaAge <= nmeaExtendedThresholdMs
+
+ val (best, source) = when {
+ nmeaFresh -> nmea!! to GpsSource.NMEA
+
+ nmeaMarginallyStale && android != null ->
+ // Quality tie-break: NMEA wins only when it has a strictly better accuracy.
+ if (nmea!!.hasStrictlyBetterAccuracyThan(android)) nmea to GpsSource.NMEA
+ else android to GpsSource.ANDROID
+
+ android != null -> android to GpsSource.ANDROID
+ nmea != null -> nmea to GpsSource.NMEA // only source, however stale
+ else -> null to GpsSource.NONE
+ }
+
+ _bestPosition.value = best
+ _activeGpsSource.value = source
+ }
+
+ // ── private helpers ───────────────────────────────────────────────────────
+
+ /**
+ * Returns true when this fix carries an accuracy estimate that is numerically
+ * smaller (i.e. better) than [other]'s. Returns false when either estimate is
+ * absent — conservatively preferring the other source when quality is unknown.
+ */
+ private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean {
+ val thisAccuracy = accuracyMeters ?: return false
+ val otherAccuracy = other.accuracyMeters ?: return true
+ return thisAccuracy < otherAccuracy
+ }
+
+ /**
+ * Update the ocean current conditions from the latest marine-forecast response.
+ *
+ * @param speedKt Current speed in knots (null to clear)
+ * @param directionDeg Direction the current flows TOWARD, in degrees (null to clear)
+ */
+ fun updateCurrentConditions(speedKt: Double?, directionDeg: Double?) {
+ _currentSpeedKt.value = speedKt
+ _currentDirectionDeg.value = directionDeg
+ }
+
+ /**
+ * Captures a snapshot of wind and current conditions at the current moment.
+ *
+ * All fields are nullable — only data that was available at snapshot time is
+ * populated. This snapshot is intended to be logged alongside a [MobEvent]
+ * at the instant of MOB activation.
+ */
+ fun snapshot(): EnvironmentalSnapshot {
+ val trueWind = _latestTrueWind.value
+ return EnvironmentalSnapshot(
+ windSpeedKt = trueWind?.speedKt,
+ windDirectionDeg = trueWind?.directionDeg,
+ currentSpeedKt = _currentSpeedKt.value,
+ currentDirectionDeg = _currentDirectionDeg.value
+ )
+ }
+}
+
+/**
+ * Point-in-time snapshot of wind and current conditions.
+ *
+ * @param windSpeedKt True Wind Speed in knots; null if sensors were unavailable.
+ * @param windDirectionDeg True Wind Direction (degrees true, wind comes FROM); null if unavailable.
+ * @param currentSpeedKt Ocean current speed in knots; null if forecast was unavailable.
+ * @param currentDirectionDeg Ocean current direction (degrees, flows TOWARD); null if unavailable.
+ */
+data class EnvironmentalSnapshot(
+ val windSpeedKt: Double?,
+ val windDirectionDeg: Double?,
+ val currentSpeedKt: Double?,
+ val currentDirectionDeg: Double?
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
new file mode 100644
index 0000000..d4cf50d
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
@@ -0,0 +1,81 @@
+package com.example.androidapp.logbook
+
+import org.terst.nav.data.model.LogbookEntry
+import java.util.Calendar
+import java.util.TimeZone
+
+data class LogbookRow(
+ val time: String,
+ val position: String,
+ val sog: String,
+ val cog: String,
+ val wind: String,
+ val baro: String,
+ val depth: String,
+ val eventNotes: String
+)
+
+data class LogbookPage(
+ val title: String,
+ val columns: List<String>,
+ val rows: List<LogbookRow>
+)
+
+object LogbookFormatter {
+
+ val COLUMNS = listOf(
+ "Time (UTC)", "Position", "SOG", "COG", "Wind", "Baro", "Depth", "Event / Notes"
+ )
+
+ private val COMPASS_POINTS = arrayOf(
+ "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
+ "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"
+ )
+
+ fun formatTime(timestampMs: Long): String {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.timeInMillis = timestampMs
+ return "%02d:%02d".format(
+ cal.get(Calendar.HOUR_OF_DAY),
+ cal.get(Calendar.MINUTE)
+ )
+ }
+
+ fun formatPosition(lat: Double, lon: Double): String {
+ val latDir = if (lat >= 0) "N" else "S"
+ val lonDir = if (lon >= 0) "E" else "W"
+ val absLat = Math.abs(lat)
+ val absLon = Math.abs(lon)
+ val latDeg = absLat.toInt()
+ val lonDeg = absLon.toInt()
+ val latMin = (absLat - latDeg) * 60.0
+ val lonMin = (absLon - lonDeg) * 60.0
+ return "%d°%.1f%s %d°%.1f%s".format(latDeg, latMin, latDir, lonDeg, lonMin, lonDir)
+ }
+
+ fun toCompassPoint(degrees: Double): String {
+ val normalized = ((degrees % 360.0) + 360.0) % 360.0
+ val index = ((normalized + 11.25) / 22.5).toInt() % 16
+ return COMPASS_POINTS[index]
+ }
+
+ fun formatWind(knots: Double?, directionDeg: Double?): String {
+ if (knots == null) return ""
+ val knotsStr = "%.0fkt".format(knots)
+ return if (directionDeg == null) knotsStr else "$knotsStr ${toCompassPoint(directionDeg)}"
+ }
+
+ fun toRow(entry: LogbookEntry): LogbookRow = LogbookRow(
+ time = formatTime(entry.timestampMs),
+ position = formatPosition(entry.lat, entry.lon),
+ sog = "%.1f".format(entry.sogKnots),
+ cog = "%.0f".format(entry.cogDegrees),
+ wind = formatWind(entry.windKnots, entry.windDirectionDeg),
+ baro = entry.baroHpa?.let { "%.0f".format(it) } ?: "",
+ depth = entry.depthMeters?.let { "%.0fm".format(it) } ?: "",
+ eventNotes = listOfNotNull(entry.event, entry.notes).joinToString(": ")
+ )
+
+ fun toPage(entries: List<LogbookEntry>, title: String = "Trip Logbook"): LogbookPage =
+ LogbookPage(title = title, columns = COLUMNS, rows = entries.map { toRow(it) })
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt
new file mode 100644
index 0000000..78ea834
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt
@@ -0,0 +1,137 @@
+package com.example.androidapp.logbook
+
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Typeface
+import android.graphics.pdf.PdfDocument
+import org.terst.nav.data.model.LogbookEntry
+import java.io.OutputStream
+
+/**
+ * Renders trip logbook entries to a formatted PDF (landscape A4).
+ * Section 4.8 — Trip Logging and Electronic Logbook.
+ */
+object LogbookPdfExporter {
+
+ // Landscape A4 in points (1 point = 1/72 inch)
+ private const val PAGE_WIDTH = 842
+ private const val PAGE_HEIGHT = 595
+ private const val MARGIN = 36f
+ private const val ROW_HEIGHT = 22f
+ private const val HEADER_HEIGHT = 36f
+ private const val TITLE_SIZE = 16f
+ private const val CELL_TEXT_SIZE = 9f
+
+ // Column width fractions (must sum to 1.0)
+ private val COL_FRACTIONS = floatArrayOf(
+ 0.08f, // Time
+ 0.18f, // Position
+ 0.06f, // SOG
+ 0.06f, // COG
+ 0.10f, // Wind
+ 0.07f, // Baro
+ 0.07f, // Depth
+ 0.38f // Event / Notes
+ )
+
+ fun export(
+ entries: List<LogbookEntry>,
+ outputStream: OutputStream,
+ title: String = "Trip Logbook"
+ ) {
+ val page = LogbookFormatter.toPage(entries, title)
+ val document = PdfDocument()
+ try {
+ val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, 1).create()
+ val pdfPage = document.startPage(pageInfo)
+ drawPage(pdfPage.canvas, page)
+ document.finishPage(pdfPage)
+ document.writeTo(outputStream)
+ } finally {
+ document.close()
+ }
+ }
+
+ private fun drawPage(canvas: Canvas, page: LogbookPage) {
+ val usableWidth = PAGE_WIDTH - 2 * MARGIN
+ val colWidths = COL_FRACTIONS.map { it * usableWidth }
+
+ val titlePaint = Paint().apply {
+ textSize = TITLE_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.BLACK
+ }
+ val headerTextPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ typeface = Typeface.DEFAULT_BOLD
+ color = Color.WHITE
+ }
+ val cellPaint = Paint().apply {
+ textSize = CELL_TEXT_SIZE
+ color = Color.BLACK
+ }
+ val linePaint = Paint().apply {
+ color = Color.LTGRAY
+ strokeWidth = 0.5f
+ }
+ val headerBgPaint = Paint().apply {
+ color = Color.rgb(41, 82, 123)
+ style = Paint.Style.FILL
+ }
+ val altBgPaint = Paint().apply {
+ color = Color.rgb(235, 242, 252)
+ style = Paint.Style.FILL
+ }
+ val borderPaint = Paint().apply {
+ color = Color.DKGRAY
+ strokeWidth = 1f
+ style = Paint.Style.STROKE
+ }
+
+ var y = MARGIN
+
+ // Title
+ canvas.drawText(page.title, MARGIN, y + TITLE_SIZE, titlePaint)
+ y += HEADER_HEIGHT
+
+ val tableTop = y
+
+ // Column header background
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, headerBgPaint)
+
+ // Column header text
+ var x = MARGIN + 3f
+ page.columns.forEachIndexed { i, col ->
+ canvas.drawText(col, x, y + ROW_HEIGHT - 6f, headerTextPaint)
+ x += colWidths[i]
+ }
+ y += ROW_HEIGHT
+
+ // Data rows
+ page.rows.forEach { row ->
+ if (y + ROW_HEIGHT > PAGE_HEIGHT - MARGIN) return@forEach
+
+ if (page.rows.indexOf(row) % 2 == 1) {
+ canvas.drawRect(MARGIN, y, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, altBgPaint)
+ }
+
+ val cells = listOf(
+ row.time, row.position, row.sog, row.cog,
+ row.wind, row.baro, row.depth, row.eventNotes
+ )
+ x = MARGIN + 3f
+ cells.forEachIndexed { i, cell ->
+ val maxChars = (colWidths[i] / (CELL_TEXT_SIZE * 0.55)).toInt().coerceAtLeast(4)
+ canvas.drawText(cell.take(maxChars), x, y + ROW_HEIGHT - 6f, cellPaint)
+ x += colWidths[i]
+ }
+
+ canvas.drawLine(MARGIN, y + ROW_HEIGHT, PAGE_WIDTH - MARGIN, y + ROW_HEIGHT, linePaint)
+ y += ROW_HEIGHT
+ }
+
+ // Table border
+ canvas.drawRect(MARGIN, tableTop, PAGE_WIDTH - MARGIN, y, borderPaint)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
new file mode 100644
index 0000000..b1b186a
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/nmea/NmeaParser.kt
@@ -0,0 +1,94 @@
+package com.example.androidapp.nmea
+
+import com.example.androidapp.gps.GpsPosition
+import java.util.Calendar
+import java.util.TimeZone
+
+class NmeaParser {
+
+ /**
+ * Parses an NMEA RMC sentence and returns a [GpsPosition], or null if the
+ * sentence is void (status=V), malformed, or cannot be parsed.
+ *
+ * Supported talker IDs: GP, GN, and any other standard prefix.
+ * SOG and COG default to 0.0 when the fields are absent.
+ */
+ fun parseRmc(sentence: String): GpsPosition? {
+ if (sentence.isBlank()) return null
+
+ val body = if ('*' in sentence) sentence.substringBefore('*') else sentence
+ val fields = body.split(',')
+ if (fields.size < 10) return null
+
+ if (!fields[0].endsWith("RMC")) return null
+ if (fields[2] != "A") return null
+
+ val latStr = fields.getOrNull(3) ?: return null
+ val latDir = fields.getOrNull(4) ?: return null
+ val lonStr = fields.getOrNull(5) ?: return null
+ val lonDir = fields.getOrNull(6) ?: return null
+
+ val latitude = parseNmeaDegrees(latStr) * if (latDir == "S") -1.0 else 1.0
+ val longitude = parseNmeaDegrees(lonStr) * if (lonDir == "W") -1.0 else 1.0
+
+ val sog = fields.getOrNull(7)?.toDoubleOrNull() ?: 0.0
+ val cog = fields.getOrNull(8)?.toDoubleOrNull() ?: 0.0
+
+ val timestampMs = parseTimestamp(
+ timeStr = fields.getOrNull(1) ?: "",
+ dateStr = fields.getOrNull(9) ?: ""
+ )
+ if (timestampMs == 0L) return null
+
+ return GpsPosition(latitude, longitude, sog, cog, timestampMs)
+ }
+
+ /**
+ * Converts NMEA degree-minutes format (DDDMM.MMMM) to decimal degrees.
+ */
+ private fun parseNmeaDegrees(value: String): Double {
+ val raw = value.toDoubleOrNull() ?: return 0.0
+ val degrees = (raw / 100.0).toInt()
+ val minutes = raw - degrees * 100.0
+ return degrees + minutes / 60.0
+ }
+
+ /**
+ * Combines NMEA time (HHMMSS.ss) and date (DDMMYY) into Unix epoch millis UTC.
+ * Returns 0 on any parse failure.
+ */
+ private fun parseTimestamp(timeStr: String, dateStr: String): Long {
+ return try {
+ val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
+ cal.isLenient = false
+
+ if (dateStr.length >= 6) {
+ val day = dateStr.substring(0, 2).toInt()
+ val month = dateStr.substring(2, 4).toInt() - 1
+ val yy = dateStr.substring(4, 6).toInt()
+ val year = if (yy < 70) 2000 + yy else 1900 + yy
+ cal.set(Calendar.YEAR, year)
+ cal.set(Calendar.MONTH, month)
+ cal.set(Calendar.DAY_OF_MONTH, day)
+ }
+
+ if (timeStr.length >= 6) {
+ val hours = timeStr.substring(0, 2).toInt()
+ val minutes = timeStr.substring(2, 4).toInt()
+ val seconds = timeStr.substring(4, 6).toInt()
+ val millis = if (timeStr.length > 7) {
+ val fracStr = timeStr.substring(7)
+ (("0.$fracStr").toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0
+ } else 0
+ cal.set(Calendar.HOUR_OF_DAY, hours)
+ cal.set(Calendar.MINUTE, minutes)
+ cal.set(Calendar.SECOND, seconds)
+ cal.set(Calendar.MILLISECOND, millis)
+ }
+
+ cal.timeInMillis
+ } catch (e: Exception) {
+ 0L
+ }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt
new file mode 100644
index 0000000..60a5918
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt
@@ -0,0 +1,12 @@
+package com.example.androidapp.routing
+
+/**
+ * The result of an isochrone weather routing computation.
+ *
+ * @param path Ordered list of [RoutePoint]s from the start to the destination.
+ * @param etaMs Estimated Time of Arrival as a UNIX timestamp in milliseconds.
+ */
+data class IsochroneResult(
+ val path: List<RoutePoint>,
+ val etaMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt
new file mode 100644
index 0000000..901fdbc
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt
@@ -0,0 +1,178 @@
+package com.example.androidapp.routing
+
+import org.terst.nav.data.model.BoatPolars
+import org.terst.nav.data.model.WindForecast
+import kotlin.math.asin
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.pow
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Isochrone-based weather routing engine (Section 3.4).
+ *
+ * Algorithm:
+ * 1. Start from a single point; expand a fan of headings at each time step.
+ * 2. For each candidate heading, compute BSP from [BoatPolars] at the local forecast wind.
+ * 3. Advance position by BSP × Δt using the spherical-Earth destination-point formula.
+ * 4. Check whether the destination has been reached (within [arrivalRadiusM]).
+ * 5. Prune candidates: for each angular sector around the start, keep only the point that
+ * advanced furthest (removes dominated points).
+ * 6. Repeat until the destination is reached or [maxSteps] is exhausted.
+ * 7. Backtrace parent pointers to produce the optimal path.
+ */
+object IsochroneRouter {
+
+ private const val EARTH_RADIUS_M = 6_371_000.0
+ internal const val NM_TO_M = 1_852.0
+ private const val KT_TO_M_PER_S = NM_TO_M / 3600.0
+
+ const val DEFAULT_HEADING_STEP_DEG = 5.0
+ const val DEFAULT_ARRIVAL_RADIUS_M = 1_852.0 // 1 NM
+ const val DEFAULT_PRUNE_SECTORS = 72 // 5° sectors
+ const val DEFAULT_MAX_STEPS = 200
+
+ /**
+ * Compute an optimised route from start to destination.
+ *
+ * @param startLat Start latitude (decimal degrees).
+ * @param startLon Start longitude (decimal degrees).
+ * @param destLat Destination latitude (decimal degrees).
+ * @param destLon Destination longitude (decimal degrees).
+ * @param startTimeMs Departure time as UNIX timestamp (ms).
+ * @param stepMs Time increment per isochrone step (ms). Typical: 1–3 hours.
+ * @param polars Boat polar table.
+ * @param windAt Function returning [WindForecast] for a given position and time.
+ * @param headingStepDeg Angular resolution of the heading fan (degrees). Default 5°.
+ * @param arrivalRadiusM Distance threshold to consider destination reached (metres).
+ * @param maxSteps Maximum number of isochrone expansions before giving up.
+ * @return [IsochroneResult] with the optimal path and ETA, or null if unreachable.
+ */
+ fun route(
+ startLat: Double,
+ startLon: Double,
+ destLat: Double,
+ destLon: Double,
+ startTimeMs: Long,
+ stepMs: Long,
+ polars: BoatPolars,
+ windAt: (lat: Double, lon: Double, timeMs: Long) -> WindForecast,
+ headingStepDeg: Double = DEFAULT_HEADING_STEP_DEG,
+ arrivalRadiusM: Double = DEFAULT_ARRIVAL_RADIUS_M,
+ maxSteps: Int = DEFAULT_MAX_STEPS
+ ): IsochroneResult? {
+ val start = RoutePoint(startLat, startLon, startTimeMs)
+ var isochrone = listOf(start)
+
+ repeat(maxSteps) { step ->
+ val nextTimeMs = startTimeMs + (step + 1).toLong() * stepMs
+ val candidates = mutableListOf<RoutePoint>()
+
+ for (point in isochrone) {
+ var heading = 0.0
+ while (heading < 360.0) {
+ val wind = windAt(point.lat, point.lon, point.timestampMs)
+ val twa = ((heading - wind.twdDeg + 360.0) % 360.0)
+ val bspKt = polars.bsp(twa, wind.twsKt)
+ if (bspKt > 0.0) {
+ val distM = bspKt * KT_TO_M_PER_S * (stepMs / 1000.0)
+ val (newLat, newLon) = destinationPoint(point.lat, point.lon, heading, distM)
+ val newPoint = RoutePoint(newLat, newLon, nextTimeMs, parent = point)
+
+ if (haversineM(newLat, newLon, destLat, destLon) <= arrivalRadiusM) {
+ return IsochroneResult(
+ path = backtrace(newPoint),
+ etaMs = nextTimeMs
+ )
+ }
+ candidates.add(newPoint)
+ }
+ heading += headingStepDeg
+ }
+ }
+
+ if (candidates.isEmpty()) return null
+ isochrone = prune(candidates, startLat, startLon, DEFAULT_PRUNE_SECTORS)
+ }
+
+ return null
+ }
+
+ /** Walk parent pointers from destination back to start, then reverse. */
+ internal fun backtrace(dest: RoutePoint): List<RoutePoint> {
+ val path = mutableListOf<RoutePoint>()
+ var current: RoutePoint? = dest
+ while (current != null) {
+ path.add(current)
+ current = current.parent
+ }
+ path.reverse()
+ return path
+ }
+
+ /**
+ * Angular-sector pruning: divide the plane into [sectors] equal angular sectors around the
+ * start. Within each sector keep only the candidate that is furthest from the start.
+ */
+ internal fun prune(
+ candidates: List<RoutePoint>,
+ startLat: Double,
+ startLon: Double,
+ sectors: Int
+ ): List<RoutePoint> {
+ val sectorSize = 360.0 / sectors
+ val best = mutableMapOf<Int, RoutePoint>()
+
+ for (point in candidates) {
+ val bearing = bearingDeg(startLat, startLon, point.lat, point.lon)
+ val sector = (bearing / sectorSize).toInt().coerceIn(0, sectors - 1)
+ val existing = best[sector]
+ if (existing == null ||
+ haversineM(startLat, startLon, point.lat, point.lon) >
+ haversineM(startLat, startLon, existing.lat, existing.lon)
+ ) {
+ best[sector] = point
+ }
+ }
+
+ return best.values.toList()
+ }
+
+ /** Haversine great-circle distance in metres. */
+ internal fun haversineM(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
+ val dLat = Math.toRadians(lat2 - lat1)
+ val dLon = Math.toRadians(lon2 - lon1)
+ val a = sin(dLat / 2).pow(2) +
+ cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * sin(dLon / 2).pow(2)
+ return 2.0 * EARTH_RADIUS_M * asin(sqrt(a))
+ }
+
+ /** Initial bearing from point 1 to point 2 (degrees, 0 = North, clockwise). */
+ internal fun bearingDeg(lat1Deg: Double, lon1Deg: Double, lat2Deg: Double, lon2Deg: Double): Double {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lat2 = Math.toRadians(lat2Deg)
+ val dLon = Math.toRadians(lon2Deg - lon1Deg)
+ val y = sin(dLon) * cos(lat2)
+ val x = cos(lat1) * sin(lat2) - sin(lat1) * cos(lat2) * cos(dLon)
+ return (Math.toDegrees(atan2(y, x)) + 360.0) % 360.0
+ }
+
+ /** Spherical-Earth destination-point given start, bearing, and distance. */
+ internal fun destinationPoint(
+ lat1Deg: Double,
+ lon1Deg: Double,
+ bearingDeg: Double,
+ distM: Double
+ ): Pair<Double, Double> {
+ val lat1 = Math.toRadians(lat1Deg)
+ val lon1 = Math.toRadians(lon1Deg)
+ val brng = Math.toRadians(bearingDeg)
+ val d = distM / EARTH_RADIUS_M
+
+ val lat2 = asin(sin(lat1) * cos(d) + cos(lat1) * sin(d) * cos(brng))
+ val lon2 = lon1 + atan2(sin(brng) * sin(d) * cos(lat1), cos(d) - sin(lat1) * sin(lat2))
+
+ return Pair(Math.toDegrees(lat2), Math.toDegrees(lon2))
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt
new file mode 100644
index 0000000..02988d1
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt
@@ -0,0 +1,16 @@
+package com.example.androidapp.routing
+
+/**
+ * A single point in the isochrone routing tree.
+ *
+ * @param lat Latitude (decimal degrees).
+ * @param lon Longitude (decimal degrees).
+ * @param timestampMs UNIX time in milliseconds when this position is reached.
+ * @param parent The previous [RoutePoint] (null for the start point).
+ */
+data class RoutePoint(
+ val lat: Double,
+ val lon: Double,
+ val timestampMs: Long,
+ val parent: RoutePoint? = null
+)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt
new file mode 100644
index 0000000..f544f63
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/safety/AnchorWatchState.kt
@@ -0,0 +1,24 @@
+package com.example.androidapp.safety
+
+import kotlin.math.sqrt
+
+/**
+ * Holds UI-facing state for the anchor watch setup screen and provides
+ * the suggested watch-circle radius derived from depth and rode out.
+ */
+class AnchorWatchState {
+
+ /**
+ * Returns the recommended watch-circle radius (metres) for the given depth
+ * and amount of rode deployed.
+ *
+ * Uses the Pythagorean formula sqrt(rode² - vertical²) when the geometry is
+ * valid (rode > depth + freeboard). Falls back to [rodeOutM] itself as the
+ * maximum possible swing radius when the rode is too short to form a catenary angle.
+ */
+ fun calculateRecommendedWatchCircleRadius(depthM: Double, rodeOutM: Double): Double {
+ val vertical = depthM + 2.0 // 2 m default freeboard
+ return if (rodeOutM > vertical) sqrt(rodeOutM * rodeOutM - vertical * vertical)
+ else rodeOutM
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt
new file mode 100644
index 0000000..2bdbf6c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt
@@ -0,0 +1,88 @@
+package com.example.androidapp.tide
+
+import com.example.androidapp.data.model.TidePrediction
+import com.example.androidapp.data.model.TideStation
+import kotlin.math.cos
+
+/**
+ * Computes harmonic tide predictions using the standard formula:
+ * h(t) = Z0 + Σ [ Hi × cos( ωi × (t − t0) − φi ) ]
+ *
+ * where:
+ * Z0 = datum offset (mean water level above chart datum, metres)
+ * Hi = amplitude of constituent i (metres)
+ * ωi = angular speed of constituent i (degrees / hour)
+ * t = hours elapsed since [EPOCH_MS] (2000-01-01 00:00 UTC)
+ * φi = phase lag (degrees)
+ */
+object HarmonicTideCalculator {
+
+ /** Reference epoch: 2000-01-01 00:00:00 UTC in Unix milliseconds. */
+ internal const val EPOCH_MS = 946_684_800_000L
+
+ /**
+ * Predict the tide height at a single moment.
+ *
+ * @param station Tide station with harmonic constituents.
+ * @param timestampMs Unix epoch milliseconds for the desired time.
+ * @return Predicted height in metres above chart datum.
+ */
+ fun predictHeight(station: TideStation, timestampMs: Long): Double {
+ val hoursFromEpoch = (timestampMs - EPOCH_MS) / 3_600_000.0
+ var height = station.datumOffsetMeters
+ for (c in station.constituents) {
+ val angleDeg = c.speedDegPerHour * hoursFromEpoch - c.phaseDeg
+ height += c.amplitudeMeters * cos(Math.toRadians(angleDeg))
+ }
+ return height
+ }
+
+ /**
+ * Predict tide heights over a time range at regular intervals.
+ *
+ * @param station Tide station.
+ * @param fromMs Start of range (Unix milliseconds, inclusive).
+ * @param toMs End of range (Unix milliseconds, inclusive).
+ * @param intervalMs Time step in milliseconds (must be positive).
+ * @return List of [TidePrediction] ordered by ascending timestamp.
+ */
+ fun predictRange(
+ station: TideStation,
+ fromMs: Long,
+ toMs: Long,
+ intervalMs: Long
+ ): List<TidePrediction> {
+ require(intervalMs > 0) { "intervalMs must be positive" }
+ require(fromMs <= toMs) { "fromMs must not exceed toMs" }
+ val predictions = mutableListOf<TidePrediction>()
+ var t = fromMs
+ while (t <= toMs) {
+ predictions += TidePrediction(t, predictHeight(station, t))
+ t += intervalMs
+ }
+ return predictions
+ }
+
+ /**
+ * Find high and low water events from a pre-computed prediction series.
+ *
+ * Detects local maxima (high water) and minima (low water) by comparing
+ * each interior sample with its immediate neighbours.
+ *
+ * @param predictions Ordered list of tide predictions (at least 3 points).
+ * @return Subset list containing only high/low turning points.
+ */
+ fun findHighLow(predictions: List<TidePrediction>): List<TidePrediction> {
+ if (predictions.size < 3) return emptyList()
+ val result = mutableListOf<TidePrediction>()
+ for (i in 1 until predictions.size - 1) {
+ val prev = predictions[i - 1].heightMeters
+ val curr = predictions[i].heightMeters
+ val next = predictions[i + 1].heightMeters
+ val isMax = curr >= prev && curr >= next
+ val isMin = curr <= prev && curr <= next
+ if (isMax || isMin) result += predictions[i]
+ }
+ return result
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt
new file mode 100644
index 0000000..289a857
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt
@@ -0,0 +1,58 @@
+package com.example.androidapp.ui.anchorwatch
+
+import android.os.Bundle
+import android.text.Editable
+import android.text.TextWatcher
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import org.terst.nav.R
+import org.terst.nav.databinding.FragmentAnchorWatchBinding
+import com.example.androidapp.safety.AnchorWatchState
+
+class AnchorWatchHandler : Fragment() {
+
+ private var _binding: FragmentAnchorWatchBinding? = null
+ private val binding get() = _binding!!
+
+ private val anchorWatchState = AnchorWatchState()
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View {
+ _binding = FragmentAnchorWatchBinding.inflate(inflater, container, false)
+ return binding.root
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ val watcher = object : TextWatcher {
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
+ override fun afterTextChanged(s: Editable?) = updateSuggestedRadius()
+ }
+ binding.etDepth.addTextChangedListener(watcher)
+ binding.etRodeOut.addTextChangedListener(watcher)
+ }
+
+ private fun updateSuggestedRadius() {
+ val depth = binding.etDepth.text.toString().toDoubleOrNull()
+ val rode = binding.etRodeOut.text.toString().toDoubleOrNull()
+
+ if (depth != null && rode != null && depth >= 0.0 && rode > 0.0) {
+ val radius = anchorWatchState.calculateRecommendedWatchCircleRadius(depth, rode)
+ binding.tvSuggestedRadius.text =
+ getString(R.string.anchor_suggested_radius_fmt, radius)
+ } else {
+ binding.tvSuggestedRadius.text = getString(R.string.anchor_suggested_radius_empty)
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ _binding = null
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt
new file mode 100644
index 0000000..01656a3
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt
@@ -0,0 +1,3 @@
+package com.example.androidapp.wind
+
+data class ApparentWind(val speedKt: Double, val angleDeg: Double)
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt
new file mode 100644
index 0000000..db32163
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt
@@ -0,0 +1,20 @@
+package com.example.androidapp.wind
+
+import kotlin.math.atan2
+import kotlin.math.cos
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+class TrueWindCalculator {
+ fun update(apparent: ApparentWind, bsp: Double, hdgDeg: Double): TrueWindData {
+ val awaRad = Math.toRadians(apparent.angleDeg)
+ val awX = apparent.speedKt * cos(awaRad)
+ val awY = apparent.speedKt * sin(awaRad)
+ val twX = awX - bsp
+ val twY = awY
+ val tws = sqrt(twX * twX + twY * twY)
+ val twaDeg = Math.toDegrees(atan2(twY, twX))
+ val twdDeg = ((hdgDeg + twaDeg) % 360 + 360) % 360
+ return TrueWindData(speedKt = tws, directionDeg = twdDeg)
+ }
+}
diff --git a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt
new file mode 100644
index 0000000..78e9558
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt
@@ -0,0 +1,3 @@
+package com.example.androidapp.wind
+
+data class TrueWindData(val speedKt: Double, val directionDeg: Double)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
index d9dba73..f9d4dbd 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt
@@ -22,7 +22,10 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.floatingactionbutton.FloatingActionButton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -45,9 +48,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private var instrumentHandler: InstrumentHandler? = null
private var mapHandler: MapHandler? = null
private var anchorWatchHandler: AnchorWatchHandler? = null
-
+ private val loadedStyleFlow = MutableStateFlow<Style?>(null)
+
private lateinit var bottomSheetBehavior: BottomSheetBehavior<View>
private lateinit var fragmentContainer: FrameLayout
+ private lateinit var fabRecordTrack: FloatingActionButton
private val safetyFragment = SafetyFragment().apply { setSafetyListener(this@MainActivity) }
private val viewModel: MainViewModel by viewModels()
@@ -55,6 +60,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
override fun onResume() {
super.onResume()
+ mapView?.onResume()
if (pendingServiceStart) {
pendingServiceStart = false
startServices()
@@ -80,6 +86,19 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
findViewById<FloatingActionButton>(R.id.fab_mob).setOnClickListener {
onActivateMob()
}
+
+ fabRecordTrack = findViewById(R.id.fab_record_track)
+ fabRecordTrack.setOnClickListener {
+ if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack()
+ }
+ // Observe immediately — pure UI state, not gated on GPS permission
+ lifecycleScope.launch {
+ viewModel.isRecording.collect { recording ->
+ val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record
+ fabRecordTrack.setImageResource(icon)
+ fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track"
+ }
+ }
}
private fun setupBottomSheet() {
@@ -131,17 +150,13 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
private fun hideOverlays() {
fragmentContainer.visibility = View.GONE
- // Clear backstack if needed
}
override fun onActivateMob() {
lifecycleScope.launch {
LocationService.locationFlow.firstOrNull()?.let { gpsData ->
val mediaPlayer = MediaPlayer.create(this@MainActivity, R.raw.mob_alarm)
- // In a real redesign, we'd show a specialized MOB fragment
- // For now, keep existing handler logic but maybe toggle visibility
mobHandler?.activateMob(gpsData.latitude, gpsData.longitude, mediaPlayer)
- // Ensure MOB UI is visible - we might need to add it back to activity_main if removed
}
}
}
@@ -151,7 +166,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
private fun setupHandlers() {
- // ... (Keep existing handler initialization, just update view IDs as needed)
instrumentHandler = InstrumentHandler(
valueAws = findViewById(R.id.value_aws),
valueTws = findViewById(R.id.value_tws),
@@ -226,10 +240,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}, 256))
.withLayer(RasterLayer("openseamap-layer", "openseamap-source"))
- maplibreMap.setStyle(style) { loadedStyle ->
+ maplibreMap.setStyle(style) { style ->
+ loadedStyleFlow.value = style
val anchorBitmap = rasterizeDrawable(R.drawable.ic_anchor)
val arrowBitmap = rasterizeDrawable(R.drawable.ic_tidal_arrow)
- mapHandler?.setupLayers(loadedStyle, anchorBitmap, arrowBitmap)
+ mapHandler?.setupLayers(style, anchorBitmap, arrowBitmap)
}
}
}
@@ -238,6 +253,8 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
lifecycleScope.launch {
LocationService.locationFlow.collect { gpsData ->
mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude)
+ val sogKnots = gpsData.speedOverGround * 1.94384
+ viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, gpsData.courseOverGround.toDouble())
}
}
lifecycleScope.launch {
@@ -245,6 +262,11 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
safetyFragment.updateAnchorStatus(if (state.isActive) "Active: ${state.watchCircleRadiusMeters}m" else "Inactive")
}
}
+ lifecycleScope.launch {
+ loadedStyleFlow.filterNotNull()
+ .combine(viewModel.trackPoints) { style, points -> style to points }
+ .collect { (style, points) -> mapHandler?.updateTrackLayer(style, points) }
+ }
}
private fun startInstrumentSimulation(polarTable: PolarTable) {
@@ -288,7 +310,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener {
}
override fun onStart() { super.onStart(); mapView?.onStart() }
- override fun onResume() { super.onResume(); mapView?.onResume() }
override fun onPause() { super.onPause(); mapView?.onPause() }
override fun onStop() { super.onStop(); mapView?.onStop() }
override fun onDestroy() { super.onDestroy(); mapView?.onDestroy() }
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt
new file mode 100644
index 0000000..0286ea8
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/BoatPolars.kt
@@ -0,0 +1,69 @@
+package org.terst.nav.data.model
+
+import kotlin.math.pow
+import kotlin.math.sqrt
+
+/**
+ * Boat polar speed table: maps (TWA, TWS) → BSP (boat speed through water, knots).
+ *
+ * Interpolation is bilinear — linear on TWA within a given TWS, then linear on TWS.
+ * Port-tack mirror: TWA > 180° is folded to 360° - TWA before lookup.
+ */
+data class BoatPolars(
+ /** Outer key: TWS in knots. Inner key: TWA in degrees [0, 180]. Value: BSP in knots. */
+ val table: Map<Double, Map<Double, Double>>
+) {
+ /**
+ * Returns boat speed (knots) for the given True Wind Angle and True Wind Speed.
+ * TWA outside [0, 360] is clamped; port/starboard symmetry is applied (>180° mirrored).
+ */
+ fun bsp(twaDeg: Double, twsKt: Double): Double {
+ val twa = if (twaDeg > 180.0) 360.0 - twaDeg else twaDeg.coerceIn(0.0, 180.0)
+
+ val twsKeys = table.keys.sorted()
+ if (twsKeys.isEmpty()) return 0.0
+
+ val twsClamped = twsKt.coerceIn(twsKeys.first(), twsKeys.last())
+ val twsLow = twsKeys.lastOrNull { it <= twsClamped } ?: twsKeys.first()
+ val twsHigh = twsKeys.firstOrNull { it >= twsClamped } ?: twsKeys.last()
+
+ val bspLow = bspAtTws(twa, table[twsLow] ?: return 0.0)
+ val bspHigh = bspAtTws(twa, table[twsHigh] ?: return 0.0)
+
+ return if (twsHigh == twsLow) bspLow
+ else {
+ val t = (twsClamped - twsLow) / (twsHigh - twsLow)
+ bspLow + t * (bspHigh - bspLow)
+ }
+ }
+
+ private fun bspAtTws(twaDeg: Double, twaMap: Map<Double, Double>): Double {
+ val twaKeys = twaMap.keys.sorted()
+ if (twaKeys.isEmpty()) return 0.0
+
+ val twaClamped = twaDeg.coerceIn(twaKeys.first(), twaKeys.last())
+ val twaLow = twaKeys.lastOrNull { it <= twaClamped } ?: twaKeys.first()
+ val twaHigh = twaKeys.firstOrNull { it >= twaClamped } ?: twaKeys.last()
+
+ val bspLow = twaMap[twaLow] ?: 0.0
+ val bspHigh = twaMap[twaHigh] ?: 0.0
+
+ return if (twaHigh == twaLow) bspLow
+ else {
+ val t = (twaClamped - twaLow) / (twaHigh - twaLow)
+ bspLow + t * (bspHigh - bspLow)
+ }
+ }
+
+ companion object {
+ /** Default polar for a typical 35-foot cruising sloop. */
+ val DEFAULT: BoatPolars = BoatPolars(
+ mapOf(
+ 5.0 to mapOf(45.0 to 3.5, 60.0 to 4.2, 90.0 to 4.8, 120.0 to 5.0, 150.0 to 4.5, 180.0 to 4.0),
+ 10.0 to mapOf(45.0 to 5.5, 60.0 to 6.5, 90.0 to 7.0, 120.0 to 7.2, 150.0 to 6.8, 180.0 to 6.0),
+ 15.0 to mapOf(45.0 to 6.5, 60.0 to 7.5, 90.0 to 8.0, 120.0 to 8.5, 150.0 to 8.0, 180.0 to 7.0),
+ 20.0 to mapOf(45.0 to 7.0, 60.0 to 8.0, 90.0 to 8.5, 120.0 to 9.0, 150.0 to 8.5, 180.0 to 7.5)
+ )
+ )
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
index 9d284b5..715c1db 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribFile.kt
@@ -2,39 +2,8 @@ package org.terst.nav.data.model
import java.time.Instant
-/**
- * Metadata record for a locally-stored GRIB2 file.
- *
- * @param region The geographic region this file covers.
- * @param modelRunTime When the NWP model run that produced this file started (UTC).
- * @param forecastHours Number of forecast hours included in this file.
- * @param downloadedAt Wall-clock time when the file was saved locally.
- * @param filePath Absolute path to the GRIB2 file on the device filesystem.
- * @param sizeBytes File size in bytes.
- */
-data class GribFile(
- val region: GribRegion,
- val modelRunTime: Instant,
- val forecastHours: Int,
- val downloadedAt: Instant,
- val filePath: String,
- val sizeBytes: Long
-) {
- /**
- * The wall-clock time at which this GRIB file's forecast data expires.
- * Per design doc §6.3: valid until model run + forecast hours.
- */
+data class GribFile(val region: GribRegion, val modelRunTime: Instant, val forecastHours: Int, val downloadedAt: Instant, val filePath: String, val sizeBytes: Long) {
fun validUntil(): Instant = modelRunTime.plusSeconds(forecastHours.toLong() * 3600)
-
- /**
- * Returns true if the data has expired relative to [now].
- * Per design doc §6.3: stale after model run + forecast hour.
- */
fun isStale(now: Instant = Instant.now()): Boolean = now.isAfter(validUntil())
-
- /**
- * Age of the download in seconds.
- */
- fun ageSeconds(now: Instant = Instant.now()): Long =
- now.epochSecond - downloadedAt.epochSecond
+ fun ageSeconds(now: Instant = Instant.now()): Long = now.epochSecond - downloadedAt.epochSecond
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt
new file mode 100644
index 0000000..a322ea9
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribParameter.kt
@@ -0,0 +1,24 @@
+package org.terst.nav.data.model
+
+/** GRIB meteorological/oceanographic parameters that can be requested for download. */
+enum class GribParameter {
+ WIND_SPEED,
+ WIND_DIRECTION,
+ SURFACE_PRESSURE,
+ TEMPERATURE_2M,
+ PRECIPITATION,
+ WAVE_HEIGHT,
+ WAVE_DIRECTION;
+
+ companion object {
+ /**
+ * Minimal parameter set for satellite (Iridium) links: wind speed, wind direction,
+ * and surface pressure only. Per §9.1: skip temperature/clouds to minimise bandwidth.
+ */
+ val SATELLITE_MINIMAL: Set<GribParameter> = setOf(
+ WIND_SPEED,
+ WIND_DIRECTION,
+ SURFACE_PRESSURE
+ )
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
index 5e32d6c..f960bc3 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/GribRegion.kt
@@ -1,20 +1,6 @@
package org.terst.nav.data.model
-/**
- * Geographic bounding box used to identify a GRIB download region.
- * @param name Human-readable region name (e.g. "North Atlantic", "English Channel")
- */
-data class GribRegion(
- val name: String,
- val latMin: Double,
- val latMax: Double,
- val lonMin: Double,
- val lonMax: Double
-) {
- /** True if [lat]/[lon] falls within this region's bounding box. */
- fun contains(lat: Double, lon: Double): Boolean =
- lat in latMin..latMax && lon in lonMin..lonMax
-
- /** Area in square degrees (rough proxy for download size estimate). */
+data class GribRegion(val name: String, val latMin: Double, val latMax: Double, val lonMin: Double, val lonMax: Double) {
+ fun contains(lat: Double, lon: Double): Boolean = lat in latMin..latMax && lon in lonMin..lonMax
fun areaDegrees(): Double = (latMax - latMin) * (lonMax - lonMin)
}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt
new file mode 100644
index 0000000..f41e917
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/LogbookEntry.kt
@@ -0,0 +1,19 @@
+package org.terst.nav.data.model
+
+/**
+ * A single entry in the electronic trip logbook.
+ * Matches the log format from Section 4.8 of COMPONENT_DESIGN.md.
+ */
+data class LogbookEntry(
+ val timestampMs: Long,
+ val lat: Double,
+ val lon: Double,
+ val sogKnots: Double,
+ val cogDegrees: Double,
+ val windKnots: Double? = null,
+ val windDirectionDeg: Double? = null,
+ val baroHpa: Double? = null,
+ val depthMeters: Double? = null,
+ val event: String? = null,
+ val notes: String? = null
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt
new file mode 100644
index 0000000..d14c9da
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SatelliteDownloadRequest.kt
@@ -0,0 +1,27 @@
+package org.terst.nav.data.model
+
+/**
+ * A bandwidth-optimised GRIB download request for satellite (Iridium) links.
+ *
+ * Per §9.1: crop to needed region and request only essential parameters
+ * (wind, pressure) to fit within the ~2.4 Kbps Iridium budget.
+ *
+ * @param region Geographic area to download (cropped to route corridor + 200 nm buffer).
+ * @param parameters GRIB parameters to include. Use [GribParameter.SATELLITE_MINIMAL]
+ * for satellite links.
+ * @param forecastHours Number of forecast hours to request (e.g. 24, 48, 120).
+ * @param resolutionDeg Grid spacing in degrees. Coarser grids produce smaller files;
+ * 1.0° is typical for satellite; 0.25° for WiFi/cellular.
+ */
+data class SatelliteDownloadRequest(
+ val region: GribRegion,
+ val parameters: Set<GribParameter>,
+ val forecastHours: Int,
+ val resolutionDeg: Double = 1.0
+) {
+ init {
+ require(forecastHours > 0) { "forecastHours must be positive" }
+ require(resolutionDeg > 0.0) { "resolutionDeg must be positive" }
+ require(parameters.isNotEmpty()) { "parameters must not be empty" }
+ }
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt
new file mode 100644
index 0000000..deb73d6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideConstituent.kt
@@ -0,0 +1,9 @@
+package com.example.androidapp.data.model
+
+/** A single harmonic tidal constituent used in harmonic tide prediction. */
+data class TideConstituent(
+ val name: String, // e.g. "M2", "S2", "K1"
+ val speedDegPerHour: Double, // angular speed in degrees per hour
+ val amplitudeMeters: Double, // amplitude in metres
+ val phaseDeg: Double // phase lag (kappa) in degrees
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt
new file mode 100644
index 0000000..51eea44
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TidePrediction.kt
@@ -0,0 +1,7 @@
+package com.example.androidapp.data.model
+
+/** A predicted tide height at a specific point in time. */
+data class TidePrediction(
+ val timestampMs: Long, // Unix epoch milliseconds
+ val heightMeters: Double // predicted water height above chart datum in metres
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt
new file mode 100644
index 0000000..c9f96a6
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/TideStation.kt
@@ -0,0 +1,11 @@
+package com.example.androidapp.data.model
+
+/** A tide station with harmonic constituents for offline tide prediction. */
+data class TideStation(
+ val id: String,
+ val name: String,
+ val lat: Double,
+ val lon: Double,
+ val datumOffsetMeters: Double, // mean water level above chart datum (Z0)
+ val constituents: List<TideConstituent>
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt
new file mode 100644
index 0000000..f009da8
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/WindForecast.kt
@@ -0,0 +1,18 @@
+package org.terst.nav.data.model
+
+/**
+ * Wind conditions at a specific location and time.
+ *
+ * @param lat Latitude (decimal degrees).
+ * @param lon Longitude (decimal degrees).
+ * @param timestampMs UNIX time in milliseconds.
+ * @param twsKt True Wind Speed in knots.
+ * @param twdDeg True Wind Direction in degrees (the direction FROM which the wind blows, 0–360).
+ */
+data class WindForecast(
+ val lat: Double,
+ val lon: Double,
+ val timestampMs: Long,
+ val twsKt: Double,
+ val twdDeg: Double
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
new file mode 100644
index 0000000..d803c8c
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt
@@ -0,0 +1,12 @@
+package org.terst.nav.track
+
+data class TrackPoint(
+ val lat: Double,
+ val lon: Double,
+ val sogKnots: Double,
+ val cogDeg: Double,
+ val windSpeedKnots: Double,
+ val windAngleDeg: Double,
+ val isTrueWind: Boolean,
+ val timestampMs: Long
+)
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
new file mode 100644
index 0000000..7953822
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org/terst/nav/track/TrackRepository.kt
@@ -0,0 +1,26 @@
+package org.terst.nav.track
+
+class TrackRepository {
+
+ var isRecording: Boolean = false
+ private set
+
+ private val points = mutableListOf<TrackPoint>()
+
+ fun startTrack() {
+ points.clear()
+ isRecording = true
+ }
+
+ fun stopTrack() {
+ isRecording = false
+ }
+
+ fun addPoint(point: TrackPoint): Boolean {
+ if (!isRecording) return false
+ points.add(point)
+ return true
+ }
+
+ fun getPoints(): List<TrackPoint> = points.toList()
+}
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
index 8e84e1e..0efff52 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt
@@ -6,6 +6,8 @@ import org.terst.nav.ais.AisHubSource
import org.terst.nav.ais.AisRepository
import org.terst.nav.ais.AisVessel
import org.terst.nav.data.api.AisHubApiService
+import org.terst.nav.track.TrackPoint
+import org.terst.nav.track.TrackRepository
import org.terst.nav.data.model.ForecastItem
import org.terst.nav.data.model.WindArrow
import org.terst.nav.data.repository.WeatherRepository
@@ -43,6 +45,37 @@ class MainViewModel(
private val aisRepository = AisRepository()
+ private val trackRepository = TrackRepository()
+
+ private val _isRecording = MutableStateFlow(false)
+ val isRecording: StateFlow<Boolean> = _isRecording.asStateFlow()
+
+ private val _trackPoints = MutableStateFlow<List<TrackPoint>>(emptyList())
+ val trackPoints: StateFlow<List<TrackPoint>> = _trackPoints.asStateFlow()
+
+ fun startTrack() {
+ trackRepository.startTrack()
+ _trackPoints.value = emptyList()
+ _isRecording.value = true
+ }
+
+ fun stopTrack() {
+ trackRepository.stopTrack()
+ _isRecording.value = false
+ }
+
+ fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
+ val point = TrackPoint(
+ lat = lat, lon = lon,
+ sogKnots = sogKnots, cogDeg = cogDeg,
+ windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false,
+ timestampMs = System.currentTimeMillis()
+ )
+ if (trackRepository.addPoint(point)) {
+ _trackPoints.value = trackRepository.getPoints()
+ }
+ }
+
private val aisHubApi: AisHubApiService by lazy {
Retrofit.Builder()
.baseUrl("https://data.aishub.net")
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
index 91569cf..cbc2e90 100644
--- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
+++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt
@@ -7,6 +7,7 @@ import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.Style
import org.maplibre.android.style.layers.CircleLayer
+import org.maplibre.android.style.layers.LineLayer
import org.maplibre.android.style.layers.PropertyFactory
import org.maplibre.android.style.layers.RasterLayer
import org.maplibre.android.style.layers.SymbolLayer
@@ -15,10 +16,12 @@ import org.maplibre.android.style.sources.RasterSource
import org.maplibre.android.style.sources.TileSet
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
+import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
import org.maplibre.geojson.Polygon
import org.terst.nav.AnchorWatchState
import org.terst.nav.TidalCurrentState
+import org.terst.nav.track.TrackPoint
import kotlin.math.cos
import kotlin.math.sin
@@ -37,9 +40,13 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
private val TIDAL_CURRENT_LAYER_ID = "tidal-current-layer"
private val TIDAL_ARROW_ICON_ID = "tidal-arrow-icon"
+ private val TRACK_SOURCE_ID = "track-source"
+ private val TRACK_LAYER_ID = "track-line"
+
private var anchorPointSource: GeoJsonSource? = null
private var anchorCircleSource: GeoJsonSource? = null
private var tidalCurrentSource: GeoJsonSource? = null
+ private var trackSource: GeoJsonSource? = null
/**
* Initializes map layers for anchor watch and tidal currents.
@@ -127,6 +134,28 @@ class MapHandler(private val maplibreMap: MapLibreMap) {
}
}
+ /**
+ * Updates the GPS track polyline on the map. Lazily initialises the layer on first call.
+ */
+ fun updateTrackLayer(style: Style, points: List<TrackPoint>) {
+ if (trackSource == null) {
+ trackSource = GeoJsonSource(TRACK_SOURCE_ID)
+ style.addSource(trackSource!!)
+ style.addLayer(LineLayer(TRACK_LAYER_ID, TRACK_SOURCE_ID).apply {
+ setProperties(
+ PropertyFactory.lineColor("#E53935"),
+ PropertyFactory.lineWidth(3f)
+ )
+ })
+ }
+ if (points.size >= 2) {
+ val coords = points.map { Point.fromLngLat(it.lon, it.lat) }
+ trackSource?.setGeoJson(Feature.fromGeometry(LineString.fromLngLats(coords)))
+ } else {
+ trackSource?.setGeoJson(FeatureCollection.fromFeatures(emptyList()))
+ }
+ }
+
private fun createCirclePolygon(lat: Double, lon: Double, radiusMeters: Double): Polygon {
val points = mutableListOf<Point>()
val degreesBetweenPoints = 8
diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideConstituent.kt
diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TidePrediction.kt
diff --git a/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android-app/app/src/main/kotlin/org\/terst\/nav/data/model/TideStation.kt
diff --git a/android-app/app/src/main/res/drawable/ic_track_record.xml b/android-app/app/src/main/res/drawable/ic_track_record.xml
new file mode 100644
index 0000000..9016369
--- /dev/null
+++ b/android-app/app/src/main/res/drawable/ic_track_record.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <!-- Outer ring -->
+ <path
+ android:fillColor="#00000000"
+ android:strokeColor="@android:color/white"
+ android:strokeWidth="2"
+ android:pathData="M12,12m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"/>
+ <!-- Filled red dot -->
+ <path
+ android:fillColor="#E53935"
+ android:pathData="M12,12m-6,0a6,6 0,1 1,12 0a6,6 0,1 1,-12 0"/>
+</vector>
diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml
index 66d1abe..68abc60 100644
--- a/android-app/app/src/main/res/layout/activity_main.xml
+++ b/android-app/app/src/main/res/layout/activity_main.xml
@@ -65,7 +65,20 @@
android:contentDescription="Man Overboard"
app:srcCompat="@android:drawable/ic_dialog_alert"
app:backgroundTint="@color/mob_button_background"
- app:layout_anchor="@id/bottom_navigation"
+ app:layout_anchor="@id/instrument_bottom_sheet"
app:layout_anchorGravity="top|start" />
+ <!-- Record Track Button -->
+ <com.google.android.material.floatingactionbutton.FloatingActionButton
+ android:id="@+id/fab_record_track"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_margin="16dp"
+ android:clickable="true"
+ android:focusable="true"
+ android:contentDescription="Record Track"
+ app:srcCompat="@drawable/ic_track_record"
+ app:layout_anchor="@id/instrument_bottom_sheet"
+ app:layout_anchorGravity="top|end" />
+
</androidx.coordinatorlayout.widget.CoordinatorLayout>
diff --git a/android-app/app/src/main/res/layout/fragment_anchor_watch.xml b/android-app/app/src/main/res/layout/fragment_anchor_watch.xml
new file mode 100644
index 0000000..96b9969
--- /dev/null
+++ b/android-app/app/src/main/res/layout/fragment_anchor_watch.xml
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ android:padding="16dp">
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_watch_title"
+ android:textSize="20sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="24dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_depth_label"
+ android:textSize="14sp"
+ android:layout_marginBottom="4dp" />
+
+ <EditText
+ android:id="@+id/etDepth"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/anchor_depth_hint"
+ android:inputType="numberDecimal"
+ android:importantForAutofill="no"
+ android:layout_marginBottom="16dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_rode_label"
+ android:textSize="14sp"
+ android:layout_marginBottom="4dp" />
+
+ <EditText
+ android:id="@+id/etRodeOut"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:hint="@string/anchor_rode_hint"
+ android:inputType="numberDecimal"
+ android:importantForAutofill="no"
+ android:layout_marginBottom="24dp" />
+
+ <TextView
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_suggested_radius_label"
+ android:textSize="14sp"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/tvSuggestedRadius"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_suggested_radius_empty"
+ android:textSize="18sp"
+ android:textStyle="bold"
+ android:layout_marginBottom="4dp" />
+
+ <TextView
+ android:id="@+id/tvSuggestedRadiusHint"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:text="@string/anchor_suggested_radius_hint"
+ android:textSize="12sp"
+ android:alpha="0.6" />
+
+ </LinearLayout>
+
+</ScrollView>
diff --git a/android-app/app/src/main/res/layout/fragment_map.xml b/android-app/app/src/main/res/layout/fragment_map.xml
index e5b86b7..2b9b40d 100644
--- a/android-app/app/src/main/res/layout/fragment_map.xml
+++ b/android-app/app/src/main/res/layout/fragment_map.xml
@@ -27,4 +27,18 @@
android:textSize="14sp"
android:visibility="gone" />
+ <!-- Staleness banner -->
+ <TextView
+ android:id="@+id/tvStalenessWarning"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_gravity="bottom"
+ android:background="#FFCC00"
+ android:textColor="#000000"
+ android:textStyle="bold"
+ android:padding="8dp"
+ android:gravity="center"
+ android:visibility="gone"
+ android:text="" />
+
</FrameLayout>
diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml
index 499ba8d..756f5e3 100755
--- a/android-app/app/src/main/res/values/strings.xml
+++ b/android-app/app/src/main/res/values/strings.xml
@@ -58,4 +58,14 @@
<string name="temp_fmt">%.0f °C</string>
<string name="precip_fmt">%d%%</string>
<string name="permission_rationale">Location is needed to show weather for your current position.</string>
+ <string name="nav_anchor_watch">Anchor</string>
+ <string name="anchor_watch_title">Anchor Watch</string>
+ <string name="anchor_depth_label">Depth (m)</string>
+ <string name="anchor_depth_hint">e.g. 5.0</string>
+ <string name="anchor_rode_label">Rode Out (m)</string>
+ <string name="anchor_rode_hint">e.g. 30.0</string>
+ <string name="anchor_suggested_radius_label">Suggested Watch Radius</string>
+ <string name="anchor_suggested_radius_empty">—</string>
+ <string name="anchor_suggested_radius_fmt">%.1f m</string>
+ <string name="anchor_suggested_radius_hint">Calculated from rode and depth using Pythagorean scope formula (2 m freeboard assumed)</string>
</resources>