diff options
| -rw-r--r-- | android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt | 87 | ||||
| -rw-r--r-- | android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt | 110 |
2 files changed, 195 insertions, 2 deletions
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 index c6ff8b7..28dfc90 100644 --- 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 @@ -8,17 +8,37 @@ 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). * - * Call [updateSensorData] whenever new NMEA or Signal K data arrives and + * ## 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. Fall back to Android GPS when NMEA is stale. + * 3. Use stale NMEA only when Android GPS has never provided a fix. + * 4. [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 is + * considered stale and Android GPS is preferred instead. Default: 5 000 ms. + * @param clockMs Injectable clock for unit-testable staleness checks. */ class LocationService( - private val windCalculator: TrueWindCalculator = TrueWindCalculator() + private val windCalculator: TrueWindCalculator = TrueWindCalculator(), + private val nmeaStalenessThresholdMs: Long = 5_000L, + private val clockMs: () -> Long = System::currentTimeMillis ) { private val _latestSensor = MutableStateFlow<SensorData?>(null) @@ -32,6 +52,23 @@ class LocationService( 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 @@ -54,6 +91,52 @@ class LocationService( } } + // ── 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: + * 1. Fresh NMEA (age ≤ [nmeaStalenessThresholdMs]) + * 2. Android GPS (any age) + * 3. Stale NMEA (only if Android has never reported) + */ + private fun recomputeBestPosition() { + val now = clockMs() + val nmea = lastNmeaPosition + val android = lastAndroidPosition + + val nmeaFresh = nmea != null && (now - nmea.timestampMs) <= nmeaStalenessThresholdMs + + val (best, source) = when { + nmeaFresh -> nmea!! to GpsSource.NMEA + android != null -> android to GpsSource.ANDROID + nmea != null -> nmea to GpsSource.NMEA // stale, but only source + else -> null to GpsSource.NONE + } + + _bestPosition.value = best + _activeGpsSource.value = source + } + /** * Update the ocean current conditions from the latest marine-forecast response. * diff --git a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt index d9192c6..237004b 100644 --- a/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt +++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt @@ -114,4 +114,114 @@ class LocationServiceTest { assertEquals(data, svc.latestSensor.first()) } + + // ── GPS sensor fusion ───────────────────────────────────────────────────── + + private fun fusionService( + nmeaStalenessThresholdMs: Long = 5_000L, + clockMs: () -> Long = System::currentTimeMillis + ) = LocationService( + nmeaStalenessThresholdMs = nmeaStalenessThresholdMs, + clockMs = clockMs + ) + + private fun pos(lat: Double, lon: Double, timestampMs: Long) = + GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs) + + @Test + fun noGpsData_bestPositionNullAndSourceNone() = runBlocking { + val svc = fusionService() + assertNull(svc.bestPosition.first()) + assertEquals(GpsSource.NONE, svc.activeGpsSource.first()) + } + + @Test + fun freshNmea_preferredOverAndroid() = runBlocking { + val now = 10_000L + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, now) + val androidFix = pos(42.0, -72.0, now - 1_000L) + + svc.updateAndroidGps(androidFix) + svc.updateNmeaGps(nmeaFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + assertEquals(nmeaFix, svc.bestPosition.first()) + } + + @Test + fun staleNmea_androidFallback() = runBlocking { + val nmeaTime = 0L + val now = 10_000L // 10 s later — NMEA is stale (threshold 5 s) + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, nmeaTime) + val androidFix = pos(42.0, -72.0, now) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun onlyNmeaAvailable_usedEvenWhenStale() = runBlocking { + val now = 60_000L // 60 s after fix — very stale + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, 0L) + svc.updateNmeaGps(nmeaFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + assertEquals(nmeaFix, svc.bestPosition.first()) + } + + @Test + fun onlyAndroidAvailable_isUsed() = runBlocking { + val svc = fusionService() + val androidFix = pos(42.0, -72.0, System.currentTimeMillis()) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun nmeaAtExactThreshold_isConsideredFresh() = runBlocking { + val fixTime = 0L + val now = 5_000L // exactly at threshold + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + val nmeaFix = pos(41.0, -71.0, fixTime) + val androidFix = pos(42.0, -72.0, now) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + } + + @Test + fun nmeaRecovery_switchesBackFromAndroid() = runBlocking { + var now = 0L + val svc = fusionService(nmeaStalenessThresholdMs = 5_000L, clockMs = { now }) + + // Fresh NMEA + svc.updateNmeaGps(pos(41.0, -71.0, 0L)) + assertEquals(GpsSource.NMEA, svc.activeGpsSource.value) + + // NMEA goes stale; Android takes over + now = 10_000L + val androidFix = pos(42.0, -72.0, 10_000L) + svc.updateAndroidGps(androidFix) + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.value) + + // NMEA recovers with a fresh fix + val freshNmea = pos(41.1, -71.1, 10_000L) + svc.updateNmeaGps(freshNmea) + assertEquals(GpsSource.NMEA, svc.activeGpsSource.value) + assertEquals(freshNmea, svc.bestPosition.value) + } } |
