diff options
| author | Claude Agent <agent@claude.ai> | 2026-03-25 01:57:17 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-25 04:55:58 +0000 |
| commit | 0294c6fccc5a1dac7d4fb0ac084b273683e47d32 (patch) | |
| tree | cd2f23567324a1881835444ab4022efd6e2ed575 /android-app/app | |
| parent | e5cd0ce6bf65fff1bbbb5d8e12c4076da088ebe1 (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.kt | 99 | ||||
| -rw-r--r-- | android-app/app/src/test/kotlin/com/example/androidapp/gps/LocationServiceTest.kt | 117 |
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()) + } +} |
