diff options
| author | Claude <noreply@anthropic.com> | 2026-05-28 07:09:43 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-05-28 07:09:43 +0000 |
| commit | c498b33300d260bbe64bec49de9ef78d9a322a6d (patch) | |
| tree | 2ee8b3fc08d759b105de0a8c2b36d4301c3f393a /android-app/app | |
| parent | 3673ed0ef79d776c6da3bfa0db9de8b08326fd32 (diff) | |
Tech debt: delete com.example.androidapp ghost package, symlink TackDetector, fix forecast current hour
- Deleted all 18 stale source files in com.example.androidapp (main + test)
that duplicated org.terst.nav implementations from an earlier namespace
- Fixed the 3 tide model files (TidePrediction/Station/Constituent) whose
package declarations read com.example.androidapp.data.model despite living
in org.terst.nav.data.model directories
- Fixed HarmonicTideCalculator.kt imports to match the corrected package
- Fixed TideModelTest.kt package declaration
- Migrated HarmonicTideCalculatorTest to org.terst.nav.tide with correct imports
- Replaced test-runner's TackDetector.kt copy with a symlink to the android-app
canonical source so the two can never diverge again
- Fixed addGpsPoint to pick the current wall-clock hour's forecast slot instead
of always using the app-start hour (first item in forecast list)
https://claude.ai/code/session_01KXYBuAHZkvv63DeUG6XaZD
Diffstat (limited to 'android-app/app')
33 files changed, 19 insertions, 2259 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) 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 index deb73d6..34a502c 100644 --- 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 @@ -1,4 +1,4 @@ -package com.example.androidapp.data.model +package org.terst.nav.data.model /** A single harmonic tidal constituent used in harmonic tide prediction. */ data class TideConstituent( 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 index 51eea44..f6c2b72 100644 --- 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 @@ -1,4 +1,4 @@ -package com.example.androidapp.data.model +package org.terst.nav.data.model /** A predicted tide height at a specific point in time. */ data class TidePrediction( 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 index c9f96a6..4e1238e 100644 --- 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 @@ -1,4 +1,4 @@ -package com.example.androidapp.data.model +package org.terst.nav.data.model /** A tide station with harmonic constituents for offline tide prediction. */ data class TideStation( diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt index b1e5652..f0c0ab2 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt @@ -1,7 +1,7 @@ package org.terst.nav.tide -import com.example.androidapp.data.model.TidePrediction -import com.example.androidapp.data.model.TideStation +import org.terst.nav.data.model.TidePrediction +import org.terst.nav.data.model.TideStation import kotlin.math.cos /** 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 f664aa9..33366f9 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 @@ -124,6 +124,7 @@ class MainViewModel( fun onSafDirectorySelected(treeUri: Uri) { trackRepository.initSafDirectory(treeUri) _safState.value = trackRepository.safState() + NavApplication.logbookRepository.reload() viewModelScope.launch { reloadPastTracks() } } @@ -236,10 +237,18 @@ class MainViewModel( lastLat = lat; lastLon = lon; lastSog = sogKnots; lastCog = cogDeg _currentPosition.value = lat to lon val conditions = _marineConditions.value - val forecast = _forecast.value.firstOrNull() + val nowHourPrefix = java.time.Instant.ofEpochMilli(System.currentTimeMillis()) + .toString().substring(0, 13) // "2024-01-15T14" — matches Open-Meteo hourly slots + val forecast = _forecast.value.find { it.timeIso.startsWith(nowHourPrefix) } + ?: _forecast.value.firstOrNull() + val windDirDeg = conditions?.windDirDeg ?: forecast?.windDirDeg + val twa = windDirDeg?.let { ((it - cogDeg + 360.0) % 360.0) } val point = TrackPoint( lat = lat, lon = lon, sogKnots = sogKnots, cogDeg = cogDeg, + windSpeedKnots = conditions?.windSpeedKt ?: forecast?.windKt, + windAngleDeg = twa, + isTrueWind = twa != null, airTempC = forecast?.tempC, waveHeightM = conditions?.waveHeightM, currentSpeedKts = conditions?.currentSpeedKt, diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt deleted file mode 100644 index 535e46a..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/GribStalenessCheckerTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.example.androidapp.data.weather - -import com.example.androidapp.data.model.GribFile -import com.example.androidapp.data.model.GribRegion -import com.example.androidapp.data.storage.InMemoryGribFileManager -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import java.time.Instant - -class GribStalenessCheckerTest { - - private lateinit var manager: InMemoryGribFileManager - private lateinit var checker: GribStalenessChecker - private val region = GribRegion("test", 35.0, 40.0, -125.0, -120.0) - - @Before - fun setUp() { - manager = InMemoryGribFileManager() - checker = GribStalenessChecker(manager) - } - - private fun makeFile( - modelRunTime: Instant, - forecastHours: Int, - downloadedAt: Instant = modelRunTime - ) = GribFile( - region = region, - modelRunTime = modelRunTime, - forecastHours = forecastHours, - downloadedAt = downloadedAt, - filePath = "/tmp/test.grib", - sizeBytes = 1024L - ) - - @Test - fun `check_returnsFresh_whenFileIsNotStale`() { - val now = Instant.parse("2026-03-16T12:00:00Z") - // model run at 06:00, 24h forecast → valid until 06:00 next day, well beyond now - val file = makeFile( - modelRunTime = Instant.parse("2026-03-16T06:00:00Z"), - forecastHours = 24, - downloadedAt = Instant.parse("2026-03-16T07:00:00Z") - ) - manager.saveMetadata(file) - - val result = checker.check(region, now) - - assertTrue("Expected Fresh but got $result", result is FreshnessResult.Fresh) - } - - @Test - fun `check_returnsStale_whenFileIsExpired`() { - val now = Instant.parse("2026-03-16T20:00:00Z") - // model run at 06:00, 6h forecast → valid until 12:00; now is 8h after that - val file = makeFile( - modelRunTime = Instant.parse("2026-03-16T06:00:00Z"), - forecastHours = 6, - downloadedAt = Instant.parse("2026-03-16T07:00:00Z") - ) - manager.saveMetadata(file) - - val result = checker.check(region, now) - - assertTrue("Expected Stale but got $result", result is FreshnessResult.Stale) - val stale = result as FreshnessResult.Stale - assertTrue("Message should contain hours outdated", stale.message.contains("8h")) - assertEquals(file, stale.file) - } - - @Test - fun `check_returnsNoData_whenNoFilesForRegion`() { - val otherRegion = GribRegion("other", 50.0, 55.0, -10.0, 0.0) - val file = makeFile( - modelRunTime = Instant.parse("2026-03-16T06:00:00Z"), - forecastHours = 24 - ) - manager.saveMetadata(file) - - val result = checker.check(otherRegion, Instant.parse("2026-03-16T12:00:00Z")) - - assertEquals(FreshnessResult.NoData, result) - } - - @Test - fun `check_returnsNoData_whenManagerEmpty`() { - val result = checker.check(region, Instant.now()) - - assertEquals(FreshnessResult.NoData, result) - } -} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt deleted file mode 100644 index 4bf7985..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/data/weather/SatelliteGribDownloaderTest.kt +++ /dev/null @@ -1,180 +0,0 @@ -package com.example.androidapp.data.weather - -import com.example.androidapp.data.model.GribParameter -import com.example.androidapp.data.model.GribRegion -import com.example.androidapp.data.model.SatelliteDownloadRequest -import com.example.androidapp.data.storage.InMemoryGribFileManager -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import java.time.Instant - -class SatelliteGribDownloaderTest { - - private lateinit var manager: InMemoryGribFileManager - private lateinit var downloader: SatelliteGribDownloader - - // 10°×10° region at 1°: 11×11 = 121 grid points - private val region10x10 = GribRegion("atlantic", 30.0, 40.0, -70.0, -60.0) - - @Before - fun setUp() { - manager = InMemoryGribFileManager() - downloader = SatelliteGribDownloader(manager) - } - - // ------------------------------------------------------------------ size estimation - - @Test - fun `estimateSizeBytes_scalesWithRegionArea`() { - // 10°×10° region: 11×11 = 121 grid points - val req10 = SatelliteDownloadRequest( - region = region10x10, - parameters = GribParameter.SATELLITE_MINIMAL, - forecastHours = 24 - ) - // 20°×20° region: 21×21 = 441 grid points — roughly 3.6× more grid points - val region20x20 = GribRegion("bigger", 20.0, 40.0, -80.0, -60.0) - val req20 = SatelliteDownloadRequest( - region = region20x20, - parameters = GribParameter.SATELLITE_MINIMAL, - forecastHours = 24 - ) - - val size10 = downloader.estimateSizeBytes(req10) - val size20 = downloader.estimateSizeBytes(req20) - - assertTrue("Larger region must produce larger estimate", size20 > size10) - } - - @Test - fun `estimateSizeBytes_scalesWithParameterCount`() { - val minimalReq = SatelliteDownloadRequest( - region = region10x10, - parameters = GribParameter.SATELLITE_MINIMAL, // 3 params - forecastHours = 24 - ) - val fullReq = SatelliteDownloadRequest( - region = region10x10, - parameters = GribParameter.values().toSet(), // all 7 params - forecastHours = 24 - ) - - val sizeMinimal = downloader.estimateSizeBytes(minimalReq) - val sizeFull = downloader.estimateSizeBytes(fullReq) - - assertTrue("More parameters must produce larger estimate", sizeFull > sizeMinimal) - } - - @Test - fun `estimateSizeBytes_coarserResolutionProducesSmallerFile`() { - val finReq = SatelliteDownloadRequest( - region = region10x10, - parameters = GribParameter.SATELLITE_MINIMAL, - forecastHours = 24, - resolutionDeg = 1.0 - ) - val coarseReq = SatelliteDownloadRequest( - region = region10x10, - parameters = GribParameter.SATELLITE_MINIMAL, - forecastHours = 24, - resolutionDeg = 2.0 - ) - - val sizeFine = downloader.estimateSizeBytes(finReq) - val sizeCoarse = downloader.estimateSizeBytes(coarseReq) - - assertTrue("Coarser resolution must produce smaller estimate", sizeCoarse < sizeFine) - } - - @Test - fun `estimatedDownloadSeconds_atIridiumBandwidth`() { - // 10°×10°, 3 params, 24h at 1° → known estimate - val req = SatelliteDownloadRequest( - region = region10x10, - parameters = GribParameter.SATELLITE_MINIMAL, - forecastHours = 24 - ) - val estBytes = downloader.estimateSizeBytes(req) - val expectedSeconds = Math.ceil(estBytes * 8.0 / SatelliteGribDownloader.SATELLITE_BANDWIDTH_BPS).toLong() - - val actualSeconds = downloader.estimatedDownloadSeconds(req) - - assertEquals(expectedSeconds, actualSeconds) - // Sanity: should be > 0 seconds and less than 10 minutes for a small region - assertTrue("Download estimate must be positive", actualSeconds > 0) - assertTrue("Small 10°×10° should complete in under 10 min at 2.4kbps", actualSeconds < 600) - } - - // ------------------------------------------------------------------ buildMinimalRequest - - @Test - fun `buildMinimalRequest_containsOnlyWindAndPressure`() { - val req = downloader.buildMinimalRequest(region10x10, 48) - - assertEquals(GribParameter.SATELLITE_MINIMAL, req.parameters) - assertTrue(req.parameters.contains(GribParameter.WIND_SPEED)) - assertTrue(req.parameters.contains(GribParameter.WIND_DIRECTION)) - assertTrue(req.parameters.contains(GribParameter.SURFACE_PRESSURE)) - assertFalse(req.parameters.contains(GribParameter.TEMPERATURE_2M)) - assertFalse(req.parameters.contains(GribParameter.PRECIPITATION)) - assertEquals(region10x10, req.region) - assertEquals(48, req.forecastHours) - } - - // ------------------------------------------------------------------ download() - - @Test - fun `download_abortsWhenEstimatedSizeExceedsLimit`() { - val req = downloader.buildMinimalRequest(region10x10, 24) - var fetcherCalled = false - - val result = downloader.download( - request = req, - fetcher = { fetcherCalled = true; ByteArray(100) }, - outputPath = "/tmp/test.grib", - sizeLimitBytes = 1L // ridiculously small limit - ) - - assertTrue("Should abort without calling fetcher", result is SatelliteGribDownloader.DownloadResult.Aborted) - assertFalse("Fetcher must not be called when aborting", fetcherCalled) - val aborted = result as SatelliteGribDownloader.DownloadResult.Aborted - assertTrue("Should report estimated bytes", aborted.estimatedBytes > 0) - } - - @Test - fun `download_returnsFailedWhenFetcherReturnsNull`() { - val req = downloader.buildMinimalRequest(region10x10, 24) - - val result = downloader.download( - request = req, - fetcher = { null }, - outputPath = "/tmp/test.grib" - ) - - assertTrue("Should fail when fetcher returns null", result is SatelliteGribDownloader.DownloadResult.Failed) - } - - @Test - fun `download_savesMetadataAndReturnsSuccessOnValidFetch`() { - val req = downloader.buildMinimalRequest(region10x10, 24) - val fakeBytes = ByteArray(8208) { 0x00 } - val now = Instant.parse("2026-03-16T12:00:00Z") - - val result = downloader.download( - request = req, - fetcher = { fakeBytes }, - outputPath = "/tmp/atlantic.grib", - now = now - ) - - assertTrue("Should succeed", result is SatelliteGribDownloader.DownloadResult.Success) - val success = result as SatelliteGribDownloader.DownloadResult.Success - assertEquals(region10x10, success.file.region) - assertEquals(24, success.file.forecastHours) - assertEquals(fakeBytes.size.toLong(), success.file.sizeBytes) - assertEquals("/tmp/atlantic.grib", success.file.filePath) - // Metadata must be persisted in the manager - assertNotNull(manager.latestFile(region10x10)) - } -} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt deleted file mode 100644 index 8b2753c..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/gps/GpsPositionTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.example.androidapp.gps - -import org.junit.Assert.* -import org.junit.Test - -class GpsPositionTest { - - @Test - fun `GpsPosition holds correct values`() { - val pos = GpsPosition( - latitude = 41.5, - longitude = -71.0, - sog = 5.2, - cog = 180.0, - timestampMs = 1_000L - ) - assertEquals(41.5, pos.latitude, 0.0) - assertEquals(-71.0, pos.longitude, 0.0) - assertEquals(5.2, pos.sog, 0.0) - assertEquals(180.0, pos.cog, 0.0) - assertEquals(1_000L, pos.timestampMs) - } - - @Test - fun `GpsPosition equality works as expected for data class`() { - val pos1 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L) - val pos2 = GpsPosition(41.5, -71.0, 5.2, 180.0, 1_000L) - val pos3 = GpsPosition(42.0, -70.0, 3.0, 90.0, 2_000L) - - assertEquals(pos1, pos2) - assertNotEquals(pos1, pos3) - } -} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt deleted file mode 100644 index 4eb9898..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt +++ /dev/null @@ -1,317 +0,0 @@ -package com.example.androidapp.gps - -import com.example.androidapp.data.model.SensorData -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.junit.Assert.* -import org.junit.Test - -class LocationServiceTest { - - private fun service() = LocationService() - - // ── snapshot with no data ───────────────────────────────────────────────── - - @Test - fun snapshot_noData_allFieldsNull() { - val snap = service().snapshot() - assertNull(snap.windSpeedKt) - assertNull(snap.windDirectionDeg) - assertNull(snap.currentSpeedKt) - assertNull(snap.currentDirectionDeg) - } - - // ── true-wind resolution ────────────────────────────────────────────────── - - @Test - fun updateSensorData_withFullReading_resolvesTrueWind() = runBlocking { - val svc = service() - // Head north (hdg = 0°), AWS = 10 kt coming from ahead (AWA = 0°), BSP = 5 kt - // → TW comes FROM ahead at 5 kt - svc.updateSensorData( - SensorData( - headingTrueDeg = 0.0, - apparentWindSpeedKt = 10.0, - apparentWindAngleDeg = 0.0, - speedOverGroundKt = 5.0 - ) - ) - val tw = svc.latestTrueWind.first() - assertNotNull(tw) - assertTrue("Expected TWS > 0", tw!!.speedKt > 0.0) - } - - @Test - fun updateSensorData_missingHeading_doesNotResolveTrueWind() = runBlocking { - val svc = service() - svc.updateSensorData( - SensorData( - apparentWindSpeedKt = 10.0, - apparentWindAngleDeg = 45.0, - speedOverGroundKt = 5.0 - // headingTrueDeg omitted - ) - ) - assertNull(svc.latestTrueWind.first()) - } - - // ── current conditions ──────────────────────────────────────────────────── - - @Test - fun updateCurrentConditions_reflectedInSnapshot() { - val svc = service() - svc.updateCurrentConditions(speedKt = 1.5, directionDeg = 135.0) - - val snap = svc.snapshot() - assertEquals(1.5, snap.currentSpeedKt!!, 0.001) - assertEquals(135.0, snap.currentDirectionDeg!!, 0.001) - } - - @Test - fun updateCurrentConditions_nullClears() { - val svc = service() - svc.updateCurrentConditions(speedKt = 2.0, directionDeg = 90.0) - svc.updateCurrentConditions(speedKt = null, directionDeg = null) - - val snap = svc.snapshot() - assertNull(snap.currentSpeedKt) - assertNull(snap.currentDirectionDeg) - } - - // ── combined snapshot ───────────────────────────────────────────────────── - - @Test - fun snapshot_afterFullUpdate_populatesAllFields() = runBlocking { - val svc = service() - - // Head east (hdg = 90°), wind from starboard bow, BSP proxy = 6 kt - svc.updateSensorData( - SensorData( - headingTrueDeg = 90.0, - apparentWindSpeedKt = 12.0, - apparentWindAngleDeg = 45.0, - speedOverGroundKt = 6.0 - ) - ) - svc.updateCurrentConditions(speedKt = 0.8, directionDeg = 270.0) - - val snap = svc.snapshot() - assertNotNull(snap.windSpeedKt) - assertNotNull(snap.windDirectionDeg) - assertEquals(0.8, snap.currentSpeedKt!!, 0.001) - assertEquals(270.0, snap.currentDirectionDeg!!, 0.001) - } - - // ── latestSensor flow ───────────────────────────────────────────────────── - - @Test - fun updateSensorData_updatesLatestSensorFlow() = runBlocking { - val svc = service() - assertNull(svc.latestSensor.first()) - - val data = SensorData(latitude = 41.5, longitude = -71.3) - svc.updateSensorData(data) - - assertEquals(data, svc.latestSensor.first()) - } - - // ── GPS sensor fusion ───────────────────────────────────────────────────── - - private fun fusionService( - nmeaStalenessThresholdMs: Long = 5_000L, - nmeaExtendedThresholdMs: Long = 10_000L, - clockMs: () -> Long = System::currentTimeMillis - ) = LocationService( - nmeaStalenessThresholdMs = nmeaStalenessThresholdMs, - nmeaExtendedThresholdMs = nmeaExtendedThresholdMs, - clockMs = clockMs - ) - - private fun pos(lat: Double, lon: Double, timestampMs: Long) = - GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs) - - private fun posWithAccuracy(lat: Double, lon: Double, timestampMs: Long, accuracyMeters: Double) = - GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs, accuracyMeters = accuracyMeters) - - @Test - fun noGpsData_bestPositionNullAndSourceNone() = runBlocking { - val svc = fusionService() - assertNull(svc.bestPosition.first()) - assertEquals(GpsSource.NONE, svc.activeGpsSource.first()) - } - - @Test - fun freshNmea_preferredOverAndroid() = runBlocking { - val now = 10_000L - val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) - - val nmeaFix = pos(41.0, -71.0, now) - val androidFix = pos(42.0, -72.0, now - 1_000L) - - svc.updateAndroidGps(androidFix) - svc.updateNmeaGps(nmeaFix) - - assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) - assertEquals(nmeaFix, svc.bestPosition.first()) - } - - @Test - fun staleNmea_androidFallback() = runBlocking { - val nmeaTime = 0L - val now = 10_000L // 10 s later — NMEA is stale (threshold 5 s) - val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) - - val nmeaFix = pos(41.0, -71.0, nmeaTime) - val androidFix = pos(42.0, -72.0, now) - - svc.updateNmeaGps(nmeaFix) - svc.updateAndroidGps(androidFix) - - assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) - assertEquals(androidFix, svc.bestPosition.first()) - } - - @Test - fun onlyNmeaAvailable_usedEvenWhenStale() = runBlocking { - val now = 60_000L // 60 s after fix — very stale - val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) - - val nmeaFix = pos(41.0, -71.0, 0L) - svc.updateNmeaGps(nmeaFix) - - assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) - assertEquals(nmeaFix, svc.bestPosition.first()) - } - - @Test - fun onlyAndroidAvailable_isUsed() = runBlocking { - val svc = fusionService() - val androidFix = pos(42.0, -72.0, System.currentTimeMillis()) - svc.updateAndroidGps(androidFix) - - assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) - assertEquals(androidFix, svc.bestPosition.first()) - } - - @Test - fun nmeaAtExactThreshold_isConsideredFresh() = runBlocking { - val fixTime = 0L - val now = 5_000L // exactly at threshold - val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) - - val nmeaFix = pos(41.0, -71.0, fixTime) - val androidFix = pos(42.0, -72.0, now) - - svc.updateNmeaGps(nmeaFix) - svc.updateAndroidGps(androidFix) - - assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) - } - - // ── fix-quality (accuracy) tie-breaking ────────────────────────────────── - - @Test - fun marginallyStaleNmea_betterAccuracy_preferredOverAndroid() = runBlocking { - // NMEA is 7 s old (> primary 5 s, ≤ extended 10 s) but has accuracy 3 m vs Android 15 m. - val nmeaTime = 0L - val now = 7_000L - val svc = fusionService( - nmeaStalenessThresholdMs = 5_000L, - nmeaExtendedThresholdMs = 10_000L, - clockMs = { now } - ) - - val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 3.0) - val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 15.0) - - svc.updateNmeaGps(nmeaFix) - svc.updateAndroidGps(androidFix) - - assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) - assertEquals(nmeaFix, svc.bestPosition.first()) - } - - @Test - fun marginallyStaleNmea_worseAccuracy_fallsBackToAndroid() = runBlocking { - // NMEA is 7 s old with accuracy 15 m; Android has accuracy 3 m → Android wins. - val nmeaTime = 0L - val now = 7_000L - val svc = fusionService( - nmeaStalenessThresholdMs = 5_000L, - nmeaExtendedThresholdMs = 10_000L, - clockMs = { now } - ) - - val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 15.0) - val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 3.0) - - svc.updateNmeaGps(nmeaFix) - svc.updateAndroidGps(androidFix) - - assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) - assertEquals(androidFix, svc.bestPosition.first()) - } - - @Test - fun marginallyStaleNmea_noAccuracyData_fallsBackToAndroid() = runBlocking { - // Neither source has accuracy metadata — conservative: prefer Android. - val nmeaTime = 0L - val now = 7_000L - val svc = fusionService( - nmeaStalenessThresholdMs = 5_000L, - nmeaExtendedThresholdMs = 10_000L, - clockMs = { now } - ) - - val nmeaFix = pos(41.0, -71.0, nmeaTime) - val androidFix = pos(42.0, -72.0, now) - - svc.updateNmeaGps(nmeaFix) - svc.updateAndroidGps(androidFix) - - assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) - } - - @Test - fun veryStaleNmea_beyondExtendedThreshold_androidPreferred() = runBlocking { - // NMEA is 15 s old (beyond extended 10 s); Android wins even if NMEA has better accuracy. - val nmeaTime = 0L - val now = 15_000L - val svc = fusionService( - nmeaStalenessThresholdMs = 5_000L, - nmeaExtendedThresholdMs = 10_000L, - clockMs = { now } - ) - - val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 2.0) - val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 20.0) - - svc.updateNmeaGps(nmeaFix) - svc.updateAndroidGps(androidFix) - - assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) - assertEquals(androidFix, svc.bestPosition.first()) - } - - @Test - fun nmeaRecovery_switchesBackFromAndroid() = runBlocking { - var now = 0L - val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) - - // Fresh NMEA - svc.updateNmeaGps(pos(41.0, -71.0, 0L)) - assertEquals(GpsSource.NMEA, svc.activeGpsSource.value) - - // NMEA goes stale; Android takes over - now = 10_000L - val androidFix = pos(42.0, -72.0, 10_000L) - svc.updateAndroidGps(androidFix) - assertEquals(GpsSource.ANDROID, svc.activeGpsSource.value) - - // NMEA recovers with a fresh fix - val freshNmea = pos(41.1, -71.1, 10_000L) - svc.updateNmeaGps(freshNmea) - assertEquals(GpsSource.NMEA, svc.activeGpsSource.value) - assertEquals(freshNmea, svc.bestPosition.value) - } -} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt deleted file mode 100644 index 30b421f..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/logbook/LogbookFormatterTest.kt +++ /dev/null @@ -1,178 +0,0 @@ -package com.example.androidapp.logbook - -import com.example.androidapp.data.model.LogbookEntry -import org.junit.Assert.* -import org.junit.Test - -class LogbookFormatterTest { - - // 2021-06-15 08:00:00 UTC = 1623744000000 ms - private val t0 = 1_623_744_000_000L - - private fun entry( - ts: Long = t0, - lat: Double = 41.39, - lon: Double = -71.202, - sog: Double = 6.2, - cog: Double = 225.0, - windKt: Double? = 15.0, - windDir: Double? = 225.0, - baro: Double? = 1018.0, - depth: Double? = 14.0, - event: String? = "Departed slip", - notes: String? = null - ) = LogbookEntry(ts, lat, lon, sog, cog, windKt, windDir, baro, depth, event, notes) - - // --- formatTime --- - - @Test - fun `formatTime returns HH_MM for UTC midnight`() { - // 2021-06-15 00:00:00 UTC - val ts = 1_623_715_200_000L - assertEquals("00:00", LogbookFormatter.formatTime(ts)) - } - - @Test - fun `formatTime returns correct UTC hour for known timestamp`() { - // t0 = 2021-06-15 08:00:00 UTC - assertEquals("08:00", LogbookFormatter.formatTime(t0)) - } - - @Test - fun `formatTime pads single-digit hour and minute`() { - // 2021-06-15 01:05:00 UTC = 1623715200000 + 65*60*1000 = 1623715200000 + 3900000 - val ts = 1_623_715_200_000L + 65 * 60_000L - assertEquals("01:05", LogbookFormatter.formatTime(ts)) - } - - // --- formatPosition --- - - @Test - fun `formatPosition north east`() { - // 41.39°N → 41°23.4N, 71.202°E → 71°12.1E - val result = LogbookFormatter.formatPosition(41.39, 71.202) - assertEquals("41°23.4N 71°12.1E", result) - } - - @Test - fun `formatPosition south west`() { - // -41.39°S → 41°23.4S, -71.202°W → 71°12.1W - val result = LogbookFormatter.formatPosition(-41.39, -71.202) - assertEquals("41°23.4S 71°12.1W", result) - } - - @Test - fun `formatPosition zero zero`() { - val result = LogbookFormatter.formatPosition(0.0, 0.0) - assertEquals("0°0.0N 0°0.0E", result) - } - - // --- formatWind --- - - @Test - fun `formatWind null knots returns empty string`() { - assertEquals("", LogbookFormatter.formatWind(null, null)) - } - - @Test - fun `formatWind with knots and null direction returns knots only`() { - assertEquals("15kt", LogbookFormatter.formatWind(15.0, null)) - } - - @Test - fun `formatWind 225 degrees is SW`() { - assertEquals("15kt SW", LogbookFormatter.formatWind(15.0, 225.0)) - } - - @Test - fun `formatWind 0 degrees is N`() { - assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 0.0)) - } - - @Test - fun `formatWind 360 degrees is N`() { - assertEquals("10kt N", LogbookFormatter.formatWind(10.0, 360.0)) - } - - @Test - fun `formatWind 90 degrees is E`() { - assertEquals("8kt E", LogbookFormatter.formatWind(8.0, 90.0)) - } - - // --- toCompassPoint --- - - @Test - fun `toCompassPoint covers all 16 cardinal and intercardinal points`() { - val expected = listOf("N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", - "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW") - expected.forEachIndexed { i, dir -> - val degrees = i * 22.5 - assertEquals("degrees=$degrees", dir, LogbookFormatter.toCompassPoint(degrees)) - } - } - - // --- toRow --- - - @Test - fun `toRow formats all fields correctly`() { - val row = LogbookFormatter.toRow(entry()) - assertEquals("08:00", row.time) - assertEquals("41°23.4N 71°12.1W", row.position) - assertEquals("6.2", row.sog) - assertEquals("225", row.cog) - assertEquals("15kt SW", row.wind) - assertEquals("1018", row.baro) - assertEquals("14m", row.depth) - assertEquals("Departed slip", row.eventNotes) - } - - @Test - fun `toRow combines event and notes with colon`() { - val row = LogbookFormatter.toRow(entry(event = "Reef #1", notes = "Strong gusts")) - assertEquals("Reef #1: Strong gusts", row.eventNotes) - } - - @Test - fun `toRow with only notes has no colon prefix`() { - val row = LogbookFormatter.toRow(entry(event = null, notes = "Calm seas")) - assertEquals("Calm seas", row.eventNotes) - } - - @Test - fun `toRow with null optional fields uses empty strings`() { - val e = LogbookEntry(t0, 0.0, 0.0, 0.0, 0.0) - val row = LogbookFormatter.toRow(e) - assertEquals("", row.wind) - assertEquals("", row.baro) - assertEquals("", row.depth) - assertEquals("", row.eventNotes) - } - - // --- toPage --- - - @Test - fun `toPage returns page with default title and correct column count`() { - val page = LogbookFormatter.toPage(emptyList()) - assertEquals("Trip Logbook", page.title) - assertEquals(8, page.columns.size) - } - - @Test - fun `toPage maps entries to rows in order`() { - val entries = listOf( - entry(ts = t0, event = "First"), - entry(ts = t0 + 3_600_000L, event = "Second") - ) - val page = LogbookFormatter.toPage(entries, "Voyage Log") - assertEquals("Voyage Log", page.title) - assertEquals(2, page.rows.size) - assertEquals("First", page.rows[0].eventNotes) - assertEquals("Second", page.rows[1].eventNotes) - } - - @Test - fun `toPage empty entries produces empty rows`() { - val page = LogbookFormatter.toPage(emptyList()) - assertTrue(page.rows.isEmpty()) - } -} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt deleted file mode 100644 index b8a878a..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/nmea/NmeaParserTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.example.androidapp.nmea - -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test - -class NmeaParserTest { - - private lateinit var parser: NmeaParser - - @Before - fun setUp() { - parser = NmeaParser() - } - - // $GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A - // lat: 48 + 7.038/60 = 48.1173°N, lon: 11 + 31.000/60 = 11.51667°E - // SOG 22.4 kn, COG 84.4° - - @Test - fun `valid RMC sentence parses latitude and longitude`() { - val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A" - val pos = parser.parseRmc(sentence) - assertNotNull(pos) - assertEquals(48.1173, pos!!.latitude, 0.0001) - assertEquals(11.51667, pos.longitude, 0.0001) - } - - @Test - fun `valid RMC sentence parses SOG and COG`() { - val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A" - val pos = parser.parseRmc(sentence) - assertNotNull(pos) - assertEquals(22.4, pos!!.sog, 0.001) - assertEquals(84.4, pos.cog, 0.001) - } - - @Test - fun `void status V returns null`() { - val sentence = "\$GPRMC,123519,V,4807.038,N,01131.000,E,,,230394,003.1,W" - assertNull(parser.parseRmc(sentence)) - } - - @Test - fun `malformed sentence with too few fields returns null`() { - assertNull(parser.parseRmc("\$GPRMC,123519,A")) - } - - @Test - fun `empty string returns null`() { - assertNull(parser.parseRmc("")) - } - - @Test - fun `non-RMC sentence returns null`() { - val sentence = "\$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,," - assertNull(parser.parseRmc(sentence)) - } - - @Test - fun `south latitude is negative`() { - // lat: -(42 + 50.5589/60) = -42.84265 - val sentence = "\$GPRMC,092204.999,A,4250.5589,S,14718.5084,E,0.00,89.68,211200,," - val pos = parser.parseRmc(sentence) - assertNotNull(pos) - assertTrue("South latitude must be negative", pos!!.latitude < 0) - assertEquals(-42.84265, pos.latitude, 0.0001) - } - - @Test - fun `west longitude is negative`() { - // lon: -(11 + 31.000/60) = -11.51667 - val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,W,022.4,084.4,230394,003.1,E" - val pos = parser.parseRmc(sentence) - assertNotNull(pos) - assertTrue("West longitude must be negative", pos!!.longitude < 0) - assertEquals(-11.51667, pos.longitude, 0.0001) - } - - @Test - fun `SOG and COG parse with decimal precision`() { - val sentence = "\$GPRMC,093456,A,3352.1234,N,11801.5678,W,12.345,270.5,140326,," - val pos = parser.parseRmc(sentence) - assertNotNull(pos) - assertEquals(12.345, pos!!.sog, 0.0001) - assertEquals(270.5, pos.cog, 0.0001) - } - - @Test - fun `empty SOG and COG fields default to zero`() { - val sentence = "\$GPRMC,123519,A,4807.038,N,01131.000,E,,,230394,003.1,W" - val pos = parser.parseRmc(sentence) - assertNotNull(pos) - assertEquals(0.0, pos!!.sog, 0.001) - assertEquals(0.0, pos.cog, 0.001) - } - - @Test - fun `GNRMC talker ID is also accepted`() { - val sentence = "\$GNRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W" - val pos = parser.parseRmc(sentence) - assertNotNull(pos) - assertEquals(48.1173, pos!!.latitude, 0.0001) - } -} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt deleted file mode 100644 index e5615e9..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/routing/IsochroneRouterTest.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.example.androidapp.routing - -import com.example.androidapp.data.model.BoatPolars -import com.example.androidapp.data.model.WindForecast -import org.junit.Assert.* -import org.junit.Test - -class IsochroneRouterTest { - - private val startTimeMs = 1_000_000_000L - private val oneHourMs = 3_600_000L - - // ── BoatPolars ──────────────────────────────────────────────────────────── - - @Test - fun `bsp returns exact value for exact twa and tws entry`() { - val polars = BoatPolars.DEFAULT - // At TWS=10, TWA=90 the table has 7.0 kt - assertEquals(7.0, polars.bsp(90.0, 10.0), 1e-9) - } - - @Test - fun `bsp interpolates between twa entries`() { - val polars = BoatPolars.DEFAULT - // At TWS=10: TWA=60 → 6.5, TWA=90 → 7.0; midpoint TWA=75 → 6.75 - assertEquals(6.75, polars.bsp(75.0, 10.0), 1e-9) - } - - @Test - fun `bsp interpolates between tws entries`() { - val polars = BoatPolars.DEFAULT - // At TWA=90: TWS=10 → 7.0, TWS=15 → 8.0; midpoint TWS=12.5 → 7.5 - assertEquals(7.5, polars.bsp(90.0, 12.5), 1e-9) - } - - @Test - fun `bsp mirrors port tack twa to starboard`() { - val polars = BoatPolars.DEFAULT - // TWA=270 should mirror to 360-270=90, giving same as TWA=90 - assertEquals(polars.bsp(90.0, 10.0), polars.bsp(270.0, 10.0), 1e-9) - } - - @Test - fun `bsp clamps tws below table minimum`() { - val polars = BoatPolars.DEFAULT - // TWS=0 clamps to minimum TWS=5 - assertEquals(polars.bsp(90.0, 5.0), polars.bsp(90.0, 0.0), 1e-9) - } - - @Test - fun `bsp clamps tws above table maximum`() { - val polars = BoatPolars.DEFAULT - // TWS=100 clamps to maximum TWS=20 - assertEquals(polars.bsp(90.0, 20.0), polars.bsp(90.0, 100.0), 1e-9) - } - - // ── IsochroneRouter geometry helpers ───────────────────────────────────── - - @Test - fun `haversineM returns zero for same point`() { - assertEquals(0.0, IsochroneRouter.haversineM(10.0, 20.0, 10.0, 20.0), 1e-3) - } - - @Test - fun `haversineM one degree of latitude is approximately 111_195 m`() { - val dist = IsochroneRouter.haversineM(0.0, 0.0, 1.0, 0.0) - assertEquals(111_195.0, dist, 50.0) - } - - @Test - fun `bearingDeg returns 0 for due north`() { - val bearing = IsochroneRouter.bearingDeg(0.0, 0.0, 1.0, 0.0) - assertEquals(0.0, bearing, 1e-6) - } - - @Test - fun `bearingDeg returns 90 for due east`() { - val bearing = IsochroneRouter.bearingDeg(0.0, 0.0, 0.0, 1.0) - assertEquals(90.0, bearing, 1e-4) - } - - @Test - fun `destinationPoint due north by 1 NM moves latitude by expected amount`() { - val (lat, lon) = IsochroneRouter.destinationPoint(0.0, 0.0, 0.0, IsochroneRouter.NM_TO_M) - assertTrue("latitude should increase", lat > 0.0) - assertEquals(0.0, lon, 1e-9) - // 1 NM ≈ 1/60 degree of latitude - assertEquals(1.0 / 60.0, lat, 1e-4) - } - - // ── Pruning ─────────────────────────────────────────────────────────────── - - @Test - fun `prune keeps only furthest point per sector`() { - // Two points both due north of origin at different distances - val close = RoutePoint(1.0, 0.0, startTimeMs) - val far = RoutePoint(2.0, 0.0, startTimeMs) - val result = IsochroneRouter.prune(listOf(close, far), 0.0, 0.0, 72) - assertEquals(1, result.size) - assertEquals(far, result[0]) - } - - @Test - fun `prune keeps points in different sectors separately`() { - // One point north, one point east — different sectors - val north = RoutePoint(1.0, 0.0, startTimeMs) - val east = RoutePoint(0.0, 1.0, startTimeMs) - val result = IsochroneRouter.prune(listOf(north, east), 0.0, 0.0, 72) - assertEquals(2, result.size) - } - - // ── Full routing ────────────────────────────────────────────────────────── - - @Test - fun `route finds path to destination with constant wind`() { - // Destination is ~5 NM due east of start; constant 10kt easterly (FROM east = 90°) - // A 10kt boat sailing downwind (TWA=180) = 6.0 kt; ~5 NM / 6 kt ≈ 50 min → 1 step - val destLat = 0.0 - val destLon = 0.0 + (5.0 / 60.0) // ~5 NM east - val constantWind = { _: Double, _: Double, _: Long -> - WindForecast(0.0, 0.0, startTimeMs, twsKt = 10.0, twdDeg = 90.0) - } - val result = IsochroneRouter.route( - startLat = 0.0, - startLon = 0.0, - destLat = destLat, - destLon = destLon, - startTimeMs = startTimeMs, - stepMs = oneHourMs, - polars = BoatPolars.DEFAULT, - windAt = constantWind, - arrivalRadiusM = 2_000.0 // 2 km arrival radius - ) - assertNotNull("Should find a route", result) - result!! - assertTrue("Path should have at least 2 points (start + arrival)", result.path.size >= 2) - assertEquals("Path should start at origin", 0.0, result.path.first().lat, 1e-6) - assertEquals("ETA should be after start", startTimeMs, result.etaMs - oneHourMs) - } - - @Test - fun `route returns null when polars produce zero speed`() { - val zeroPolar = BoatPolars(emptyMap()) - val result = IsochroneRouter.route( - startLat = 0.0, - startLon = 0.0, - destLat = 1.0, - destLon = 0.0, - startTimeMs = startTimeMs, - stepMs = oneHourMs, - polars = zeroPolar, - windAt = { _, _, _ -> WindForecast(0.0, 0.0, startTimeMs, 10.0, 0.0) }, - maxSteps = 3 - ) - assertNull("Should return null when no progress is possible", result) - } - - @Test - fun `backtrace returns path from start to arrival in order`() { - val p0 = RoutePoint(0.0, 0.0, 0L) - val p1 = RoutePoint(1.0, 0.0, 1L, parent = p0) - val p2 = RoutePoint(2.0, 0.0, 2L, parent = p1) - val path = IsochroneRouter.backtrace(p2) - assertEquals(3, path.size) - assertEquals(p0, path[0]) - assertEquals(p1, path[1]) - assertEquals(p2, path[2]) - } -} diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt deleted file mode 100644 index 40f7df0..0000000 --- a/android-app/app/src/test/kotlin/com/example/androidapp/safety/AnchorWatchStateTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.androidapp.safety - -import org.junit.Assert.* -import org.junit.Test -import kotlin.math.sqrt - -class AnchorWatchStateTest { - - private val state = AnchorWatchState() - - @Test - fun calculateRecommendedWatchCircleRadius_validGeometry() { - // depth=6m, rode=50m → vertical=8m, radius=sqrt(50²-8²)=sqrt(2436) - val expected = sqrt(2436.0) - val actual = state.calculateRecommendedWatchCircleRadius(depthM = 6.0, rodeOutM = 50.0) - assertEquals(expected, actual, 0.001) - } - - @Test - fun calculateRecommendedWatchCircleRadius_rodeShorterThanVertical_fallsBackToRode() { - // depth=10m, rode=5m → vertical=12m > rode, fallback returns rode - val actual = state.calculateRecommendedWatchCircleRadius(depthM = 10.0, rodeOutM = 5.0) - assertEquals(5.0, actual, 0.001) - } - - @Test - fun calculateRecommendedWatchCircleRadius_rodeEqualsVertical_fallsBackToRode() { - // depth=8m, rode=10m → vertical=10m == rode, fallback returns rode - val actual = state.calculateRecommendedWatchCircleRadius(depthM = 8.0, rodeOutM = 10.0) - assertEquals(10.0, actual, 0.001) - } -} diff --git a/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt index 0a6f4bb..771043c 100644 --- a/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt +++ b/android-app/app/src/test/kotlin/org/terst/nav/data/model/TideModelTest.kt @@ -1,4 +1,4 @@ -package com.example.androidapp.data.model +package org.terst.nav.data.model import org.junit.Assert.* import org.junit.Test diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt b/android-app/app/src/test/kotlin/org/terst/nav/tide/HarmonicTideCalculatorTest.kt index 612ae34..d18209c 100644 --- a/android-app/app/src/test/kotlin/com/example/androidapp/tide/HarmonicTideCalculatorTest.kt +++ b/android-app/app/src/test/kotlin/org/terst/nav/tide/HarmonicTideCalculatorTest.kt @@ -1,7 +1,7 @@ -package com.example.androidapp.tide +package org.terst.nav.tide -import com.example.androidapp.data.model.TideConstituent -import com.example.androidapp.data.model.TideStation +import org.terst.nav.data.model.TideConstituent +import org.terst.nav.data.model.TideStation import org.junit.Assert.* import org.junit.Test |
