Skip to main content

navcore_signalk/
wind.rs

1//! The vessel's true wind, tracked from separate Signal K readings and
2//! resolved on demand.
3//!
4//! Wind is not part of a fix -- it carries no position, and
5//! [`fix::FixBuilder`] has no use for it -- but it is a live reading the
6//! same way a fix's own course and speed are, which is what makes it a
7//! poor fit for [`crate::Measurement::MaximumDraft`]'s own "arrives once,
8//! kept forever" treatment. [`WindTracker`] is the shape this actually
9//! wants: several separately timestamped readings, resolved to one
10//! answer only when asked, with a stale reading treated as no reading at
11//! all -- the same discipline [`fix::Sources`] already keeps for a fix,
12//! applied here to a value that is not one.
13
14use std::time::Duration;
15
16use crate::delta::Measurement;
17
18/// How long a wind reading stays current by default.
19///
20/// A little more forgiving than the three seconds a GPS fix is typically
21/// given -- ordinary wind instruments update at a slower, less regular
22/// cadence than a GPS's steady 1 Hz -- but the same principle: a reading
23/// that stopped arriving must not go on describing the actual wind.
24/// [`WindTracker::wind`] takes this as a parameter rather than baking it
25/// in, so a caller with reason to judge differently is free to.
26pub const DEFAULT_WIND_GOOD_FOR: Duration = Duration::from_secs(5);
27
28/// The vessel's true wind, direct from the server's own calculation or
29/// derived from apparent wind -- see [`WindTracker::wind`] for which, and
30/// when.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct Wind {
33    /// Degrees true -- an absolute bearing the wind blows *from*, not an
34    /// angle off the bow.
35    pub direction_true_deg: f64,
36    /// Knots.
37    pub speed_true_kn: f64,
38}
39
40/// Keeps the latest of each of the four wind readings Signal K can carry,
41/// each with when it arrived, and resolves them to one [`Wind`] on
42/// demand.
43#[derive(Debug, Clone, Copy, Default, PartialEq)]
44pub struct WindTracker {
45    direction_true: Option<(f64, Duration)>,
46    speed_true: Option<(f64, Duration)>,
47    angle_apparent: Option<(f64, Duration)>,
48    speed_apparent: Option<(f64, Duration)>,
49}
50
51impl WindTracker {
52    /// No readings yet.
53    #[must_use]
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Records one measurement, if it is a wind reading -- returns
59    /// whether it was. Anything else is left for the caller to handle;
60    /// this never touches [`fix::FixBuilder`] or any other measurement
61    /// kind, the same separation [`crate::apply_measurement`] itself
62    /// keeps by only ever recognising the four fields a fix is made of.
63    ///
64    /// `now` is the caller's own clock -- the same basis [`Self::wind`]
65    /// later judges freshness against, passed in rather than read from
66    /// the system clock so that a recorded stream of deltas replays
67    /// identically.
68    pub fn record(&mut self, measurement: Measurement, now: Duration) -> bool {
69        match measurement {
70            Measurement::WindDirectionTrue(deg) => {
71                self.direction_true = Some((deg, now));
72                true
73            }
74            Measurement::WindSpeedTrue(kn) => {
75                self.speed_true = Some((kn, now));
76                true
77            }
78            Measurement::WindAngleApparent(deg) => {
79                self.angle_apparent = Some((deg, now));
80                true
81            }
82            Measurement::WindSpeedApparent(kn) => {
83                self.speed_apparent = Some((kn, now));
84                true
85            }
86            _ => false,
87        }
88    }
89
90    /// The vessel's true wind, however it can currently be determined.
91    ///
92    /// `now` and `good_for` decide freshness together -- see
93    /// [`DEFAULT_WIND_GOOD_FOR`] for a reasonable `good_for` when the
94    /// caller has no reason to choose its own. `heading_deg` and
95    /// `sog_kn` are the vessel's own motion, needed only for the
96    /// fallback path below.
97    ///
98    /// Prefers the server's own `directionTrue`/`speedTrue` when both
99    /// are fresh: an installation that computes true wind for itself
100    /// may know things this cannot -- current, calibration, a better
101    /// sensor. Falls back to deriving it from apparent wind and the
102    /// vessel's own motion, via [`nav_math::wind`], when the server
103    /// does not.
104    ///
105    /// `None` when neither path has anything fresh enough, or -- for
106    /// the fallback path -- when there is no heading to measure the
107    /// apparent angle against. Never a stale number quietly reused.
108    ///
109    /// # The one approximation this makes
110    ///
111    /// Deriving true wind from apparent wants speed *through water* --
112    /// [`nav_math::wind`]'s own doc calls this "the sailing wind", the
113    /// one polar data and laylines are indexed by. Plenty of vessels
114    /// have no paddlewheel reading, only speed over ground; `sog_kn`
115    /// stands in for it here, and the two differ by the current vector,
116    /// which is exactly the error this introduces. Reason enough to
117    /// prefer the server's own calculation whenever it publishes one.
118    #[must_use]
119    pub fn wind(&self, now: Duration, heading_deg: Option<f64>, sog_kn: f64, good_for: Duration) -> Option<Wind> {
120        let fresh = |reading: Option<(f64, Duration)>| {
121            reading.filter(|&(_, at)| now.saturating_sub(at) <= good_for).map(|(value, _)| value)
122        };
123
124        if let (Some(direction_true_deg), Some(speed_true_kn)) =
125            (fresh(self.direction_true), fresh(self.speed_true))
126        {
127            return Some(Wind { direction_true_deg, speed_true_kn });
128        }
129
130        let heading_deg = heading_deg?;
131        let angle_deg = fresh(self.angle_apparent)?;
132        let speed_kn = fresh(self.speed_apparent)?;
133        let apparent = nav_math::wind::Wind { speed_kn, angle_deg };
134        let true_wind = nav_math::wind::true_from_apparent(apparent, sog_kn);
135        Some(Wind {
136            direction_true_deg: nav_math::wind::direction_deg(heading_deg, true_wind.angle_deg),
137            speed_true_kn: true_wind.speed_kn,
138        })
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn reported_true_wind_is_preferred_over_a_derivation_from_apparent() {
148        let mut tracker = WindTracker::new();
149        tracker.record(Measurement::WindDirectionTrue(270.0), Duration::ZERO);
150        tracker.record(Measurement::WindSpeedTrue(12.0), Duration::ZERO);
151        // Present too, and would derive a very different answer if used --
152        // proving the reported pair actually wins rather than merely
153        // being available.
154        tracker.record(Measurement::WindAngleApparent(45.0), Duration::ZERO);
155        tracker.record(Measurement::WindSpeedApparent(20.0), Duration::ZERO);
156
157        let wind = tracker
158            .wind(Duration::ZERO, Some(0.0), 6.0, DEFAULT_WIND_GOOD_FOR)
159            .expect("reported true wind");
160        assert!((wind.direction_true_deg - 270.0).abs() < 1e-9, "{wind:?}");
161        assert!((wind.speed_true_kn - 12.0).abs() < 1e-9, "{wind:?}");
162    }
163
164    #[test]
165    fn true_wind_is_derived_from_apparent_when_none_is_reported() {
166        let mut tracker = WindTracker::new();
167        // Beating: 38 degrees apparent at 14 kn, boat doing 6.5.
168        tracker.record(Measurement::WindAngleApparent(38.0), Duration::ZERO);
169        tracker.record(Measurement::WindSpeedApparent(14.0), Duration::ZERO);
170
171        // Heading due north: the true wind angle off the bow becomes the
172        // true wind direction directly.
173        let wind = tracker
174            .wind(Duration::ZERO, Some(0.0), 6.5, DEFAULT_WIND_GOOD_FOR)
175            .expect("derived true wind");
176        let expected = nav_math::wind::true_from_apparent(
177            nav_math::wind::Wind { speed_kn: 14.0, angle_deg: 38.0 },
178            6.5,
179        );
180        assert!((wind.direction_true_deg - expected.angle_deg).abs() < 1e-6, "{wind:?}");
181        assert!((wind.speed_true_kn - expected.speed_kn).abs() < 1e-6, "{wind:?}");
182    }
183
184    #[test]
185    fn deriving_true_wind_needs_a_heading() {
186        let mut tracker = WindTracker::new();
187        tracker.record(Measurement::WindAngleApparent(38.0), Duration::ZERO);
188        tracker.record(Measurement::WindSpeedApparent(14.0), Duration::ZERO);
189
190        // Apparent wind is relative to the bow; with no compass there is
191        // nothing to turn it into an absolute direction with, and
192        // guessing from course over ground would silently mix leeway and
193        // current into a number that looks like a measurement.
194        assert!(tracker.wind(Duration::ZERO, None, 6.5, DEFAULT_WIND_GOOD_FOR).is_none());
195    }
196
197    #[test]
198    fn a_wind_reading_that_stopped_arriving_is_not_used() {
199        let mut tracker = WindTracker::new();
200        tracker.record(Measurement::WindDirectionTrue(270.0), Duration::ZERO);
201        tracker.record(Measurement::WindSpeedTrue(12.0), Duration::ZERO);
202
203        // Fresh right after it arrived.
204        assert!(tracker.wind(Duration::from_secs(2), None, 0.0, DEFAULT_WIND_GOOD_FOR).is_some());
205        // Older than good_for: the one rule this exists for -- a value
206        // that stopped updating must never look current.
207        assert!(tracker.wind(Duration::from_secs(10), None, 0.0, DEFAULT_WIND_GOOD_FOR).is_none());
208    }
209
210    #[test]
211    fn nothing_reported_at_all_is_no_wind_rather_than_a_guess() {
212        let tracker = WindTracker::new();
213        assert!(tracker.wind(Duration::ZERO, Some(90.0), 6.0, DEFAULT_WIND_GOOD_FOR).is_none());
214    }
215
216    #[test]
217    fn recording_a_non_wind_measurement_is_reported_as_such() {
218        let mut tracker = WindTracker::new();
219        assert!(!tracker.record(Measurement::MaximumDraft(2.0), Duration::ZERO));
220    }
221}