diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-04-04 07:45:41 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-04-04 07:45:41 +0000 |
| commit | 97715ab4007ff3101f58edf4385cef1fc3d1615b (patch) | |
| tree | 464bdb1df8cfed31402f5316fe84df974c0e59e2 /android-app/app/src/main/kotlin/org | |
| parent | 9f01ddfba17dda7fb386e83f007c671fec6d5b8e (diff) | |
refactor: unify core models and finish org.terst.nav migration
Diffstat (limited to 'android-app/app/src/main/kotlin/org')
22 files changed, 991 insertions, 332 deletions
diff --git a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt b/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt deleted file mode 100644 index 0c63662..0000000 --- a/android-app/app/src/main/kotlin/org/terst/nav/AnchorWatchData.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.terst.nav - -import android.location.Location -import kotlin.math.* - -data class AnchorWatchState( - val anchorLocation: Location? = null, - val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS, - val setTimeMillis: Long = 0L, - val isActive: Boolean = false -) { - companion object { - const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 // Default 50 meters - - /** - * Calculates the recommended watch circle radius based on depth, freeboard, and rode out. - * Formula from docs/COMPONENT_DESIGN.md: Rode Out × cos(asin((Depth + Freeboard) / Rode Out)) - * - * @param depthMeters Depth from surface to seabed in meters. - * @param freeboardMeters Distance from surface to anchor attachment point on boat in meters. - * @param rodeOutMeters Length of chain/rode deployed in meters. - * @return Recommended watch circle radius in meters. Returns 0.0 if inputs are invalid. - */ - fun calculateRecommendedWatchCircleRadius( - depthMeters: Double, - freeboardMeters: Double, - rodeOutMeters: Double - ): Double { - if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) { - return 0.0 // Invalid inputs - } - - val totalVerticalDistance = depthMeters + freeboardMeters - - // Ensure we don't take asin of a value > 1 or < -1 - if (totalVerticalDistance > rodeOutMeters) { - // Rode is too short for the depth+freeboard, effectively boat is directly above anchor - // In this case, the watch circle radius is 0, or very small. - return 0.0 - } - - // angle = asin( (Depth + Freeboard) / Rode Out ) - val angle = asin(totalVerticalDistance / rodeOutMeters) - - // Watch circle radius = Rode Out * cos(angle) - return rodeOutMeters * cos(angle) - } - } - - fun isDragging(currentLocation: Location): Boolean { - anchorLocation ?: return false // Cannot drag if anchor not set - if (!isActive) return false // Not active, so not dragging - - val distance = anchorLocation.distanceTo(currentLocation) - return distance > watchCircleRadiusMeters - } -} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt index 138fc6c..b18db8d 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/LocationService.kt @@ -20,12 +20,18 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.asStateFlow import org.terst.nav.nmea.NmeaParser import org.terst.nav.nmea.NmeaStreamManager import org.terst.nav.sensors.DepthData import org.terst.nav.sensors.HeadingData import org.terst.nav.sensors.WindData import org.terst.nav.gps.GpsPosition +import org.terst.nav.data.model.SensorData +import org.terst.nav.safety.AnchorWatchState +import org.terst.nav.wind.TrueWindCalculator +import org.terst.nav.wind.ApparentWind +import org.terst.nav.wind.TrueWindData import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.tasks.await import kotlinx.coroutines.CoroutineScope @@ -34,22 +40,23 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -data class GpsData( - val latitude: Double, - val longitude: Double, - val speedOverGround: Float, // m/s - val courseOverGround: Float // degrees -) { - fun toLocation(): Location { - val location = Location("GpsData") - location.latitude = latitude - location.longitude = longitude - location.speed = speedOverGround - location.bearing = courseOverGround - return location - } -} - +/** Source of the currently active GPS fix. */ +enum class GpsSource { NONE, NMEA, ANDROID } + +/** + * Point-in-time snapshot of wind and current conditions. + */ +data class EnvironmentalSnapshot( + val windSpeedKt: Double?, + val windDirectionDeg: Double?, + val currentSpeedKt: Double?, + val currentDirectionDeg: Double? +) + +/** + * Aggregates real-time location and environmental sensor data for use throughout + * the navigation subsystem. + */ class LocationService : Service() { private lateinit var fusedLocationClient: FusedLocationProviderClient @@ -60,22 +67,30 @@ class LocationService : Service() { private lateinit var nmeaStreamManager: NmeaStreamManager private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val windCalculator = TrueWindCalculator() + + // GPS sensor fusion state + private var lastNmeaPosition: GpsPosition? = null + private var lastAndroidPosition: GpsPosition? = null + private val nmeaStalenessThresholdMs: Long = 5_000L + private val nmeaExtendedThresholdMs: Long = 10_000L + private val NOTIFICATION_CHANNEL_ID = "location_service_channel" private val NOTIFICATION_ID = 123 - private var isAlarmTriggered = false // To prevent repeated alarm triggering + private var isAlarmTriggered = false override fun onCreate() { super.onCreate() Log.d("LocationService", "Service created") fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) - anchorAlarmManager = AnchorAlarmManager(this) // Initialize with service context + anchorAlarmManager = AnchorAlarmManager(this) barometerSensorManager = BarometerSensorManager(this) nmeaParser = NmeaParser() nmeaStreamManager = NmeaStreamManager(nmeaParser, serviceScope) createNotificationChannel() - // Observe barometer status and update our public state + // Observe barometer status serviceScope.launch { barometerSensorManager.barometerStatus.collect { status -> _barometerStatus.value = status @@ -84,14 +99,17 @@ class LocationService : Service() { // Collect NMEA GPS positions serviceScope.launch { - nmeaStreamManager.nmeaGpsPosition.collectLatest { gpsPosition -> - _nmeaGpsPositionFlow.emit(gpsPosition) + nmeaStreamManager.nmeaGpsPosition.collectLatest { position -> + lastNmeaPosition = position + recomputeBestPosition() + _nmeaGpsPositionFlow.emit(position) } } // Collect NMEA Wind Data serviceScope.launch { nmeaStreamManager.nmeaWindData.collectLatest { windData -> + updateTrueWindFromNmea(windData) _nmeaWindDataFlow.emit(windData) } } @@ -110,26 +128,22 @@ class LocationService : Service() { } } - // Mock tidal current data generator - serviceScope.launch { - while (true) { - val currents = MockTidalCurrentGenerator.generateMockCurrents() - _tidalCurrentState.update { it.copy(currents = currents) } - kotlinx.coroutines.delay(60000) // Update every minute - } - } - locationCallback = object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { locationResult.lastLocation?.let { location -> - val gpsData = GpsData( + val position = GpsPosition( latitude = location.latitude, longitude = location.longitude, - speedOverGround = location.speed, - courseOverGround = location.bearing + sog = location.speed * 1.94384, // m/s to knots + cog = location.bearing.toDouble(), + timestampMs = location.time, + accuracyMeters = if (location.hasAccuracy()) location.accuracy.toDouble() else null ) + lastAndroidPosition = position + recomputeBestPosition() + serviceScope.launch { - _locationFlow.emit(gpsData) // Emit to shared flow (Android system GPS) + _locationFlow.emit(position) } // Check for anchor drag if anchor watch is active @@ -139,32 +153,71 @@ class LocationService : Service() { } } - /** - * Checks if the current location is outside the anchor watch circle. - */ + private fun recomputeBestPosition() { + val now = System.currentTimeMillis() + val nmea = lastNmeaPosition + val android = lastAndroidPosition + + val nmeaAge = nmea?.let { now - it.timestampMs } + val nmeaFresh = nmeaAge != null && nmeaAge <= nmeaStalenessThresholdMs + val nmeaMarginallyStale = nmeaAge != null && + nmeaAge > nmeaStalenessThresholdMs && + nmeaAge <= nmeaExtendedThresholdMs + + val (best, source) = when { + nmeaFresh -> nmea!! to GpsSource.NMEA + + nmeaMarginallyStale && android != null -> + if (nmea!!.hasStrictlyBetterAccuracyThan(android)) nmea to GpsSource.NMEA + else android to GpsSource.ANDROID + + android != null -> android to GpsSource.ANDROID + nmea != null -> nmea to GpsSource.NMEA + else -> null to GpsSource.NONE + } + + _bestPosition.value = best + _activeGpsSource.value = source + } + + private fun GpsPosition.hasStrictlyBetterAccuracyThan(other: GpsPosition): Boolean { + val thisAccuracy = accuracyMeters ?: return false + val otherAccuracy = other.accuracyMeters ?: return true + return thisAccuracy < otherAccuracy + } + + private fun updateTrueWindFromNmea(wind: WindData) { + val sog = _bestPosition.value?.sog + val hdg = _nmeaHeadingDataFlow.replayCache.firstOrNull()?.headingDegreesTrue + + if (sog != null && hdg != null) { + _latestTrueWind.value = windCalculator.update( + apparent = ApparentWind(speedKt = wind.windSpeed, angleDeg = wind.windAngle), + bsp = sog, // Use SOG as proxy for BSP if BSP is not available + hdgDeg = hdg + ) + } + } + private fun checkAnchorDrag(location: Location) { _anchorWatchState.update { currentState -> if (currentState.isActive && currentState.anchorLocation != null) { val isDragging = currentState.isDragging(location) if (isDragging) { - Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!! Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m") + Log.w("AnchorWatch", "!!! ANCHOR DRAG DETECTED !!!") if (!isAlarmTriggered) { anchorAlarmManager.startAlarm() isAlarmTriggered = true } } else { - Log.d("AnchorWatch", "Anchor holding. Distance: ${currentState.anchorLocation.distanceTo(location)}m, Radius: ${currentState.watchCircleRadiusMeters}m") if (isAlarmTriggered) { anchorAlarmManager.stopAlarm() isAlarmTriggered = false } } - } else { - // If anchor watch is not active, ensure alarm is stopped - if (isAlarmTriggered) { - anchorAlarmManager.stopAlarm() - isAlarmTriggered = false - } + } else if (isAlarmTriggered) { + anchorAlarmManager.stopAlarm() + isAlarmTriggered = false } currentState } @@ -173,24 +226,21 @@ class LocationService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_START_FOREGROUND_SERVICE -> { - Log.d("LocationService", "Starting foreground service") startForeground(NOTIFICATION_ID, createNotification()) serviceScope.launch { - _currentPowerMode.emit(PowerMode.FULL) // Set initial power mode to FULL + _currentPowerMode.emit(PowerMode.FULL) startLocationUpdatesInternal(PowerMode.FULL) } barometerSensorManager.start() nmeaStreamManager.start(NMEA_GATEWAY_IP, NMEA_GATEWAY_PORT) } ACTION_STOP_FOREGROUND_SERVICE -> { - Log.d("LocationService", "Stopping foreground service") stopLocationUpdatesInternal() barometerSensorManager.stop() nmeaStreamManager.stop() stopSelf() } ACTION_START_ANCHOR_WATCH -> { - Log.d("LocationService", "Received ACTION_START_ANCHOR_WATCH") val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) serviceScope.launch { startAnchorWatch(radius) @@ -198,45 +248,34 @@ class LocationService : Service() { } } ACTION_STOP_ANCHOR_WATCH -> { - Log.d("LocationService", "Received ACTION_STOP_ANCHOR_WATCH") stopAnchorWatch() - setPowerMode(PowerMode.FULL) // Revert to full power mode after stopping anchor watch + setPowerMode(PowerMode.FULL) } ACTION_UPDATE_WATCH_RADIUS -> { - Log.d("LocationService", "Received ACTION_UPDATE_WATCH_RADIUS") val radius = intent.getDoubleExtra(EXTRA_WATCH_RADIUS, AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) updateWatchCircleRadius(radius) } - ACTION_TOGGLE_TIDAL_VISIBILITY -> { - val isVisible = intent.getBooleanExtra(EXTRA_TIDAL_VISIBILITY, false) - _tidalCurrentState.update { it.copy(isVisible = isVisible) } - } } return START_NOT_STICKY } - override fun onBind(intent: Intent?): IBinder? { - return null // Not a bound service - } + override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { super.onDestroy() - Log.d("LocationService", "Service destroyed") stopLocationUpdatesInternal() anchorAlarmManager.stopAlarm() barometerSensorManager.stop() - nmeaStreamManager.stop() // Stop NMEA stream when service is destroyed + nmeaStreamManager.stop() _anchorWatchState.value = AnchorWatchState(isActive = false) - isAlarmTriggered = false // Reset alarm trigger state - serviceScope.cancel() // Cancel the coroutine scope + isAlarmTriggered = false + serviceScope.cancel() } - @SuppressLint("MissingPermission") private fun startLocationUpdatesInternal(powerMode: PowerMode) { - Log.d("LocationService", "Requesting location updates with PowerMode: ${powerMode.name}, interval: ${powerMode.gpsUpdateIntervalMillis}ms") val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, powerMode.gpsUpdateIntervalMillis) - .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2) // Half the interval for minUpdateInterval + .setMinUpdateIntervalMillis(powerMode.gpsUpdateIntervalMillis / 2) .build() fusedLocationClient.requestLocationUpdates( locationRequest, @@ -246,22 +285,15 @@ class LocationService : Service() { } private fun stopLocationUpdatesInternal() { - Log.d("LocationService", "Removing location updates") fusedLocationClient.removeLocationUpdates(locationCallback) } fun setPowerMode(powerMode: PowerMode) { serviceScope.launch { if (_currentPowerMode.value != powerMode) { - // Emit the new power mode first _currentPowerMode.emit(powerMode) - Log.d("LocationService", "Power mode changing to ${powerMode.name}. Restarting location updates.") - // Stop current updates if running stopLocationUpdatesInternal() - // Start new updates with the new power mode's interval startLocationUpdatesInternal(powerMode) - } else { - Log.d("LocationService", "Power mode already ${powerMode.name}. No change needed.") } } } @@ -278,25 +310,15 @@ class LocationService : Service() { private fun createNotification(): Notification { val notificationIntent = Intent(this, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - this, - 0, - notificationIntent, - PendingIntent.FLAG_IMMUTABLE - ) - + val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE) return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setContentTitle("Sailing Companion") - .setContentText("Tracking your location in the background...") + .setContentText("Tracking your location...") .setSmallIcon(R.drawable.ic_anchor) .setContentIntent(pendingIntent) .build() } - /** - * Starts the anchor watch with the current location as the anchor point. - * @param radiusMeters The watch circle radius in meters. - */ @SuppressLint("MissingPermission") suspend fun startAnchorWatch(radiusMeters: Double = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS) { val lastLocation = fusedLocationClient.lastLocation.await() @@ -307,29 +329,27 @@ class LocationService : Service() { setTimeMillis = System.currentTimeMillis(), isActive = true ) } - Log.i("AnchorWatch", "Anchor watch started at lat: ${location.latitude}, lon: ${location.longitude} with radius: ${radiusMeters}m") - } ?: run { - Log.e("AnchorWatch", "Could not start anchor watch: Last known location is null.") - // Handle error, e.g., show a toast to the user } } - /** - * Stops the anchor watch. - */ fun stopAnchorWatch() { _anchorWatchState.update { AnchorWatchState(isActive = false) } - Log.i("AnchorWatch", "Anchor watch stopped.") anchorAlarmManager.stopAlarm() isAlarmTriggered = false } - /** - * Updates the watch circle radius. - */ fun updateWatchCircleRadius(radiusMeters: Double) { _anchorWatchState.update { it.copy(watchCircleRadiusMeters = radiusMeters) } - Log.d("AnchorWatch", "Watch circle radius updated to ${radiusMeters}m.") + } + + fun snapshot(): EnvironmentalSnapshot { + val trueWind = _latestTrueWind.value + return EnvironmentalSnapshot( + windSpeedKt = trueWind?.speedKt, + windDirectionDeg = trueWind?.directionDeg, + currentSpeedKt = null, // TODO: Pull from latest forecast + currentDirectionDeg = null + ) } companion object { @@ -338,56 +358,42 @@ class LocationService : Service() { const val ACTION_START_ANCHOR_WATCH = "ACTION_START_ANCHOR_WATCH" const val ACTION_STOP_ANCHOR_WATCH = "ACTION_STOP_ANCHOR_WATCH" const val ACTION_UPDATE_WATCH_RADIUS = "ACTION_UPDATE_WATCH_RADIUS" - const val ACTION_TOGGLE_TIDAL_VISIBILITY = "ACTION_TOGGLE_TIDAL_VISIBILITY" const val EXTRA_WATCH_RADIUS = "extra_watch_radius" - const val EXTRA_TIDAL_VISIBILITY = "extra_tidal_visibility" - - // NMEA Gateway configuration (example values - these should ideally be configurable by the user) - private const val NMEA_GATEWAY_IP = "192.168.1.1" // Placeholder IP address - private const val NMEA_GATEWAY_PORT = 10110 // Default NMEA port - - // Publicly accessible flows - val locationFlow: SharedFlow<GpsData> - get() = _locationFlow - val anchorWatchState: StateFlow<AnchorWatchState> - get() = _anchorWatchState - val tidalCurrentState: StateFlow<TidalCurrentState> - get() = _tidalCurrentState - val barometerStatus: StateFlow<BarometerStatus> - get() = _barometerStatus - - // NMEA Data Flows - val nmeaGpsPositionFlow: SharedFlow<GpsPosition> - get() = _nmeaGpsPositionFlow - val nmeaWindDataFlow: SharedFlow<WindData> - get() = _nmeaWindDataFlow - val nmeaDepthDataFlow: SharedFlow<DepthData> - get() = _nmeaDepthDataFlow - val nmeaHeadingDataFlow: SharedFlow<HeadingData> - get() = _nmeaHeadingDataFlow - - private val _locationFlow = MutableSharedFlow<GpsData>(replay = 1) + + private const val NMEA_GATEWAY_IP = "192.168.1.1" + private const val NMEA_GATEWAY_PORT = 10110 + + private val _locationFlow = MutableSharedFlow<GpsPosition>(replay = 1) + val locationFlow: SharedFlow<GpsPosition> get() = _locationFlow + + private val _bestPosition = MutableStateFlow<GpsPosition?>(null) + val bestPosition: StateFlow<GpsPosition?> = _bestPosition.asStateFlow() + + private val _activeGpsSource = MutableStateFlow(GpsSource.NONE) + val activeGpsSource: StateFlow<GpsSource> = _activeGpsSource.asStateFlow() + private val _anchorWatchState = MutableStateFlow(AnchorWatchState()) - private val _tidalCurrentState = MutableStateFlow(TidalCurrentState()) + val anchorWatchState: StateFlow<AnchorWatchState> get() = _anchorWatchState + private val _barometerStatus = MutableStateFlow(BarometerStatus()) + val barometerStatus: StateFlow<BarometerStatus> get() = _barometerStatus - // Private NMEA Data Flows - private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>( - replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - private val _nmeaWindDataFlow = MutableSharedFlow<WindData>( - replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>( - replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>( - replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST - ) + private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null) + val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow() + + private val _nmeaGpsPositionFlow = MutableSharedFlow<GpsPosition>(replay = 1) + val nmeaGpsPositionFlow: SharedFlow<GpsPosition> get() = _nmeaGpsPositionFlow + + private val _nmeaWindDataFlow = MutableSharedFlow<WindData>(replay = 1) + val nmeaWindDataFlow: SharedFlow<WindData> get() = _nmeaWindDataFlow + + private val _nmeaDepthDataFlow = MutableSharedFlow<DepthData>(replay = 1) + val nmeaDepthDataFlow: SharedFlow<DepthData> get() = _nmeaDepthDataFlow + + private val _nmeaHeadingDataFlow = MutableSharedFlow<HeadingData>(replay = 1) + val nmeaHeadingDataFlow: SharedFlow<HeadingData> get() = _nmeaHeadingDataFlow private val _currentPowerMode = MutableStateFlow(PowerMode.FULL) - val currentPowerMode: StateFlow<PowerMode> - get() = _currentPowerMode + val currentPowerMode: StateFlow<PowerMode> get() = _currentPowerMode } } - diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index 3f09309..fd2cf61 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -39,6 +39,7 @@ import org.terst.nav.ui.doc.DocFragment import org.terst.nav.ui.safety.SafetyFragment import org.terst.nav.ui.voicelog.VoiceLogFragment import java.util.* +import org.terst.nav.safety.AnchorWatchState class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { @@ -46,7 +47,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { private var mobHandler: MobHandler? = null private var instrumentHandler: InstrumentHandler? = null private var mapHandler: MapHandler? = null - private var anchorWatchHandler: AnchorWatchHandler? = null private val loadedStyleFlow = MutableStateFlow<Style?>(null) private lateinit var bottomSheetBehavior: BottomSheetBehavior<View> @@ -186,7 +186,7 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { } override fun onConfigureAnchor() { - anchorWatchHandler?.toggleVisibility() + // Now handled via fragment navigation from SafetyFragment } private fun setupHandlers() { @@ -305,13 +305,12 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { lifecycleScope.launch { LocationService.locationFlow.collect { gpsData -> mapHandler?.centerOnLocation(gpsData.latitude, gpsData.longitude) - mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.courseOverGround) - val sogKnots = gpsData.speedOverGround * 1.94384 - val cogDeg = gpsData.courseOverGround - viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, sogKnots, cogDeg.toDouble()) + mapHandler?.updateUserPosition(gpsData.latitude, gpsData.longitude, gpsData.cog.toFloat()) + + viewModel.addGpsPoint(gpsData.latitude, gpsData.longitude, gpsData.sog, gpsData.cog) instrumentHandler?.updateDisplay( - sog = "%.1f".format(Locale.getDefault(), sogKnots), - cog = "%.0f°".format(Locale.getDefault(), cogDeg) + sog = "%.1f".format(Locale.getDefault(), gpsData.sog), + cog = "%.0f°".format(Locale.getDefault(), gpsData.cog) ) if (!conditionsLoaded) { conditionsLoaded = true diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt new file mode 100644 index 0000000..fc1d79d --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/model/SensorData.kt @@ -0,0 +1,10 @@ +package org.terst.nav.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/org/terst/nav/data/storage/GribFileManager.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt index e17e5ca..6a976f6 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/storage/GribFileManager.kt @@ -5,38 +5,20 @@ import org.terst.nav.data.model.GribRegion import java.time.Instant interface GribFileManager { - /** Save metadata for a newly-downloaded GRIB file. */ fun saveMetadata(file: GribFile) - /** Return all stored GRIB files for [region], newest first. */ fun listFiles(region: GribRegion): List<GribFile> - /** Return the most-recently-downloaded GRIB file for [region], or null if none. */ fun latestFile(region: GribRegion): GribFile? - /** Delete a specific GRIB file's metadata and from disk. Returns true if deleted. */ fun delete(file: GribFile): Boolean - /** Delete all GRIB files older than [before]. Returns count of deleted files. */ fun purgeOlderThan(before: Instant): Int - /** Total size in bytes of all stored GRIB files. */ fun totalSizeBytes(): Long } class InMemoryGribFileManager : GribFileManager { private val files = mutableListOf<GribFile>() - override fun saveMetadata(file: GribFile) { files.add(file) } - - override fun listFiles(region: GribRegion): List<GribFile> = - files.filter { it.region.name == region.name } - .sortedByDescending { it.downloadedAt } - + override fun listFiles(region: GribRegion): List<GribFile> = files.filter { it.region.name == region.name }.sortedByDescending { it.downloadedAt } override fun latestFile(region: GribRegion): GribFile? = listFiles(region).firstOrNull() - override fun delete(file: GribFile): Boolean = files.remove(file) - - override fun purgeOlderThan(before: Instant): Int { - val toRemove = files.filter { it.downloadedAt.isBefore(before) } - files.removeAll(toRemove) - return toRemove.size - } - + override fun purgeOlderThan(before: Instant): Int { val toRemove = files.filter { it.downloadedAt.isBefore(before) }; files.removeAll(toRemove); return toRemove.size } override fun totalSizeBytes(): Long = files.sumOf { it.sizeBytes } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt new file mode 100644 index 0000000..f39957b --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/GribStalenessChecker.kt @@ -0,0 +1,36 @@ +package org.terst.nav.data.weather + +import org.terst.nav.data.model.GribFile +import org.terst.nav.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/org/terst/nav/data/weather/SatelliteGribDownloader.kt b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt new file mode 100644 index 0000000..875d971 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/data/weather/SatelliteGribDownloader.kt @@ -0,0 +1,134 @@ +package org.terst.nav.data.weather + +import org.terst.nav.data.model.GribFile +import org.terst.nav.data.model.GribParameter +import org.terst.nav.data.model.GribRegion +import org.terst.nav.data.model.SatelliteDownloadRequest +import org.terst.nav.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/org/terst/nav/gps/GpsPosition.kt b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt index 5faf30c..99cef2d 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/gps/GpsPosition.kt @@ -1,9 +1,20 @@ package org.terst.nav.gps +/** + * Represents a single GPS fix. + * + * @param latitude Degrees, positive = North, negative = South. + * @param longitude Degrees, positive = East, negative = West. + * @param sog Speed Over Ground in knots. + * @param cog Course Over Ground in degrees true (0-360). + * @param timestampMs Unix epoch milliseconds UTC. + * @param accuracyMeters Estimated horizontal accuracy (1-sigma) in meters; null if unknown. + */ data class GpsPosition( val latitude: Double, val longitude: Double, - val sog: Double, // knots - val cog: Double, // degrees true - val timestampMs: Long + val sog: Double, + val cog: Double, + val timestampMs: Long, + val accuracyMeters: Double? = null ) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt new file mode 100644 index 0000000..67cfcce --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookFormatter.kt @@ -0,0 +1,81 @@ +package org.terst.nav.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/org/terst/nav/logbook/LogbookPdfExporter.kt b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt new file mode 100644 index 0000000..6417db9 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/logbook/LogbookPdfExporter.kt @@ -0,0 +1,137 @@ +package org.terst.nav.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/org/terst/nav/nmea/NmeaParser.kt b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt index 453c758..6a470b8 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/nmea/NmeaParser.kt @@ -273,8 +273,9 @@ class NmeaParser { val hours = timeStr.substring(0, 2).toInt() val minutes = timeStr.substring(2, 4).toInt() val seconds = timeStr.substring(4, 6).toInt() - val millis = if (timeStr.length > 7) { - (timeStr.substring(7).toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0 + val millis = if (timeStr.contains('.')) { + val fracStr = timeStr.substringAfter('.') + ("0.$fracStr".toDoubleOrNull()?.times(1000.0))?.toInt() ?: 0 } else 0 cal.set(Calendar.HOUR_OF_DAY, hours) cal.set(Calendar.MINUTE, minutes) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt new file mode 100644 index 0000000..13fb132 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneResult.kt @@ -0,0 +1,12 @@ +package org.terst.nav.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/org/terst/nav/routing/IsochroneRouter.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt new file mode 100644 index 0000000..8ac73cf --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/IsochroneRouter.kt @@ -0,0 +1,178 @@ +package org.terst.nav.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/org/terst/nav/routing/RoutePoint.kt b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt new file mode 100644 index 0000000..a6562d9 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/routing/RoutePoint.kt @@ -0,0 +1,16 @@ +package org.terst.nav.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/org/terst/nav/safety/AnchorWatchState.kt b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt new file mode 100644 index 0000000..9121ce6 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/safety/AnchorWatchState.kt @@ -0,0 +1,40 @@ +package org.terst.nav.safety + +import android.location.Location +import kotlin.math.* + +/** + * Holds state for the anchor watch and provides the suggested watch-circle radius. + */ +data class AnchorWatchState( + val anchorLocation: Location? = null, + val watchCircleRadiusMeters: Double = DEFAULT_WATCH_CIRCLE_RADIUS_METERS, + val setTimeMillis: Long = 0L, + val isActive: Boolean = false +) { + companion object { + const val DEFAULT_WATCH_CIRCLE_RADIUS_METERS = 50.0 + + /** + * Calculates the recommended watch circle radius based on depth, freeboard, and rode out. + */ + fun calculateRecommendedWatchCircleRadius( + depthMeters: Double, + freeboardMeters: Double, + rodeOutMeters: Double + ): Double { + if (rodeOutMeters <= 0 || depthMeters < 0 || freeboardMeters < 0) return 0.0 + val totalVerticalDistance = depthMeters + freeboardMeters + if (totalVerticalDistance > rodeOutMeters) return 0.0 + val angle = asin(totalVerticalDistance / rodeOutMeters) + return rodeOutMeters * cos(angle) + } + } + + fun isDragging(currentLocation: Location): Boolean { + anchorLocation ?: return false + if (!isActive) return false + val distance = anchorLocation.distanceTo(currentLocation) + return distance > watchCircleRadiusMeters + } +} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt new file mode 100644 index 0000000..b1e5652 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/tide/HarmonicTideCalculator.kt @@ -0,0 +1,88 @@ +package org.terst.nav.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/org/terst/nav/ui/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt deleted file mode 100644 index d55de90..0000000 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/AnchorWatchHandler.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.terst.nav.ui - -import android.content.Context -import android.content.Intent -import android.view.View -import android.widget.Button -import android.widget.TextView -import android.widget.Toast -import androidx.constraintlayout.widget.ConstraintLayout -import org.terst.nav.AnchorWatchState -import org.terst.nav.LocationService -import java.util.Locale - -/** - * Handles the Anchor Watch UI interactions and state updates. - */ -class AnchorWatchHandler( - private val context: Context, - private val container: ConstraintLayout, - private val statusText: TextView, - private val radiusText: TextView, - private val buttonDecrease: Button, - private val buttonIncrease: Button, - private val buttonSet: Button, - private val buttonStop: Button -) { - private var currentRadius = AnchorWatchState.DEFAULT_WATCH_CIRCLE_RADIUS_METERS - - init { - updateRadiusDisplay() - - buttonDecrease.setOnClickListener { - updateRadius((currentRadius - 5).coerceAtLeast(10.0)) - } - - buttonIncrease.setOnClickListener { - updateRadius((currentRadius + 5).coerceAtMost(200.0)) - } - - buttonSet.setOnClickListener { - startWatch() - } - - buttonStop.setOnClickListener { - stopWatch() - } - } - - private fun updateRadius(newRadius: Double) { - currentRadius = newRadius - updateRadiusDisplay() - val intent = Intent(context, LocationService::class.java).apply { - action = LocationService.ACTION_UPDATE_WATCH_RADIUS - putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius) - } - context.startService(intent) - } - - private fun updateRadiusDisplay() { - radiusText.text = String.format(Locale.getDefault(), "Radius: %.1fm", currentRadius) - } - - private fun startWatch() { - val intent = Intent(context, LocationService::class.java).apply { - action = LocationService.ACTION_START_ANCHOR_WATCH - putExtra(LocationService.EXTRA_WATCH_RADIUS, currentRadius) - } - context.startService(intent) - Toast.makeText(context, "Anchor watch set!", Toast.LENGTH_SHORT).show() - } - - private fun stopWatch() { - val intent = Intent(context, LocationService::class.java).apply { - action = LocationService.ACTION_STOP_ANCHOR_WATCH - } - context.startService(intent) - Toast.makeText(context, "Anchor watch stopped.", Toast.LENGTH_SHORT).show() - } - - /** - * Updates the UI based on the current anchor watch state. - */ - fun updateUI(state: AnchorWatchState) { - statusText.text = if (state.isActive) { - "STATUS: ACTIVE" // Simple status for UI - } else { - "STATUS: INACTIVE" - } - currentRadius = state.watchCircleRadiusMeters - updateRadiusDisplay() - } - - /** - * Toggles the visibility of the anchor configuration container. - */ - fun toggleVisibility() { - container.visibility = if (container.visibility == View.VISIBLE) View.GONE else View.VISIBLE - } -} diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt index 4f08de7..bfefb6f 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/MapHandler.kt @@ -19,7 +19,7 @@ import org.maplibre.geojson.FeatureCollection import org.maplibre.geojson.LineString import org.maplibre.geojson.Point import org.maplibre.geojson.Polygon -import org.terst.nav.AnchorWatchState +import org.terst.nav.safety.AnchorWatchState import org.terst.nav.TidalCurrentState import org.terst.nav.track.TrackPoint import kotlin.math.cos diff --git a/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt new file mode 100644 index 0000000..d435f00 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/ui/anchorwatch/AnchorWatchHandler.kt @@ -0,0 +1,58 @@ +package org.terst.nav.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 org.terst.nav.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, 2.0, 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/org/terst/nav/wind/ApparentWind.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt new file mode 100644 index 0000000..fd504cb --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/ApparentWind.kt @@ -0,0 +1,3 @@ +package org.terst.nav.wind + +data class ApparentWind(val speedKt: Double, val angleDeg: Double) diff --git a/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt new file mode 100644 index 0000000..dc3117c --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindCalculator.kt @@ -0,0 +1,20 @@ +package org.terst.nav.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/org/terst/nav/wind/TrueWindData.kt b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt new file mode 100644 index 0000000..8c3ac56 --- /dev/null +++ b/android-app/app/src/main/kotlin/org/terst/nav/wind/TrueWindData.kt @@ -0,0 +1,3 @@ +package org.terst.nav.wind + +data class TrueWindData(val speedKt: Double, val directionDeg: Double) |
