diff options
| author | Claude Agent <agent@example.com> | 2026-03-25 02:19:39 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-25 04:55:58 +0000 |
| commit | 75ec688eb2d2754b77ff18946412bca434eb503a (patch) | |
| tree | 68cb98bb7ce6469a0ae4ca1a698f974ea810d763 | |
| parent | 62c27bf28de30979bc58ef7808185ac189f71197 (diff) | |
feat(gps): add fix-quality (accuracy) tier to GPS sensor fusion
Extend LocationService's source-selection policy with a quality-aware
"marginal staleness" zone between the primary and a new extended
staleness threshold (default 10 s):
1. Fresh NMEA (≤ primary threshold, 5 s) → always prefer NMEA
2. Marginally stale NMEA (5–10 s) → prefer NMEA only when
GpsPosition.accuracyMeters is strictly better than Android's;
fall back to Android conservatively when accuracy data is absent
3. Very stale NMEA (> 10 s) → always prefer Android
4. Only one source available → use it regardless of age
Changes:
- GpsPosition: add nullable accuracyMeters field (default null, no
breaking change to existing callers)
- LocationService: add nmeaExtendedThresholdMs constructor parameter;
recomputeBestPosition() now implements three-tier logic; extract
GpsPosition.hasStrictlyBetterAccuracyThan() helper
- LocationServiceTest: expose nmeaExtendedThresholdMs in fusionService
helper; add posWithAccuracy helper; add 4 new test cases covering
accuracy-based NMEA preference, worse-accuracy fallback, no-accuracy
conservative fallback, and very-stale unconditional fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 files changed, 144 insertions, 19 deletions
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 index 6df685b..cbe5c84 100644 --- 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 @@ -1,9 +1,10 @@ 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 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 index 28dfc90..0a315d4 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 @@ -22,22 +22,30 @@ enum class GpsSource { NONE, NMEA, ANDROID } * * 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. + * 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 is - * considered stale and Android GPS is preferred instead. Default: 5 000 ms. + * @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 ) { @@ -114,29 +122,55 @@ class LocationService( /** * 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) + * 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 nmeaFresh = nmea != null && (now - nmea.timestampMs) <= nmeaStalenessThresholdMs + 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 - android != null -> android to GpsSource.ANDROID - nmea != null -> nmea to GpsSource.NMEA // stale, but only source - else -> null to GpsSource.NONE + 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. * 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 237004b..4eb9898 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 @@ -119,15 +119,20 @@ class LocationServiceTest { private fun fusionService( nmeaStalenessThresholdMs: Long = 5_000L, + nmeaExtendedThresholdMs: Long = 10_000L, clockMs: () -> Long = System::currentTimeMillis ) = LocationService( nmeaStalenessThresholdMs = nmeaStalenessThresholdMs, + nmeaExtendedThresholdMs = nmeaExtendedThresholdMs, clockMs = clockMs ) private fun pos(lat: Double, lon: Double, timestampMs: Long) = GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs) + private fun posWithAccuracy(lat: Double, lon: Double, timestampMs: Long, accuracyMeters: Double) = + GpsPosition(lat, lon, sog = 0.0, cog = 0.0, timestampMs = timestampMs, accuracyMeters = accuracyMeters) + @Test fun noGpsData_bestPositionNullAndSourceNone() = runBlocking { val svc = fusionService() @@ -203,6 +208,91 @@ class LocationServiceTest { assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) } + // ── fix-quality (accuracy) tie-breaking ────────────────────────────────── + + @Test + fun marginallyStaleNmea_betterAccuracy_preferredOverAndroid() = runBlocking { + // NMEA is 7 s old (> primary 5 s, ≤ extended 10 s) but has accuracy 3 m vs Android 15 m. + val nmeaTime = 0L + val now = 7_000L + val svc = fusionService( + nmeaStalenessThresholdMs = 5_000L, + nmeaExtendedThresholdMs = 10_000L, + clockMs = { now } + ) + + val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 3.0) + val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 15.0) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.NMEA, svc.activeGpsSource.first()) + assertEquals(nmeaFix, svc.bestPosition.first()) + } + + @Test + fun marginallyStaleNmea_worseAccuracy_fallsBackToAndroid() = runBlocking { + // NMEA is 7 s old with accuracy 15 m; Android has accuracy 3 m → Android wins. + val nmeaTime = 0L + val now = 7_000L + val svc = fusionService( + nmeaStalenessThresholdMs = 5_000L, + nmeaExtendedThresholdMs = 10_000L, + clockMs = { now } + ) + + val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 15.0) + val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 3.0) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + + @Test + fun marginallyStaleNmea_noAccuracyData_fallsBackToAndroid() = runBlocking { + // Neither source has accuracy metadata — conservative: prefer Android. + val nmeaTime = 0L + val now = 7_000L + val svc = fusionService( + nmeaStalenessThresholdMs = 5_000L, + nmeaExtendedThresholdMs = 10_000L, + clockMs = { now } + ) + + val nmeaFix = pos(41.0, -71.0, nmeaTime) + val androidFix = pos(42.0, -72.0, now) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + } + + @Test + fun veryStaleNmea_beyondExtendedThreshold_androidPreferred() = runBlocking { + // NMEA is 15 s old (beyond extended 10 s); Android wins even if NMEA has better accuracy. + val nmeaTime = 0L + val now = 15_000L + val svc = fusionService( + nmeaStalenessThresholdMs = 5_000L, + nmeaExtendedThresholdMs = 10_000L, + clockMs = { now } + ) + + val nmeaFix = posWithAccuracy(41.0, -71.0, nmeaTime, accuracyMeters = 2.0) + val androidFix = posWithAccuracy(42.0, -72.0, now, accuracyMeters = 20.0) + + svc.updateNmeaGps(nmeaFix) + svc.updateAndroidGps(androidFix) + + assertEquals(GpsSource.ANDROID, svc.activeGpsSource.first()) + assertEquals(androidFix, svc.bestPosition.first()) + } + @Test fun nmeaRecovery_switchesBackFromAndroid() = runBlocking { var now = 0L |
