summaryrefslogtreecommitdiff
path: root/android-app/app
diff options
context:
space:
mode:
authorClaude Agent <agent@claude.ai>2026-03-25 01:57:17 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-25 04:55:58 +0000
commit0294c6fccc5a1dac7d4fb0ac084b273683e47d32 (patch)
treecd2f23567324a1881835444ab4022efd6e2ed575 /android-app/app
parente5cd0ce6bf65fff1bbbb5d8e12c4076da088ebe1 (diff)
feat(safety): log wind and current conditions at MOB activation (Section 4.6)
Per COMPONENT_DESIGN.md Section 4.6, the MOB navigation view must display wind and current conditions at the time of the event. - MobEvent: add nullable windSpeedKt, windDirectionDeg, currentSpeedKt, currentDirectionDeg fields captured at the exact moment of activation - MobAlarmManager.activate(): accept optional wind/current params and forward them into MobEvent (defaults to null for backward compatibility) - LocationService (new): aggregates live SensorData (resolves true wind via TrueWindCalculator) and marine-forecast current conditions; snapshot() provides a point-in-time EnvironmentalSnapshot for safety-critical logging - MobAlarmManagerTest: add tests for wind/current storage and null defaults - LocationServiceTest (new): covers snapshot, true-wind resolution, current-condition updates, and the latestSensor flow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'android-app/app')
-rw-r--r--android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt99
-rw-r--r--android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt117
2 files changed, 216 insertions, 0 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
new file mode 100644
index 0000000..c6ff8b7
--- /dev/null
+++ b/android-app/app/src/main/kotlin/com/example/androidapp/gps/LocationService.kt
@@ -0,0 +1,99 @@
+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
+
+/**
+ * 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
+ * [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.
+ */
+class LocationService(
+ private val windCalculator: TrueWindCalculator = TrueWindCalculator()
+) {
+
+ 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)
+
+ /**
+ * 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
+ )
+ }
+ }
+
+ /**
+ * 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?
+)
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
new file mode 100644
index 0000000..d9192c6
--- /dev/null
+++ b/android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt
@@ -0,0 +1,117 @@
+package com.example.androidapp.gps
+
+import com.example.androidapp.data.model.SensorData
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.*
+import org.junit.Test
+
+class LocationServiceTest {
+
+ private fun service() = LocationService()
+
+ // ── snapshot with no data ─────────────────────────────────────────────────
+
+ @Test
+ fun snapshot_noData_allFieldsNull() {
+ val snap = service().snapshot()
+ assertNull(snap.windSpeedKt)
+ assertNull(snap.windDirectionDeg)
+ assertNull(snap.currentSpeedKt)
+ assertNull(snap.currentDirectionDeg)
+ }
+
+ // ── true-wind resolution ──────────────────────────────────────────────────
+
+ @Test
+ fun updateSensorData_withFullReading_resolvesTrueWind() = runBlocking {
+ val svc = service()
+ // Head north (hdg = 0°), AWS = 10 kt coming from ahead (AWA = 0°), BSP = 5 kt
+ // → TW comes FROM ahead at 5 kt
+ svc.updateSensorData(
+ SensorData(
+ headingTrueDeg = 0.0,
+ apparentWindSpeedKt = 10.0,
+ apparentWindAngleDeg = 0.0,
+ speedOverGroundKt = 5.0
+ )
+ )
+ val tw = svc.latestTrueWind.first()
+ assertNotNull(tw)
+ assertTrue("Expected TWS > 0", tw!!.speedKt > 0.0)
+ }
+
+ @Test
+ fun updateSensorData_missingHeading_doesNotResolveTrueWind() = runBlocking {
+ val svc = service()
+ svc.updateSensorData(
+ SensorData(
+ apparentWindSpeedKt = 10.0,
+ apparentWindAngleDeg = 45.0,
+ speedOverGroundKt = 5.0
+ // headingTrueDeg omitted
+ )
+ )
+ assertNull(svc.latestTrueWind.first())
+ }
+
+ // ── current conditions ────────────────────────────────────────────────────
+
+ @Test
+ fun updateCurrentConditions_reflectedInSnapshot() {
+ val svc = service()
+ svc.updateCurrentConditions(speedKt = 1.5, directionDeg = 135.0)
+
+ val snap = svc.snapshot()
+ assertEquals(1.5, snap.currentSpeedKt!!, 0.001)
+ assertEquals(135.0, snap.currentDirectionDeg!!, 0.001)
+ }
+
+ @Test
+ fun updateCurrentConditions_nullClears() {
+ val svc = service()
+ svc.updateCurrentConditions(speedKt = 2.0, directionDeg = 90.0)
+ svc.updateCurrentConditions(speedKt = null, directionDeg = null)
+
+ val snap = svc.snapshot()
+ assertNull(snap.currentSpeedKt)
+ assertNull(snap.currentDirectionDeg)
+ }
+
+ // ── combined snapshot ─────────────────────────────────────────────────────
+
+ @Test
+ fun snapshot_afterFullUpdate_populatesAllFields() = runBlocking {
+ val svc = service()
+
+ // Head east (hdg = 90°), wind from starboard bow, BSP proxy = 6 kt
+ svc.updateSensorData(
+ SensorData(
+ headingTrueDeg = 90.0,
+ apparentWindSpeedKt = 12.0,
+ apparentWindAngleDeg = 45.0,
+ speedOverGroundKt = 6.0
+ )
+ )
+ svc.updateCurrentConditions(speedKt = 0.8, directionDeg = 270.0)
+
+ val snap = svc.snapshot()
+ assertNotNull(snap.windSpeedKt)
+ assertNotNull(snap.windDirectionDeg)
+ assertEquals(0.8, snap.currentSpeedKt!!, 0.001)
+ assertEquals(270.0, snap.currentDirectionDeg!!, 0.001)
+ }
+
+ // ── latestSensor flow ─────────────────────────────────────────────────────
+
+ @Test
+ fun updateSensorData_updatesLatestSensorFlow() = runBlocking {
+ val svc = service()
+ assertNull(svc.latestSensor.first())
+
+ val data = SensorData(latitude = 41.5, longitude = -71.3)
+ svc.updateSensorData(data)
+
+ assertEquals(data, svc.latestSensor.first())
+ }
+}