summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/com/example/androidapp
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/src/main/kotlin/com/example/androidapp
parent9f01ddfba17dda7fb386e83f007c671fec6d5b8e (diff)
refactor: unify core models and finish org.terst.nav migration
Diffstat (limited to 'android-app/app/src/main/kotlin/com/example/androidapp')
-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
18 files changed, 0 insertions, 1144 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
deleted file mode 100644
index d427a5d..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/data/model/SensorData.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-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
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/data/weather/GribStalenessChecker.kt b/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt
deleted file mode 100644
index 70f36d9..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/GribStalenessChecker.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-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
deleted file mode 100644
index 6e565b7..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloader.kt
+++ /dev/null
@@ -1,134 +0,0 @@
-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
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/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
deleted file mode 100644
index d4cf50d..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookFormatter.kt
+++ /dev/null
@@ -1,81 +0,0 @@
-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
deleted file mode 100644
index 78ea834..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/logbook/LogbookPdfExporter.kt
+++ /dev/null
@@ -1,137 +0,0 @@
-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
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/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt
deleted file mode 100644
index 60a5918..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneResult.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-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
deleted file mode 100644
index 901fdbc..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/routing/IsochroneRouter.kt
+++ /dev/null
@@ -1,178 +0,0 @@
-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
deleted file mode 100644
index 02988d1..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/routing/RoutePoint.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-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
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/com/example/androidapp/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt
deleted file mode 100644
index 2bdbf6c..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/tide/HarmonicTideCalculator.kt
+++ /dev/null
@@ -1,88 +0,0 @@
-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
deleted file mode 100644
index 289a857..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/ui/anchorwatch/AnchorWatchHandler.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-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
deleted file mode 100644
index 01656a3..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/wind/ApparentWind.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-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
deleted file mode 100644
index db32163..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindCalculator.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-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
deleted file mode 100644
index 78e9558..0000000
--- a/android-app/app/src/main/kotlin/com/example/androidapp/wind/TrueWindData.kt
+++ /dev/null
@@ -1,3 +0,0 @@
-package com.example.androidapp.wind
-
-data class TrueWindData(val speedKt: Double, val directionDeg: Double)