summaryrefslogtreecommitdiff
path: root/scripts/.claude/ct-wind-wiring.txt
blob: 09768abbfa0e013cf28ceaf35671136e0e12bede (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
Context
-------
MainViewModel.addGpsPoint() (android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt,
line 67) builds TrackPoint with hardcoded wind zeroes:

    windSpeedKnots = 0.0, windAngleDeg = 0.0, isTrueWind = false

LocationService already exposes live NMEA wind data:
    LocationService.nmeaWindDataFlow: SharedFlow<WindData>   (companion object, line 362)

WindData (android-app/app/src/main/kotlin/org/terst/nav/sensors/WindData.kt):
    windAngle: Double   // degrees, relative or true
    windSpeed: Double   // knots
    isTrueWind: Boolean
    timestampMs: Long

TrackPoint (android-app/app/src/main/kotlin/org/terst/nav/track/TrackPoint.kt) already has
windSpeedKnots, windAngleDeg, isTrueWind fields — they just aren't populated.

Goal
----
Cache the latest WindData in MainViewModel and use it when building TrackPoints.
Default to zero wind if no NMEA wind sentence has arrived yet.

Step 1 — Red: write failing tests
----------------------------------
Create android-app/app/src/test/kotlin/org/terst/nav/ui/MainViewModelWindTest.kt

Setup boilerplate needed (Dispatchers.setMain / resetMain with UnconfinedTestDispatcher):

    import androidx.arch.core.executor.testing.InstantTaskExecutorRule
    import kotlinx.coroutines.Dispatchers
    import kotlinx.coroutines.ExperimentalCoroutinesApi
    import kotlinx.coroutines.test.UnconfinedTestDispatcher
    import kotlinx.coroutines.test.resetMain
    import kotlinx.coroutines.test.runTest
    import kotlinx.coroutines.test.setMain
    import org.junit.After
    import org.junit.Assert.assertEquals
    import org.junit.Before
    import org.junit.Rule
    import org.junit.Test
    import org.terst.nav.sensors.WindData
    import org.terst.nav.ui.MainViewModel

    @OptIn(ExperimentalCoroutinesApi::class)
    class MainViewModelWindTest {

        @get:Rule val instantTask = InstantTaskExecutorRule()
        private val dispatcher = UnconfinedTestDispatcher()

        @Before fun setUp() { Dispatchers.setMain(dispatcher) }
        @After fun tearDown() { Dispatchers.resetMain() }

        @Test fun `addGpsPoint uses zero wind when no wind update received`() = runTest {
            val vm = MainViewModel()
            vm.startTrack()
            vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
            val pt = vm.trackPoints.value.first()
            assertEquals(0.0, pt.windSpeedKnots, 0.001)
            assertEquals(0.0, pt.windAngleDeg, 0.001)
            assertEquals(false, pt.isTrueWind)
        }

        @Test fun `addGpsPoint uses latest wind data after updateWind`() = runTest {
            val vm = MainViewModel()
            vm.updateWind(WindData(windAngle = 45.0, windSpeed = 15.5, isTrueWind = true, timestampMs = 1000L))
            vm.startTrack()
            vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
            val pt = vm.trackPoints.value.first()
            assertEquals(15.5, pt.windSpeedKnots, 0.001)
            assertEquals(45.0, pt.windAngleDeg, 0.001)
            assertEquals(true, pt.isTrueWind)
        }

        @Test fun `addGpsPoint reflects wind update between fixes`() = runTest {
            val vm = MainViewModel()
            vm.startTrack()
            vm.addGpsPoint(37.0, -122.0, 5.0, 90.0)
            vm.updateWind(WindData(windAngle = 180.0, windSpeed = 8.0, isTrueWind = false, timestampMs = 2000L))
            vm.addGpsPoint(37.1, -122.0, 5.0, 90.0)
            val pts = vm.trackPoints.value
            assertEquals(0.0, pts[0].windSpeedKnots, 0.001)
            assertEquals(8.0, pts[1].windSpeedKnots, 0.001)
        }
    }

Run and confirm RED:
    cd android-app && ./gradlew :app:testDebugUnitTest --tests "*MainViewModelWind*"

Step 2 — Green: implement
--------------------------
In android-app/app/src/main/kotlin/org/terst/nav/ui/MainViewModel.kt:

1. Add import:
    import org.terst.nav.sensors.WindData

2. Add field after trackRepository:
    private var latestWind: WindData? = null

3. Add method:
    fun updateWind(wind: WindData) {
        latestWind = wind
    }

4. Update addGpsPoint() to use latestWind:
    fun addGpsPoint(lat: Double, lon: Double, sogKnots: Double, cogDeg: Double) {
        val wind = latestWind
        val point = TrackPoint(
            lat = lat, lon = lon,
            sogKnots = sogKnots, cogDeg = cogDeg,
            windSpeedKnots = wind?.windSpeed ?: 0.0,
            windAngleDeg  = wind?.windAngle ?: 0.0,
            isTrueWind    = wind?.isTrueWind ?: false,
            timestampMs   = System.currentTimeMillis()
        )
        if (trackRepository.addPoint(point)) {
            _trackPoints.value = trackRepository.getPoints()
        }
    }

Run and confirm GREEN:
    cd android-app && ./gradlew :app:testDebugUnitTest --tests "*MainViewModelWind*"

Step 3 — Wire: feed wind updates from LocationService
------------------------------------------------------
In android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt,
inside observeDataSources(), add after the anchorWatchState collector:

    lifecycleScope.launch {
        LocationService.nmeaWindDataFlow.collect { wind ->
            viewModel.updateWind(wind)
        }
    }

No additional imports needed (LocationService is already imported).

Step 4 — Verify all tests pass
-------------------------------
cd android-app && ./gradlew :app:testDebugUnitTest

Confirm all existing tests still pass alongside the 3 new wind tests.

Step 5 — Commit and push
-------------------------
git add -A && git commit -m "feat(track): wire NMEA wind data into GPS track points

MainViewModel caches the latest WindData from LocationService.nmeaWindDataFlow
via updateWind(). addGpsPoint() populates TrackPoint wind fields from the cache,
defaulting to zero if no NMEA wind sentence has arrived yet.

MainActivity.observeDataSources() feeds LocationService.nmeaWindDataFlow
into viewModel.updateWind() alongside the existing GPS and anchor observers.

3 new unit tests in MainViewModelWindTest verify zero-default, wind capture,
and mid-track wind update behaviour." && git push origin main