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}