summaryrefslogtreecommitdiff
path: root/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
blob: 0a315d41b592610b3240d57534035ed8e4b72554 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
package com.example.androidapp.gps

import com.example.androidapp.data.model.SensorData
import com.example.androidapp.wind.TrueWindCalculator
import com.example.androidapp.wind.ApparentWind
import com.example.androidapp.wind.TrueWindData
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).
 *
 * ## 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. 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 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
) {

    private val _latestSensor = MutableStateFlow<SensorData?>(null)
    /** The most recently received unified sensor reading. */
    val latestSensor: StateFlow<SensorData?> = _latestSensor.asStateFlow()

    private val _latestTrueWind = MutableStateFlow<TrueWindData?>(null)
    /** Most recent resolved true-wind vector, updated whenever a full sensor reading arrives. */
    val latestTrueWind: StateFlow<TrueWindData?> = _latestTrueWind.asStateFlow()

    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
     * stored in [latestTrueWind].
     */
    fun updateSensorData(data: SensorData) {
        _latestSensor.value = data

        val aws = data.apparentWindSpeedKt
        val awa = data.apparentWindAngleDeg
        val bsp = data.speedOverGroundKt   // use SOG as proxy when BSP is absent
        val hdg = data.headingTrueDeg

        if (aws != null && awa != null && bsp != null && hdg != null) {
            _latestTrueWind.value = windCalculator.update(
                apparent = ApparentWind(speedKt = aws, angleDeg = awa),
                bsp = bsp,
                hdgDeg = hdg
            )
        }
    }

    // ── 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 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 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 ->
                // 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.
     *
     * @param speedKt     Current speed in knots (null to clear)
     * @param directionDeg Direction the current flows TOWARD, in degrees (null to clear)
     */
    fun updateCurrentConditions(speedKt: Double?, directionDeg: Double?) {
        _currentSpeedKt.value = speedKt
        _currentDirectionDeg.value = directionDeg
    }

    /**
     * Captures a snapshot of wind and current conditions at the current moment.
     *
     * All fields are nullable — only data that was available at snapshot time is
     * populated.  This snapshot is intended to be logged alongside a [MobEvent]
     * at the instant of MOB activation.
     */
    fun snapshot(): EnvironmentalSnapshot {
        val trueWind = _latestTrueWind.value
        return EnvironmentalSnapshot(
            windSpeedKt = trueWind?.speedKt,
            windDirectionDeg = trueWind?.directionDeg,
            currentSpeedKt = _currentSpeedKt.value,
            currentDirectionDeg = _currentDirectionDeg.value
        )
    }
}

/**
 * Point-in-time snapshot of wind and current conditions.
 *
 * @param windSpeedKt      True Wind Speed in knots; null if sensors were unavailable.
 * @param windDirectionDeg True Wind Direction (degrees true, wind comes FROM); null if unavailable.
 * @param currentSpeedKt   Ocean current speed in knots; null if forecast was unavailable.
 * @param currentDirectionDeg Ocean current direction (degrees, flows TOWARD); null if unavailable.
 */
data class EnvironmentalSnapshot(
    val windSpeedKt: Double?,
    val windDirectionDeg: Double?,
    val currentSpeedKt: Double?,
    val currentDirectionDeg: Double?
)