diff options
Diffstat (limited to 'android-app/app/src/main/kotlin/com')
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) |
