summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin
diff options
context:
space:
mode:
authorClaude Agent <agent@example.com>2026-03-25 02:19:39 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-25 04:55:58 +0000
commit75ec688eb2d2754b77ff18946412bca434eb503a (patch)
tree68cb98bb7ce6469a0ae4ca1a698f974ea810d763 /android-app/app/src/main/kotlin
parent62c27bf28de30979bc58ef7808185ac189f71197 (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>
Diffstat (limited to 'android-app/app/src/main/kotlin')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/GpsPosition.kt11
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt62
2 files changed, 54 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.
*