Skip to main content

navcore_route_check/
lib.rs

1//! Checking a whole route, rather than one leg at a time.
2//!
3//! Everything needed for this already existed on both sides. What was
4//! missing was the translation between them, and translation is where the
5//! decisions are:
6//!
7//! - **Every leg is checked to the same tolerances.** [`Defaults`] carries
8//!   the mariner's own standing numbers -- the same safety contour the
9//!   chart is drawn with -- and every leg is checked against them, rather
10//!   than a leg overriding the vessel's standing corridor or safety
11//!   contour: that per-leg richness has nowhere to live in Signal K's own
12//!   Route resource, and no client needs it -- see `routes::signalk`'s
13//!   own doc.
14//! - **Distances are reported twice.** Along the leg, which is what you
15//!   steer by, and along the whole route, which is what you plan by.
16//! - **Every leg is a rhumb line.** The same reason `routes::Route::distance_nm`
17//!   already measures one that way: it is what a leg drawn on a Mercator
18//!   chart actually is.
19
20#![forbid(unsafe_code)]
21
22use enc_store::{ChartStore, Finding, Leg, Sailing, Severity, StoreError, Vessel, check_leg};
23use routes::Route;
24
25/// The vessel's standing settings, applied to every leg alike.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct Defaults {
28    /// The mariner's safety contour in metres -- the same setting the chart
29    /// is drawn with, so that what the check calls unsafe is what the
30    /// screen shows as unsafe.
31    pub safety_contour_m: f64,
32    /// How far off track the vessel may be to port, in nautical miles.
33    pub port_xtd_nm: f64,
34    /// The same to starboard.
35    pub starboard_xtd_nm: f64,
36}
37
38/// What one leg of the route came back with.
39#[derive(Debug, Clone, PartialEq)]
40pub struct LegReport {
41    /// Which leg, counting from zero: leg 0 runs from waypoint 0 to
42    /// waypoint 1.
43    pub index: usize,
44    /// The name of the waypoint the leg arrives at, when it has one.
45    pub to_name: Option<String>,
46    /// Distance from the start of the *route* to the start of this leg.
47    pub starts_at_nm: f64,
48    /// The leg's own length.
49    pub length_nm: f64,
50    /// What the chart said, with distances measured along this leg.
51    pub findings: Vec<Finding>,
52}
53
54/// What a whole route came back with.
55#[derive(Debug, Clone, PartialEq)]
56pub struct RouteReport {
57    /// The route's identity, so a report can be matched to what it is about.
58    pub uuid: String,
59    /// Its name at the time of checking.
60    pub name: Option<String>,
61    /// Total distance, each leg measured the way it is drawn.
62    pub distance_nm: f64,
63    /// One per leg, in the order they are sailed.
64    pub legs: Vec<LegReport>,
65}
66
67impl RouteReport {
68    /// The worst thing found anywhere on the route, if anything was.
69    #[must_use]
70    pub fn worst(&self) -> Option<Severity> {
71        self.legs
72            .iter()
73            .flat_map(|leg| leg.findings.iter())
74            .map(|finding| finding.severity)
75            .min()
76    }
77
78    /// Whether the route can be sailed as planned: nothing unsafe and
79    /// nothing the check could not answer.
80    #[must_use]
81    pub fn is_clear(&self) -> bool {
82        !self.legs.iter().flat_map(|leg| leg.findings.iter()).any(|finding| {
83            matches!(finding.severity, Severity::Unsafe | Severity::Unsurveyed | Severity::Coarse)
84        })
85    }
86
87    /// Every finding with its distance from the start of the *route* rather
88    /// than from the start of its leg, worst first at equal distances.
89    #[must_use]
90    pub fn findings_along_route(&self) -> Vec<(f64, usize, &Finding)> {
91        let mut all: Vec<(f64, usize, &Finding)> = self
92            .legs
93            .iter()
94            .flat_map(|leg| {
95                leg.findings
96                    .iter()
97                    .map(move |finding| (leg.starts_at_nm + finding.along_track_nm, leg.index, finding))
98            })
99            .collect();
100        all.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.2.severity.cmp(&b.2.severity)));
101        all
102    }
103}
104
105/// Checks every leg of a route against a chart.
106///
107/// # Errors
108///
109/// If the chart cannot be read.
110pub fn check_route(chart: &ChartStore, route: &Route, defaults: Defaults) -> Result<RouteReport, StoreError> {
111    let mut legs = Vec::new();
112    let mut starts_at_nm = 0.0;
113
114    for (index, (from, to)) in route.legs().enumerate() {
115        let leg = Leg {
116            from: from.position,
117            to: to.position,
118            port_xtd_nm: defaults.port_xtd_nm,
119            starboard_xtd_nm: defaults.starboard_xtd_nm,
120            sailing: Sailing::Rhumb,
121        };
122        let vessel = Vessel { safety_contour_m: defaults.safety_contour_m };
123
124        let length_nm = leg.length_nm();
125        legs.push(LegReport {
126            index,
127            to_name: to.name.clone(),
128            starts_at_nm,
129            length_nm,
130            findings: check_leg(chart, leg, vessel)?,
131        });
132        starts_at_nm += length_nm;
133    }
134
135    Ok(RouteReport { uuid: route.uuid.clone(), name: route.name.clone(), distance_nm: starts_at_nm, legs })
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use nav_math::Position;
142    use routes::Waypoint;
143
144    fn defaults() -> Defaults {
145        Defaults { safety_contour_m: 3.0, port_xtd_nm: 0.05, starboard_xtd_nm: 0.05 }
146    }
147
148    fn route() -> Route {
149        let mut route = Route::new(
150            "94052456-65fa-48ce-a85d-41b78a9d2111",
151            vec![
152                Waypoint::at(Position::new(45.5150, 13.5680)),
153                Waypoint::at(Position::new(45.5500, 13.6200)),
154                Waypoint::at(Position::new(45.5480, 13.7300)),
155            ],
156        );
157        route.name = Some("Piran to Koper".to_owned());
158        route
159    }
160
161    #[test]
162    fn every_leg_takes_the_vessels_own_standing_tolerances() {
163        let plan = route();
164        let (from, to) = plan.legs().next().expect("a leg");
165        let leg = Leg {
166            from: from.position,
167            to: to.position,
168            port_xtd_nm: defaults().port_xtd_nm,
169            starboard_xtd_nm: defaults().starboard_xtd_nm,
170            sailing: Sailing::Rhumb,
171        };
172
173        assert_eq!(leg.port_xtd_nm, 0.05);
174        assert_eq!(leg.starboard_xtd_nm, 0.05);
175    }
176
177    /// A report with the findings a test wants, without a chart behind it.
178    fn report(findings: Vec<(usize, Severity)>) -> RouteReport {
179        let plan = route();
180        let mut legs: Vec<LegReport> = plan
181            .legs()
182            .enumerate()
183            .map(|(index, (_, to))| LegReport {
184                index,
185                to_name: to.name.clone(),
186                starts_at_nm: index as f64 * 2.0,
187                length_nm: 2.0,
188                findings: Vec::new(),
189            })
190            .collect();
191
192        for (leg_index, severity) in findings {
193            legs[leg_index].findings.push(Finding {
194                severity,
195                class: "DEPARE".to_owned(),
196                along_track_nm: 0.5,
197                until_nm: 0.5,
198                position: Position::new(45.53, 13.60),
199                reason: "test".to_owned(),
200            });
201        }
202
203        RouteReport { uuid: plan.uuid.clone(), name: plan.name.clone(), distance_nm: 4.0, legs }
204    }
205
206    #[test]
207    fn a_clean_route_is_clear() {
208        assert!(report(Vec::new()).is_clear());
209    }
210
211    #[test]
212    fn a_caution_does_not_stop_a_route_being_clear() {
213        // A traffic scheme or an anchorage is something to know, not
214        // something that makes the plan unsailable.
215        let clean = report(vec![(1, Severity::Caution)]);
216        assert!(clean.is_clear());
217        assert_eq!(clean.worst(), Some(Severity::Caution));
218    }
219
220    #[test]
221    fn anything_the_check_could_not_answer_stops_it_being_clear() {
222        for severity in [Severity::Unsafe, Severity::Unsurveyed, Severity::Coarse] {
223            assert!(!report(vec![(0, severity)]).is_clear(), "{severity:?} should not pass");
224        }
225    }
226
227    #[test]
228    fn findings_are_offset_onto_the_whole_route() {
229        // Along the leg is what you steer by; along the route is what you
230        // plan by, and a report that only knew the first would have every
231        // leg starting at zero.
232        let checked = report(vec![(0, Severity::Caution), (1, Severity::Unsafe)]);
233        let along = checked.findings_along_route();
234
235        assert_eq!(along[0].0, 0.5, "first leg, half a mile in");
236        assert_eq!(along[1].0, 2.5, "second leg, two miles further on");
237        assert_eq!(along[1].1, 1, "and it knows which leg that was");
238    }
239}