Skip to main content

navcore_fix/
sources.rs

1//! Several sources for the same value, and which one is current.
2//!
3//! A device with its own GPS receiver has two candidate positions: one
4//! from the boat's networked instruments, arriving via the Signal K
5//! server, and one from the receiver attached to the local machine. The
6//! two are not interchangeable: a properly installed networked receiver
7//! is usually more accurate, but depends on a wireless link that can
8//! fail; the local receiver is less accurate but always available.
9//!
10//! Ranking sources is left to the caller. This module enforces one
11//! rule: every value carries its age, and a stale value is marked as
12//! such. A value that has stopped updating must never appear current --
13//! a frozen position is indistinguishable from a stationary one
14//! otherwise -- so a value cannot be read without also reading its age.
15//!
16//! Time is passed in as a parameter, as elsewhere in this workspace, so
17//! a recorded stream of readings replays deterministically.
18
19use std::time::Duration;
20
21/// Whether a reading is still worth acting on.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Freshness {
24    /// Arrived within the source's own limit.
25    Live,
26    /// Older than that. Still returned, since withholding it would leave
27    /// no value with no explanation, but never reported as current.
28    Stale,
29}
30
31/// A value, with everything needed to judge it.
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub struct Fix<T> {
34    /// What was measured.
35    pub value: T,
36    /// How long ago it arrived, by the receiving clock.
37    pub age: Duration,
38    /// Whether that is within its source's limit.
39    pub freshness: Freshness,
40}
41
42impl<T> Fix<T> {
43    /// Whether this may be used as a current value.
44    #[must_use]
45    pub fn is_live(&self) -> bool {
46        self.freshness == Freshness::Live
47    }
48}
49
50/// One source's standing and its latest reading.
51#[derive(Debug, Clone)]
52struct Entry<T> {
53    id: String,
54    /// Lower ranks are preferred. The caller supplies this order; the
55    /// module does not judge installation quality itself.
56    rank: u8,
57    /// How long a reading from this source stays current. A GPS sending at
58    /// 1 Hz is late after three seconds; a depth sounder in a seaway may
59    /// reasonably go quiet for longer.
60    good_for: Duration,
61    latest: Option<(T, Duration)>,
62}
63
64/// Several sources for one kind of value.
65#[derive(Debug, Clone)]
66pub struct Sources<T> {
67    entries: Vec<Entry<T>>,
68}
69
70// Written out rather than derived: a derived Default would demand that the
71// value type be Default too, and there is no such thing as a default
72// position. An empty list of sources is a perfectly good starting state for
73// any T.
74impl<T> Default for Sources<T> {
75    fn default() -> Self {
76        Self {
77            entries: Vec::new(),
78        }
79    }
80}
81
82impl<T: Clone> Sources<T> {
83    /// No sources yet.
84    #[must_use]
85    pub fn new() -> Self {
86        Self {
87            entries: Vec::new(),
88        }
89    }
90
91    /// Adds a source, or changes the standing of one already known.
92    #[must_use]
93    pub fn with(mut self, id: impl Into<String>, rank: u8, good_for: Duration) -> Self {
94        let id = id.into();
95        if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) {
96            entry.rank = rank;
97            entry.good_for = good_for;
98        } else {
99            self.entries.push(Entry {
100                id,
101                rank,
102                good_for,
103                latest: None,
104            });
105        }
106        self
107    }
108
109    /// Records a reading. `at` is the receiving clock's time when it
110    /// arrived.
111    ///
112    /// An undeclared source is accepted automatically, at the lowest
113    /// rank and with the same limit as the most recently declared
114    /// source, so a source added at runtime is still recorded.
115    pub fn record(&mut self, id: &str, value: T, at: Duration) {
116        if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) {
117            entry.latest = Some((value, at));
118            return;
119        }
120
121        let good_for = self
122            .entries
123            .last()
124            .map_or(Duration::from_secs(5), |entry| entry.good_for);
125        self.entries.push(Entry {
126            id: id.to_owned(),
127            rank: u8::MAX,
128            good_for,
129            latest: Some((value, at)),
130        });
131    }
132
133    /// The value to use now, and which source it came from.
134    ///
135    /// The highest-ranked live source wins. If no source is live, the
136    /// most recently heard source is returned instead, marked stale
137    /// with its age, rather than returning no value at all.
138    #[must_use]
139    pub fn best(&self, now: Duration) -> Option<(&str, Fix<T>)> {
140        let mut live: Option<(&Entry<T>, Duration)> = None;
141        let mut newest: Option<(&Entry<T>, Duration)> = None;
142
143        for entry in &self.entries {
144            let Some((_, at)) = &entry.latest else {
145                continue;
146            };
147            let age = now.saturating_sub(*at);
148
149            if age <= entry.good_for && live.is_none_or(|(best, _)| entry.rank < best.rank) {
150                live = Some((entry, age));
151            }
152            if newest.is_none_or(|(_, seen)| age < seen) {
153                newest = Some((entry, age));
154            }
155        }
156
157        let (entry, age) = live.or(newest)?;
158        let freshness = if age <= entry.good_for {
159            Freshness::Live
160        } else {
161            Freshness::Stale
162        };
163        let (value, _) = entry.latest.as_ref()?;
164
165        Some((
166            entry.id.as_str(),
167            Fix {
168                value: value.clone(),
169                age,
170                freshness,
171            },
172        ))
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    const BOAT: &str = "n2k.gps";
181    const COCKPIT: &str = "cockpit-gps";
182
183    fn secs(n: u64) -> Duration {
184        Duration::from_secs(n)
185    }
186
187    /// The arrangement this was written for: the boat's own set preferred,
188    /// the cockpit's own receiver behind it, both expected once a second
189    /// and late after three.
190    fn both() -> Sources<f64> {
191        Sources::new()
192            .with(BOAT, 0, secs(3))
193            .with(COCKPIT, 1, secs(3))
194    }
195
196    #[test]
197    fn with_nothing_heard_there_is_no_answer_rather_than_a_default_one() {
198        assert!(both().best(secs(10)).is_none());
199    }
200
201    #[test]
202    fn the_better_ranked_source_wins_while_it_is_live() {
203        let mut sources = both();
204        sources.record(COCKPIT, 1.0, secs(10));
205        sources.record(BOAT, 2.0, secs(10));
206
207        let (id, fix) = sources.best(secs(11)).expect("a fix");
208        assert_eq!(id, BOAT);
209        assert_eq!(fix.value, 2.0);
210        assert!(fix.is_live());
211    }
212
213    #[test]
214    fn when_the_network_goes_quiet_the_local_receiver_takes_over() {
215        // The case the second GPS exists for. The boat's set was heard four
216        // seconds ago and is past its limit; the cockpit's is a second old.
217        let mut sources = both();
218        sources.record(BOAT, 2.0, secs(10));
219        sources.record(COCKPIT, 1.0, secs(13));
220
221        let (id, fix) = sources.best(secs(14)).expect("a fix");
222        assert_eq!(id, COCKPIT);
223        assert!(fix.is_live(), "the local receiver is current");
224        assert_eq!(fix.age, secs(1));
225    }
226
227    #[test]
228    fn the_network_takes_over_again_when_it_comes_back() {
229        let mut sources = both();
230        sources.record(BOAT, 2.0, secs(10));
231        sources.record(COCKPIT, 1.0, secs(13));
232        assert_eq!(sources.best(secs(14)).expect("a fix").0, COCKPIT);
233
234        sources.record(BOAT, 3.0, secs(15));
235        let (id, fix) = sources.best(secs(15)).expect("a fix");
236        assert_eq!(id, BOAT);
237        assert_eq!(fix.value, 3.0);
238    }
239
240    #[test]
241    fn when_everything_is_late_the_answer_still_comes_but_says_so() {
242        // Blanking the position because the network hiccuped is worse than
243        // showing where the boat was four seconds ago and admitting it.
244        let mut sources = both();
245        sources.record(BOAT, 2.0, secs(10));
246        sources.record(COCKPIT, 1.0, secs(11));
247
248        let (id, fix) = sources.best(secs(20)).expect("a fix even when stale");
249        assert_eq!(id, COCKPIT, "the most recently heard of the two");
250        assert!(!fix.is_live());
251        assert_eq!(fix.age, secs(9));
252    }
253
254    #[test]
255    fn a_stale_better_source_does_not_outrank_a_live_worse_one() {
256        // Rank decides between sources that are both current. It must never
257        // keep a dead one on screen because it used to be the better aerial.
258        let mut sources = both();
259        sources.record(BOAT, 2.0, secs(1));
260        sources.record(COCKPIT, 1.0, secs(19));
261
262        let (id, fix) = sources.best(secs(20)).expect("a fix");
263        assert_eq!(id, COCKPIT);
264        assert!(fix.is_live());
265    }
266
267    #[test]
268    fn the_age_is_measured_against_the_receiving_clock() {
269        let mut sources = both();
270        sources.record(BOAT, 2.0, secs(100));
271        assert_eq!(sources.best(secs(102)).expect("a fix").1.age, secs(2));
272    }
273
274    #[test]
275    fn a_source_nobody_declared_is_still_heard() {
276        // Somebody plugs a hand-held into the cockpit machine mid-passage.
277        // Ignoring it because it was not in the configuration would be a
278        // poor moment to be fussy.
279        let mut sources = both();
280        sources.record("some-handheld", 42.0, secs(10));
281
282        let (id, fix) = sources.best(secs(10)).expect("a fix");
283        assert_eq!(id, "some-handheld");
284        assert_eq!(fix.value, 42.0);
285    }
286
287    #[test]
288    fn an_undeclared_source_gives_way_to_a_declared_one() {
289        let mut sources = both();
290        sources.record("some-handheld", 42.0, secs(10));
291        sources.record(BOAT, 2.0, secs(10));
292
293        assert_eq!(sources.best(secs(10)).expect("a fix").0, BOAT);
294    }
295
296    #[test]
297    fn re_declaring_a_source_changes_its_standing_and_keeps_its_reading() {
298        let mut sources = both();
299        sources.record(BOAT, 2.0, secs(10));
300
301        // The boat's set turns out to be the one dropping out; demote it.
302        let sources = sources.with(BOAT, 9, secs(3));
303        let (id, fix) = sources.best(secs(10)).expect("a fix");
304        assert_eq!(id, BOAT, "still the only reading there is");
305        assert_eq!(fix.value, 2.0);
306    }
307}