Skip to main content

navcore_fix/
vessel.rs

1//! Own-ship state, how it is assembled, and a simulator to produce it until
2//! real readings arrive.
3//!
4//! Everything a client draws about the vessel is computed here, in Rust,
5//! using `nav-math`. A client is handed finished coordinates and does no
6//! navigation arithmetic of its own -- otherwise the same arithmetic gets
7//! written again for every other client.
8
9use std::time::Duration;
10
11use nav_math::{Position, angle, great_circle};
12
13/// How far ahead the course predictor reaches, in minutes of run at the
14/// current speed.
15///
16/// Six minutes is the usual default in marine navigation practice, and it
17/// has a neat property that is why it was chosen: at six minutes the
18/// predictor length in nautical miles is a tenth of the speed in knots,
19/// so the line doubles as a speed readout once the eye is trained.
20const PREDICTOR_MINUTES: f64 = 6.0;
21
22/// Shortest the heading line is ever drawn, in nautical miles. See
23/// [`VesselState::heading_line_end`].
24const MIN_HEADING_LINE_NM: f64 = 0.15;
25
26/// What a client knows about own ship at one instant.
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct VesselState {
29    /// Where the vessel is.
30    pub position: Position,
31    /// Course over ground, degrees true.
32    pub cog_deg: f64,
33    /// Speed over ground, knots.
34    pub sog_kn: f64,
35    /// Heading, degrees true, when the vessel has a compass.
36    ///
37    /// Differs from course when there is leeway or current. `None` when
38    /// the vessel has no heading sensor; substituting course in that
39    /// case would misrepresent a measurement as heading.
40    pub heading_deg: Option<f64>,
41}
42
43impl VesselState {
44    /// The far end of the course predictor: where the vessel reaches if it
45    /// holds this course and speed for `PREDICTOR_MINUTES`.
46    #[must_use]
47    pub fn predictor_end(&self) -> Position {
48        let distance_nm = self.sog_kn * (PREDICTOR_MINUTES / 60.0);
49        great_circle::destination(self.position, self.cog_deg, distance_nm)
50    }
51
52    /// The far end of the heading line: where the bow points, which
53    /// differs from the course over ground under leeway or current.
54    ///
55    /// Length matches the course predictor so the angle between them is
56    /// directly comparable, with a floor (`MIN_HEADING_LINE_NM`) so
57    /// the line stays visible for a vessel stopped in a tideway. `None`
58    /// when there is no compass; no line is drawn in that case.
59    #[must_use]
60    pub fn heading_line_end(&self) -> Option<Position> {
61        let heading_deg = self.heading_deg?;
62        let distance_nm = (self.sog_kn * (PREDICTOR_MINUTES / 60.0)).max(MIN_HEADING_LINE_NM);
63        Some(great_circle::destination(
64            self.position,
65            heading_deg,
66            distance_nm,
67        ))
68    }
69}
70
71/// A [`VesselState`] under construction, built up one reading at a time.
72///
73/// Readings arrive as separate calls to its setters regardless of
74/// source -- a Signal K delta, a parsed NMEA 0183 sentence, a replayed
75/// passage -- and this type only tracks whether enough have arrived
76/// for a complete state. It has no knowledge of the source protocol.
77#[derive(Debug, Clone, Copy, Default)]
78pub struct FixBuilder {
79    position: Option<Position>,
80    cog_deg: Option<f64>,
81    sog_kn: Option<f64>,
82    heading_deg: Option<f64>,
83}
84
85impl FixBuilder {
86    /// Nothing heard yet.
87    #[must_use]
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Records a position. Only a position update marks a fix as
93    /// fresh; course and speed updates alone must not reset the age of
94    /// a stalled position.
95    pub fn set_position(&mut self, position: Position) {
96        self.position = Some(position);
97    }
98
99    /// Records course over ground, degrees true.
100    pub fn set_course_over_ground(&mut self, deg: f64) {
101        self.cog_deg = Some(deg);
102    }
103
104    /// Records speed over ground, knots.
105    pub fn set_speed_over_ground(&mut self, kn: f64) {
106        self.sog_kn = Some(kn);
107    }
108
109    /// Records heading, degrees true. Never required -- see
110    /// [`VesselState::heading_deg`].
111    pub fn set_heading(&mut self, deg: f64) {
112        self.heading_deg = Some(deg);
113    }
114
115    /// A complete state, if enough has arrived to build one.
116    ///
117    /// Heading may be missing -- a vessel without a compass has no
118    /// heading line. Position, course and speed are required; there is
119    /// no meaningful default for any of them.
120    #[must_use]
121    pub fn complete(self) -> Option<VesselState> {
122        Some(VesselState {
123            position: self.position?,
124            cog_deg: self.cog_deg?,
125            sog_kn: self.sog_kn?,
126            heading_deg: self.heading_deg,
127        })
128    }
129}
130
131/// Moves a vessel over the ground, producing a state to render before a
132/// real position source is available.
133///
134/// A pure function of elapsed time rather than wall-clock time, so the
135/// same call sequence produces the same track on every run, and a
136/// session can be replayed faster than real time.
137#[derive(Debug, Clone)]
138pub struct Simulator {
139    state: VesselState,
140    /// Degrees per second, positive to starboard. Nonzero so the
141    /// simulated course visibly changes over time.
142    turn_rate_deg_s: f64,
143}
144
145impl Simulator {
146    /// A vessel under way in the Gulf of Trieste, where this project's
147    /// charts have coverage.
148    #[must_use]
149    pub fn new() -> Self {
150        Self {
151            state: VesselState {
152                position: Position::new(45.5500, 13.7300),
153                cog_deg: 300.0,
154                sog_kn: 6.2,
155                heading_deg: Some(297.0),
156            },
157            turn_rate_deg_s: 0.35,
158        }
159    }
160
161    /// Advances the vessel by `dt` and returns the new state.
162    pub fn tick(&mut self, dt: Duration) -> VesselState {
163        let seconds = dt.as_secs_f64();
164
165        self.state.cog_deg = angle::norm_360(self.state.cog_deg + self.turn_rate_deg_s * seconds);
166        // Heading lags the course by a fixed offset here; with no wind or
167        // current model there is nothing better to base it on, and pretending
168        // otherwise would invent data.
169        self.state.heading_deg = Some(angle::norm_360(self.state.cog_deg - 3.0));
170
171        let distance_nm = self.state.sog_kn * (seconds / 3600.0);
172        self.state.position =
173            great_circle::destination(self.state.position, self.state.cog_deg, distance_nm);
174
175        self.state
176    }
177
178    /// The current state without advancing it.
179    #[must_use]
180    pub fn state(&self) -> VesselState {
181        self.state
182    }
183}
184
185impl Default for Simulator {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191/// A plausible wind, for a simulated run with no boat to have a real one.
192#[derive(Debug, Clone, Copy, PartialEq)]
193pub struct SimulatedWind {
194    /// Degrees true -- an absolute bearing the wind blows *from*.
195    pub direction_true_deg: f64,
196    /// Knots.
197    pub speed_kn: f64,
198}
199
200/// A plausible wind for a simulated run, for features built on wind
201/// data (laylines, a wind readout) to have something to display before
202/// a real wind source exists.
203///
204/// A plain function of `now` rather than a [`Simulator`] method, since
205/// wind needs no accumulated state, only elapsed time. Direction and
206/// speed oscillate slowly rather than staying fixed, so a change a
207/// caller makes -- to a beat-angle setting, say -- remains
208/// distinguishable from the simulated wind's own variation.
209///
210/// `now` is elapsed time since the run started, deterministic like
211/// every other simulated value, so a recorded session replays
212/// identically.
213#[must_use]
214pub fn simulated_wind(now: Duration) -> SimulatedWind {
215    let t = now.as_secs_f64();
216    SimulatedWind {
217        direction_true_deg: angle::norm_360(270.0 + 15.0 * (t / 40.0).sin()),
218        speed_kn: 12.0 + 3.0 * (t / 25.0).sin(),
219    }
220}
221
222/// Formats a latitude as degrees and decimal minutes, the conventional
223/// format in marine navigation (charts, almanacs, radio position
224/// reports), rather than the decimal degrees used internally.
225#[must_use]
226pub fn format_latitude(lat_deg: f64) -> String {
227    let hemisphere = if lat_deg < 0.0 { 'S' } else { 'N' };
228    format!("{}{hemisphere}", degrees_minutes(lat_deg.abs(), 2))
229}
230
231/// Formats a longitude, three-digit degrees as is conventional.
232#[must_use]
233pub fn format_longitude(lon_deg: f64) -> String {
234    let hemisphere = if lon_deg < 0.0 { 'W' } else { 'E' };
235    format!("{}{hemisphere}", degrees_minutes(lon_deg.abs(), 3))
236}
237
238fn degrees_minutes(value: f64, degree_width: usize) -> String {
239    let mut degrees = value.trunc();
240    let mut minutes = (value - degrees) * 60.0;
241
242    // 59.996' rounds to 60.00' at two decimals, which is not a minute that
243    // exists. Carry it into the degrees instead of printing it.
244    if minutes >= 59.995 {
245        minutes = 0.0;
246        degrees += 1.0;
247    }
248
249    format!("{:0degree_width$.0}\u{00b0}{:05.2}\u{2032}", degrees, minutes)
250}
251
252#[cfg(test)]
253mod simulated_wind_tests {
254    use super::*;
255
256    #[test]
257    fn it_stays_within_a_believable_range() {
258        for secs in 0..600 {
259            let wind = simulated_wind(Duration::from_secs(secs));
260            assert!((0.0..360.0).contains(&wind.direction_true_deg), "{wind:?}");
261            assert!(wind.speed_kn > 0.0, "{wind:?}");
262        }
263    }
264
265    #[test]
266    fn it_is_deterministic_from_the_same_clock() {
267        let a = simulated_wind(Duration::from_secs(123));
268        let b = simulated_wind(Duration::from_secs(123));
269        assert_eq!(a, b);
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    fn close(a: f64, b: f64, tol: f64) -> bool {
278        (a - b).abs() < tol
279    }
280
281    #[test]
282    fn the_predictor_is_a_tenth_of_the_speed_in_miles() {
283        // The property that makes six minutes the conventional choice.
284        let state = VesselState {
285            position: Position::new(45.0, 13.0),
286            cog_deg: 0.0,
287            sog_kn: 6.0,
288            heading_deg: Some(0.0),
289        };
290        let run = great_circle::distance_nm(state.position, state.predictor_end());
291        assert!(close(run, 0.6, 1e-9), "got {run}");
292    }
293
294    #[test]
295    fn a_stopped_vessel_has_no_predictor() {
296        let state = VesselState {
297            position: Position::new(45.0, 13.0),
298            cog_deg: 90.0,
299            sog_kn: 0.0,
300            heading_deg: Some(90.0),
301        };
302        // Not exact equality: the destination formula round-trips the
303        // latitude through asin(sin(x)), which need not land on the same bit
304        // pattern. The property being asserted is that the vector has no
305        // length, and that is what to measure.
306        let run = great_circle::distance_nm(state.position, state.predictor_end());
307        assert!(close(run, 0.0, 1e-12), "stopped vessel predicted {run} NM ahead");
308    }
309
310    #[test]
311    fn the_heading_line_survives_the_vessel_stopping() {
312        let state = VesselState {
313            position: Position::new(45.0, 13.0),
314            cog_deg: 90.0,
315            sog_kn: 0.0,
316            heading_deg: Some(45.0),
317        };
318        let end = state.heading_line_end().expect("a compass");
319        let run = great_circle::distance_nm(state.position, end);
320        assert!(close(run, MIN_HEADING_LINE_NM, 1e-9), "got {run}");
321        // And it still points at the bow, not at the course.
322        let brg = great_circle::initial_bearing_deg(state.position, end);
323        assert!(close(brg, 45.0, 1e-6), "heading line ran off on {brg}");
324    }
325
326    #[test]
327    fn under_way_the_two_lines_are_the_same_length_and_differ_only_in_angle() {
328        let state = VesselState {
329            position: Position::new(45.0, 13.0),
330            cog_deg: 300.0,
331            sog_kn: 6.2,
332            heading_deg: Some(297.0),
333        };
334        let cog_run = great_circle::distance_nm(state.position, state.predictor_end());
335        let heading_end = state.heading_line_end().expect("a compass");
336        let hdg_run = great_circle::distance_nm(state.position, heading_end);
337        assert!(close(cog_run, hdg_run, 1e-9), "{cog_run} vs {hdg_run}");
338
339        let separation = angle::diff(
340            great_circle::initial_bearing_deg(state.position, state.predictor_end()),
341            great_circle::initial_bearing_deg(state.position, heading_end),
342        );
343        assert!(close(separation, 3.0, 1e-6), "got {separation}");
344    }
345
346    #[test]
347    fn ticking_moves_the_vessel_at_its_speed() {
348        let mut sim = Simulator::new();
349        sim.turn_rate_deg_s = 0.0; // straight, so distance is checkable
350        let before = sim.state();
351        let after = sim.tick(Duration::from_secs(3600));
352        let run = great_circle::distance_nm(before.position, after.position);
353        assert!(close(run, before.sog_kn, 1e-3), "one hour ran {run} NM");
354    }
355
356    #[test]
357    fn the_simulated_course_stays_a_valid_bearing() {
358        let mut sim = Simulator::new();
359        for _ in 0..2000 {
360            let s = sim.tick(Duration::from_secs(1));
361            assert!((0.0..360.0).contains(&s.cog_deg), "cog {} left range", s.cog_deg);
362            let heading = s.heading_deg.expect("the simulator has a compass");
363            assert!((0.0..360.0).contains(&heading), "heading {heading} left range");
364        }
365    }
366
367    #[test]
368    fn a_vessel_without_a_compass_gets_no_heading_line() {
369        // Not a line along the course, which would look like a measurement
370        // and hide the leeway the two lines exist to show. Nothing at all.
371        let state = VesselState {
372            position: Position::new(45.0, 13.0),
373            cog_deg: 90.0,
374            sog_kn: 5.0,
375            heading_deg: None,
376        };
377        assert!(state.heading_line_end().is_none());
378        // The course predictor is unaffected: it needs no compass.
379        assert!(great_circle::distance_nm(state.position, state.predictor_end()) > 0.0);
380    }
381
382    #[test]
383    fn positions_are_degrees_and_decimal_minutes() {
384        assert_eq!(format_latitude(45.55), "45\u{00b0}33.00\u{2032}N");
385        assert_eq!(format_longitude(13.73), "013\u{00b0}43.80\u{2032}E");
386    }
387
388    #[test]
389    fn the_southern_and_western_hemispheres_are_not_negative_numbers() {
390        assert_eq!(format_latitude(-33.8688), "33\u{00b0}52.13\u{2032}S");
391        assert_eq!(format_longitude(-5.2), "005\u{00b0}12.00\u{2032}W");
392    }
393
394    #[test]
395    fn sixty_minutes_carries_into_the_degree() {
396        // 45.99999 deg is 45 deg 59.9994', which must not print as 45°60.00'.
397        assert_eq!(format_latitude(45.999_99), "46\u{00b0}00.00\u{2032}N");
398    }
399
400    #[test]
401    fn a_builder_with_only_a_position_is_not_a_state() {
402        let mut builder = FixBuilder::new();
403        builder.set_position(Position::new(45.5, 13.7));
404        assert!(builder.complete().is_none(), "position alone is not a state");
405
406        builder.set_course_over_ground(300.0);
407        assert!(builder.complete().is_none());
408
409        builder.set_speed_over_ground(6.2);
410        assert!(builder.complete().is_some());
411    }
412
413    #[test]
414    fn a_builder_with_no_compass_still_produces_a_state() {
415        let mut builder = FixBuilder::new();
416        builder.set_position(Position::new(45.5, 13.7));
417        builder.set_course_over_ground(300.0);
418        builder.set_speed_over_ground(6.2);
419
420        let state = builder.complete().expect("a state");
421        assert!(state.heading_deg.is_none());
422    }
423}