From 75ec688eb2d2754b77ff18946412bca434eb503a Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Wed, 25 Mar 2026 02:19:39 +0000 Subject: feat(gps): add fix-quality (accuracy) tier to GPS sensor fusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../example/androidapp/gps/LocationServiceTest.kt | 90 ++++++++++++++++++++++ 1 file changed, 90 insertions(+) (limited to 'android-app/app/src/test/kotlin/com') 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 -- cgit v1.2.3