Skip to main content

navcore_enc_store/
check.rs

1//! Whether a leg is safe, and if not, where it stops being safe.
2//!
3//! Two rules govern every check performed here.
4//!
5//! **The safety contour decides.** It is the mariner's own setting,
6//! the same value the chart is drawn with, so what the check calls
7//! unsafe matches what the chart displays as unsafe.
8//!
9//! **An unknown answer is treated as unsafe.** A rock with no charted
10//! depth, an obstruction with no sounding, or water outside the
11//! chart's own coverage are all reported rather than passed over
12//! silently.
13
14use geo::{BoundingRect, Contains, Intersects, Point, Rect};
15use nav_math::{METRES_PER_NM, Position};
16
17use crate::corridor::Leg;
18use crate::store::{ChartStore, Feature, StoreError};
19
20/// What the vessel needs to be safe.
21///
22/// One number for now: the mariner's own safety contour. Air draught
23/// and beam belong here once the chart can answer them; a setting the
24/// check cannot honour would be worse than omitting it.
25#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Vessel {
27    /// The mariner's safety contour, in metres.
28    pub safety_contour_m: f64,
29}
30
31/// How much a finding matters.
32///
33/// Declaration order is the ranking, worst first: a danger outranks
34/// silence, silence outranks a coarse answer, and all three outrank
35/// an advisory. `Unsurveyed` and `Coarse` rank above `Caution` because
36/// they describe how far the check itself can be trusted, not a
37/// property of the water.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39pub enum Severity {
40    /// The vessel cannot pass here: land, water shallower than the safety
41    /// contour, or a danger whose depth is unknown.
42    Unsafe,
43    /// The chart has no coverage here. Reports an unanswered question,
44    /// not clear water.
45    Unsurveyed,
46    /// There is data, but it was compiled at a scale too small to answer
47    /// the question that was asked of it. A clear result here means "no
48    /// danger was drawn at this scale", which is a weaker statement than
49    /// "there is no danger".
50    Coarse,
51    /// Passable, but the mariner has to know: a restricted area, a traffic
52    /// separation scheme, a cable field nobody should anchor in.
53    Caution,
54}
55
56/// How much of a chart's own scale a position on it can be trusted to.
57///
58/// Half a millimetre at compilation scale: the standard cartographic
59/// figure for the finest detail drawable on and readable from a chart.
60/// At 1:1,500,000 this is 750 m; at 1:12,000 it is 6 m. This is the
61/// accuracy the charts were compiled to, and converts a compilation
62/// scale into the maximum positional error this check accounts for.
63const PLOTTING_ACCURACY_MM: f64 = 0.5;
64
65/// How far out the chart may be, in metres, at a given compilation scale.
66#[must_use]
67fn accuracy_m(compilation_scale: f64) -> f64 {
68    compilation_scale * PLOTTING_ACCURACY_MM / 1000.0
69}
70
71/// One thing found along a leg.
72#[derive(Debug, Clone, PartialEq)]
73pub struct Finding {
74    /// How much it matters.
75    pub severity: Severity,
76    /// S-57 object class, or `M_COVR` for a gap in coverage.
77    pub class: String,
78    /// How far along the leg it was first met, in nautical miles.
79    pub along_track_nm: f64,
80    /// How far along the leg it was last met.
81    ///
82    /// Equal to [`Self::along_track_nm`] for a hazard met at a single
83    /// point. A two-mile stretch of shoal water is reported as one
84    /// finding with two ends, not as multiple findings at each
85    /// sampled interval.
86    pub until_nm: f64,
87    /// The position on the leg where the finding starts, for a caller
88    /// to know where to look -- not the hazard's own centre, which for
89    /// a large area may be a position the vessel never approaches.
90    pub position: Position,
91    /// Why it was reported, in words fit to put on a screen.
92    pub reason: String,
93}
94
95impl Finding {
96    /// Whether this was met along a stretch rather than at one spot.
97    #[must_use]
98    pub fn is_stretch(&self) -> bool {
99        self.until_nm > self.along_track_nm
100    }
101}
102
103/// Object classes that are always a stop, whatever their attributes say.
104const ALWAYS_UNSAFE: [&str; 1] = ["LNDARE"];
105
106/// Object classes whose charted depth is compared with the safety contour.
107///
108/// S-57 chart symbology rule CS(DEPARE03) handles these two classes
109/// together: a dredged area is shaded and contoured identically to a
110/// depth area, so both are checked the same way.
111const DEPTH_AREAS: [&str; 2] = ["DEPARE", "DRGARE"];
112
113/// Point and area dangers carrying a sounding.
114const SOUNDED_DANGERS: [&str; 3] = ["OBSTRN", "UWTROC", "WRECKS"];
115
116/// Classes worth a word, in the order a mariner would want to hear them.
117const CAUTIONS: [&str; 6] = ["BRIDGE", "RESARE", "TSSLPT", "ACHARE", "CBLARE", "PIPARE"];
118
119/// The chart's own statement of where it has data.
120const COVERAGE: &str = "M_COVR";
121
122/// The compilation scale the pipeline writes onto every feature, upper
123/// case because the store folds attribute names that way.
124///
125/// Source-independent by design: the OeSENC path takes it from the SENC
126/// header's native scale, the S-57 path from the dataset's own DSPM/CSCL
127/// field, and both write it under this one name. Nothing here needs to
128/// know which kind of chart it is reading.
129const SCALE_ATTRIBUTE: &str = "COMPILATION_SCALE";
130
131/// Checks one leg against a chart.
132///
133/// Findings are returned in the order they occur along the leg.
134///
135/// # Errors
136///
137/// If the chart cannot be read.
138pub fn check_leg(
139    store: &ChartStore,
140    leg: Leg,
141    vessel: Vessel,
142) -> Result<Vec<Finding>, StoreError> {
143    let Some(band) = leg.band() else {
144        return Ok(Vec::new());
145    };
146    let Some(bounds) = band.bounding_rect() else {
147        return Ok(Vec::new());
148    };
149
150    let segments = leg.segments();
151    let mut met = Vec::new();
152
153    for class in store.object_classes() {
154        // Classes the check has no rule for are not queried at all. On a
155        // chart exported with every object class that is most of the file,
156        // and reading it to decide it means nothing is work for nothing.
157        if !has_rule(&class) {
158            continue;
159        }
160        for feature in store.features_in(&class, bounds)? {
161            let Some(verdict) = judge(&feature, vessel) else {
162                continue;
163            };
164            let Some(segment) = segments
165                .iter()
166                .find(|segment| segment.band.intersects(&feature.geometry))
167            else {
168                continue;
169            };
170
171            met.push(Met {
172                finding: Finding {
173                    severity: verdict.severity,
174                    class: feature.class.clone(),
175                    along_track_nm: segment.start_nm,
176                    until_nm: segment.start_nm,
177                    position: segment.start,
178                    reason: verdict.reason.clone(),
179                },
180                depth_m: verdict.depth_m,
181                reasons: vec![verdict.reason],
182            });
183        }
184    }
185
186    let mut findings = collapse(met);
187    findings.extend(coverage(store, &leg, bounds, leg.narrowest_xtd_nm())?);
188
189    findings.sort_by(|a, b| {
190        a.along_track_nm
191            .total_cmp(&b.along_track_nm)
192            .then(a.severity.cmp(&b.severity))
193    });
194    Ok(findings)
195}
196
197/// One feature's verdict, before it is placed on the leg.
198struct Verdict {
199    severity: Severity,
200    reason: String,
201    /// The depth that made it a finding, when there is one. Used to decide
202    /// which reason survives when neighbouring findings are collapsed:
203    /// `None` means the depth is unknown, which outranks any known one.
204    depth_m: Option<f64>,
205}
206
207impl Verdict {
208    fn unsafe_at(depth_m: Option<f64>, reason: String) -> Option<Self> {
209        Some(Self {
210            severity: Severity::Unsafe,
211            reason,
212            depth_m,
213        })
214    }
215
216    fn caution(reason: String) -> Option<Self> {
217        Some(Self {
218            severity: Severity::Caution,
219            reason,
220            depth_m: None,
221        })
222    }
223}
224
225/// One finding with the working state the collapsing needs.
226struct Met {
227    finding: Finding,
228    depth_m: Option<f64>,
229    /// The distinct reasons merged into this one, so a stretch that is land
230    /// in five places does not claim five different things, and one that is
231    /// several different things can say so.
232    reasons: Vec<String>,
233}
234
235/// Whether the check has anything to say about a class at all.
236fn has_rule(class: &str) -> bool {
237    ALWAYS_UNSAFE.contains(&class)
238        || DEPTH_AREAS.contains(&class)
239        || SOUNDED_DANGERS.contains(&class)
240        || CAUTIONS.contains(&class)
241}
242
243/// Merges findings of the same class that follow one another along the leg.
244///
245/// Without this a leg running two miles along a shoal reports the same thing
246/// eight times, and the one finding that matters is buried among them.
247fn collapse(mut met: Vec<Met>) -> Vec<Finding> {
248    met.sort_by(|a, b| {
249        a.finding
250            .class
251            .cmp(&b.finding.class)
252            .then(a.finding.severity.cmp(&b.finding.severity))
253            .then(a.finding.along_track_nm.total_cmp(&b.finding.along_track_nm))
254    });
255
256    let mut merged: Vec<Met> = Vec::new();
257    for item in met {
258        match merged.last_mut() {
259            Some(open) if adjoins(open, &item) => absorb(open, item),
260            _ => merged.push(item),
261        }
262    }
263
264    merged
265        .into_iter()
266        .map(|mut item| {
267            if item.reasons.len() > 1 {
268                let others = item.reasons.len() - 1;
269                item.finding.reason = format!("{} +{others} more", item.finding.reason);
270            }
271            item.finding
272        })
273        .collect()
274}
275
276/// Whether two findings are the same thing continuing.
277fn adjoins(open: &Met, next: &Met) -> bool {
278    open.finding.class == next.finding.class
279        && open.finding.severity == next.finding.severity
280        // Neighbouring pieces of the leg, or the same piece. A gap of more
281        // than one piece is a second encounter and stays its own finding.
282        && next.finding.along_track_nm <= open.finding.until_nm + crate::corridor::SEGMENT_NM + 1e-9
283}
284
285/// Folds one finding into the stretch it continues.
286fn absorb(open: &mut Met, next: Met) {
287    open.finding.until_nm = open.finding.until_nm.max(next.finding.until_nm);
288
289    // The worst member's words survive. Shallower beats deeper, and an
290    // unknown depth beats both -- if a leg crosses water charted at 2 m and
291    // water charted at nothing at all, the second is what to say out loud.
292    let next_is_worse = match (open.depth_m, next.depth_m) {
293        (Some(_), None) => true,
294        (Some(open_m), Some(next_m)) => next_m < open_m,
295        (None, _) => false,
296    };
297    if next_is_worse {
298        open.finding.reason.clone_from(&next.finding.reason);
299        open.depth_m = next.depth_m;
300    }
301
302    if !open.reasons.contains(&next.finding.reason) {
303        open.reasons.push(next.finding.reason);
304    }
305}
306
307/// What one feature means for this vessel, or `None` if it means nothing.
308fn judge(feature: &Feature, vessel: Vessel) -> Option<Verdict> {
309    let class = feature.class.as_str();
310    let named = feature.name().map_or(String::new(), |name| format!(" ({name})"));
311
312    if ALWAYS_UNSAFE.contains(&class) {
313        // Land has no depth to rank, and none is needed: nothing collapses
314        // against it that could be worse.
315        return Verdict::unsafe_at(Some(f64::NEG_INFINITY), format!("land{named}"));
316    }
317
318    if DEPTH_AREAS.contains(&class) {
319        return match feature.number("DRVAL1") {
320            Some(depth_m) if depth_m >= vessel.safety_contour_m => None,
321            Some(depth_m) => Verdict::unsafe_at(
322                Some(depth_m),
323                format!(
324                    "charted from {depth_m:.1} m, inside the {:.1} m safety contour{named}",
325                    vessel.safety_contour_m
326                ),
327            ),
328            // CS(DEPARE03)'s own fail-safe: a depth area with no DRVAL1 is
329            // treated as drying, because the alternative is treating unknown
330            // water as deep water. Said in those words, too -- quoting the
331            // fail-safe value as though it were a charted depth would put a
332            // number on the screen that no survey ever measured.
333            None => Verdict::unsafe_at(None, format!("no charted depth, treated as drying{named}")),
334        };
335    }
336
337    if SOUNDED_DANGERS.contains(&class) {
338        return match feature.number("VALSOU") {
339            Some(depth_m) if depth_m >= vessel.safety_contour_m => None,
340            Some(depth_m) => {
341                Verdict::unsafe_at(Some(depth_m), format!("sounded {depth_m:.1} m{named}"))
342            }
343            // No sounding at all. S-52 draws these as isolated dangers for
344            // the same reason: an obstruction of unknown depth is one to
345            // stay away from, not one to assume is deep.
346            None => Verdict::unsafe_at(None, format!("depth unknown{named}")),
347        };
348    }
349
350    if CAUTIONS.contains(&class) {
351        let detail = match class {
352            "BRIDGE" => feature
353                .number("VERCLR")
354                .map_or_else(|| "bridge".to_owned(), |m| format!("bridge, {m:.1} m clear")),
355            "RESARE" => "restricted area".to_owned(),
356            "TSSLPT" => "traffic separation scheme".to_owned(),
357            "ACHARE" => "anchorage".to_owned(),
358            "CBLARE" => "submarine cables, do not anchor".to_owned(),
359            "PIPARE" => "pipelines, do not anchor".to_owned(),
360            other => other.to_owned(),
361        };
362        return Verdict::caution(format!("{detail}{named}"));
363    }
364
365    None
366}
367
368/// What the chart says about its own data along the leg.
369///
370/// Two questions, asked of the same M_COVR features in one pass, because
371/// they are the same question at different strengths: *is* there data here,
372/// and is it fine enough to answer what was asked. Without the first, a leg
373/// running off the edge of the coverage comes back clean, and "no danger
374/// found" reads identically to "no data". Without the second, a corridor of
375/// fifty metres checked against a chart compiled at 1:1,500,000 comes back
376/// clean too -- against data that could not have shown a fifty-metre
377/// feature in the first place.
378fn coverage(
379    store: &ChartStore,
380    leg: &Leg,
381    bounds: Rect<f64>,
382    vessel_corridor_nm: f64,
383) -> Result<Vec<Finding>, StoreError> {
384    if !store.object_classes().iter().any(|class| class == COVERAGE) {
385        return Ok(Vec::new());
386    }
387
388    let covers: Vec<Feature> = store
389        .features_in(COVERAGE, bounds)?
390        .into_iter()
391        // CATCOV 1 is "coverage available"; anything else is a hole the
392        // chart drew on purpose.
393        .filter(|feature| feature.number("CATCOV").unwrap_or(1.0) == 1.0)
394        .collect();
395
396    // Every piece boundary, and the far end of the leg. The end matters on
397    // its own account: without it a leg that runs out of coverage in its
398    // last few hundred metres reports nothing -- silence exactly where the
399    // chart stops knowing, which is what this test exists to prevent.
400    let samples = leg
401        .segments()
402        .into_iter()
403        .map(|segment| (segment.start_nm, segment.start))
404        .chain(std::iter::once((leg.length_nm(), leg.to)));
405
406    let corridor_m = vessel_corridor_nm * METRES_PER_NM;
407    let mut findings = Vec::new();
408    let mut gap: Option<Run> = None;
409    let mut coarse: Option<Run> = None;
410
411    for (along_nm, position) in samples {
412        let point = Point::new(position.lon_deg, position.lat_deg);
413        let here: Vec<&Feature> = covers
414            .iter()
415            .filter(|feature| feature.geometry.contains(&point))
416            .collect();
417
418        // The finest chart covering this spot answers for it: an overview
419        // cell spanning half the Mediterranean does not make the harbour
420        // plan underneath it any less detailed.
421        let finest = here
422            .iter()
423            .filter_map(|feature| feature.number(SCALE_ATTRIBUTE))
424            .filter(|scale| *scale > 0.0)
425            .reduce(f64::min);
426
427        // A stretch with no data at all is the other finding and is not
428        // also called coarse, or every gap would be reported twice.
429        let coarse_here = if here.is_empty() {
430            None
431        } else {
432            too_coarse(finest, corridor_m)
433        };
434
435        if here.is_empty() {
436            gap.get_or_insert(Run::at(along_nm, position));
437        } else if let Some(run) = gap.take() {
438            findings.push(run.into_gap(along_nm));
439        }
440
441        match coarse_here {
442            Some(scale) => {
443                let run = coarse.get_or_insert(Run::at(along_nm, position));
444                run.worst_scale = worse_scale(run.worst_scale, scale);
445            }
446            None => {
447                if let Some(run) = coarse.take() {
448                    findings.push(run.into_coarse(along_nm, corridor_m));
449                }
450            }
451        }
452    }
453
454    // Runs still open at the far end of the leg close there.
455    let end_nm = leg.length_nm();
456    if let Some(run) = gap.take() {
457        findings.push(run.into_gap(end_nm));
458    }
459    if let Some(run) = coarse.take() {
460        findings.push(run.into_coarse(end_nm, corridor_m));
461    }
462
463    Ok(findings)
464}
465
466/// A stretch of leg being accumulated: where it started, and the worst
467/// thing seen along it so far.
468struct Run {
469    start_nm: f64,
470    start: Position,
471    /// The coarsest compilation scale met along the run. `None` means one
472    /// of the charts along it stated no scale at all, which outranks any
473    /// number.
474    worst_scale: Option<f64>,
475}
476
477impl Run {
478    fn at(start_nm: f64, start: Position) -> Self {
479        Self {
480            start_nm,
481            start,
482            worst_scale: Some(0.0),
483        }
484    }
485
486    fn into_gap(self, until_nm: f64) -> Finding {
487        Finding {
488            severity: Severity::Unsurveyed,
489            class: COVERAGE.to_owned(),
490            along_track_nm: self.start_nm,
491            until_nm,
492            position: self.start,
493            reason: "outside the chart's coverage".to_owned(),
494        }
495    }
496
497    fn into_coarse(self, until_nm: f64, corridor_m: f64) -> Finding {
498        // Both numbers, because "1:1,500,000" means little to most people
499        // and "750 m" means everything.
500        let reason = match self.worst_scale {
501            Some(scale) => format!(
502                "charted at 1:{scale:.0}, which places anything on it to about {}, \
503                 wider than the {} corridor",
504                metres(accuracy_m(scale)),
505                metres(corridor_m)
506            ),
507            None => "the chart here does not state its compilation scale".to_owned(),
508        };
509
510        Finding {
511            severity: Severity::Coarse,
512            class: COVERAGE.to_owned(),
513            along_track_nm: self.start_nm,
514            until_nm,
515            position: self.start,
516            reason,
517        }
518    }
519}
520
521/// A distance in metres, written the way it would be spoken.
522///
523/// Whole metres once there are a few of them, a decimal below that: on a
524/// harbour plan the corridor can be less than a metre wide, and rounding it
525/// to "0 m" turns the explanation into nonsense.
526fn metres(value: f64) -> String {
527    if value >= 10.0 {
528        format!("{value:.0} m")
529    } else {
530        format!("{value:.1} m")
531    }
532}
533
534/// Whether covered water is charted finely enough to answer the question.
535///
536/// `Some(Some(scale))` when the chart cannot resolve the corridor and says
537/// at what scale; `Some(None)` when it does not say at all, which takes the
538/// same posture as an unsounded rock -- an unstated scale is not a fine
539/// one. `None` when the data is good enough and there is nothing to report.
540fn too_coarse(finest_scale: Option<f64>, corridor_m: f64) -> Option<Option<f64>> {
541    match finest_scale {
542        Some(scale) if accuracy_m(scale) > corridor_m => Some(Some(scale)),
543        Some(_) => None,
544        None => Some(None),
545    }
546}
547
548/// The worse of two compilation scales: an unstated one beats every number,
549/// and among numbers the larger denominator is the coarser chart.
550fn worse_scale(open: Option<f64>, next: Option<f64>) -> Option<f64> {
551    match (open, next) {
552        (None, _) | (_, None) => None,
553        (Some(open), Some(next)) => Some(open.max(next)),
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use crate::store::Attribute;
561
562    /// A feature of one class with the attributes a test needs, without a
563    /// database behind it. The geometry is a placeholder: `judge` looks at
564    /// class and attributes only, and the geometry is what decides *whether*
565    /// it is judged, which is the query's job and tested against a real
566    /// chart.
567    fn feature(class: &str, attributes: &[(&str, Attribute)]) -> Feature {
568        Feature::for_test(
569            class,
570            geo::Geometry::Point(Point::new(13.7, 45.5)),
571            attributes,
572        )
573    }
574
575    fn vessel() -> Vessel {
576        Vessel {
577            safety_contour_m: 3.0,
578        }
579    }
580
581    #[test]
582    fn land_is_unsafe_whatever_else_it_says() {
583        let verdict = judge(&feature("LNDARE", &[]), vessel()).expect("land is a finding");
584        assert_eq!(verdict.severity, Severity::Unsafe);
585    }
586
587    #[test]
588    fn water_deeper_than_the_safety_contour_is_not_reported_at_all() {
589        let deep = feature("DEPARE", &[("DRVAL1", Attribute::Real(10.0))]);
590        assert!(judge(&deep, vessel()).is_none());
591    }
592
593    #[test]
594    fn water_shallower_than_the_safety_contour_stops_the_vessel() {
595        let shoal = feature("DEPARE", &[("DRVAL1", Attribute::Real(2.0))]);
596        let verdict = judge(&shoal, vessel()).expect("a shoal is a finding");
597        assert_eq!(verdict.severity, Severity::Unsafe);
598        assert!(verdict.reason.contains("2.0 m"), "{}", verdict.reason);
599    }
600
601    #[test]
602    fn the_safety_contour_itself_is_the_boundary_and_counts_as_safe() {
603        // A depth area charted at exactly the safety contour is on the safe
604        // side of it: the contour is the line between safe and unsafe, and
605        // S-52 shades the water at that value as safe.
606        let exact = feature("DEPARE", &[("DRVAL1", Attribute::Real(3.0))]);
607        assert!(judge(&exact, vessel()).is_none());
608    }
609
610    #[test]
611    fn a_depth_area_with_no_depth_is_treated_as_drying() {
612        // CS(DEPARE03)'s own fail-safe. Read as "unknown, therefore fine"
613        // this is the single most dangerous thing the check could do.
614        let unknown = feature("DEPARE", &[]);
615        let verdict = judge(&unknown, vessel()).expect("unknown water is a finding");
616        assert_eq!(verdict.severity, Severity::Unsafe);
617        // And it must not report the fail-safe value as if it had been
618        // surveyed: "charted from -1.0 m" is a depth nobody measured.
619        assert!(verdict.reason.contains("no charted depth"), "{}", verdict.reason);
620        assert!(!verdict.reason.contains("-1.0"), "{}", verdict.reason);
621    }
622
623    #[test]
624    fn a_rock_with_no_sounding_is_a_danger_rather_than_a_shrug() {
625        let rock = feature("UWTROC", &[]);
626        let verdict = judge(&rock, vessel()).expect("an unsounded rock is a finding");
627        assert_eq!(verdict.severity, Severity::Unsafe);
628        assert!(verdict.reason.contains("unknown"), "{}", verdict.reason);
629    }
630
631    #[test]
632    fn a_wreck_below_the_safety_contour_is_passed_over() {
633        let deep_wreck = feature("WRECKS", &[("VALSOU", Attribute::Real(18.0))]);
634        assert!(judge(&deep_wreck, vessel()).is_none());
635    }
636
637    #[test]
638    fn an_area_to_know_about_is_a_caution_and_not_a_stop() {
639        let restricted = feature(
640            "RESARE",
641            &[("OBJNAM", Attribute::Text("Rt Madona".into()))],
642        );
643        let verdict = judge(&restricted, vessel()).expect("a restricted area is a finding");
644        assert_eq!(verdict.severity, Severity::Caution);
645        assert!(verdict.reason.contains("Rt Madona"), "{}", verdict.reason);
646    }
647
648    #[test]
649    fn a_class_with_no_rule_says_nothing() {
650        // Silence for classes the check has no opinion on, rather than a
651        // finding per charted object in the corridor.
652        assert!(judge(&feature("BUISGL", &[]), vessel()).is_none());
653    }
654
655    /// A finding placed at a distance along a leg, as `check_leg` builds them.
656    fn met(class: &str, along_nm: f64, depth_m: Option<f64>, reason: &str) -> Met {
657        Met {
658            finding: Finding {
659                severity: Severity::Unsafe,
660                class: class.to_owned(),
661                along_track_nm: along_nm,
662                until_nm: along_nm,
663                position: Position::new(45.5, 13.7),
664                reason: reason.to_owned(),
665            },
666            depth_m,
667            reasons: vec![reason.to_owned()],
668        }
669    }
670
671    #[test]
672    fn the_same_thing_in_neighbouring_pieces_is_one_stretch() {
673        let collapsed = collapse(vec![
674            met("LNDARE", 1.50, Some(f64::NEG_INFINITY), "land"),
675            met("LNDARE", 1.75, Some(f64::NEG_INFINITY), "land"),
676            met("LNDARE", 2.00, Some(f64::NEG_INFINITY), "land"),
677        ]);
678        assert_eq!(collapsed.len(), 1, "{collapsed:#?}");
679        assert_eq!(collapsed[0].along_track_nm, 1.50);
680        assert_eq!(collapsed[0].until_nm, 2.00);
681        assert!(collapsed[0].is_stretch());
682    }
683
684    #[test]
685    fn meeting_the_same_class_again_later_is_a_second_finding() {
686        // Two separate shoals with clear water between them are two things
687        // to know about, not one two-mile stretch that is mostly fine.
688        let collapsed = collapse(vec![
689            met("DEPARE", 1.00, Some(2.0), "shoal"),
690            met("DEPARE", 5.00, Some(2.0), "shoal"),
691        ]);
692        assert_eq!(collapsed.len(), 2, "{collapsed:#?}");
693    }
694
695    #[test]
696    fn different_classes_never_merge() {
697        let collapsed = collapse(vec![
698            met("LNDARE", 1.00, None, "land"),
699            met("DEPARE", 1.00, Some(2.0), "shoal"),
700        ]);
701        assert_eq!(collapsed.len(), 2, "{collapsed:#?}");
702    }
703
704    #[test]
705    fn the_shallowest_member_of_a_stretch_is_the_one_reported() {
706        // Crossing water charted at 2 m and then at 0.5 m, the mariner needs
707        // to hear about the 0.5.
708        let collapsed = collapse(vec![
709            met("DEPARE", 1.00, Some(2.0), "charted from 2.0 m"),
710            met("DEPARE", 1.25, Some(0.5), "charted from 0.5 m"),
711        ]);
712        assert_eq!(collapsed.len(), 1);
713        assert!(collapsed[0].reason.starts_with("charted from 0.5 m"), "{}", collapsed[0].reason);
714    }
715
716    #[test]
717    fn an_unknown_depth_outranks_every_known_one() {
718        // Water nobody has charted is worse than water charted shallow: the
719        // second has a number, the first is a question.
720        let collapsed = collapse(vec![
721            met("DEPARE", 1.00, Some(0.5), "charted from 0.5 m"),
722            met("DEPARE", 1.25, None, "no charted depth, treated as drying"),
723        ]);
724        assert_eq!(collapsed.len(), 1);
725        assert!(collapsed[0].reason.starts_with("no charted depth"), "{}", collapsed[0].reason);
726    }
727
728    #[test]
729    fn a_stretch_that_is_several_different_things_says_so() {
730        let collapsed = collapse(vec![
731            met("DEPARE", 1.00, Some(2.0), "charted from 2.0 m"),
732            met("DEPARE", 1.25, Some(0.5), "charted from 0.5 m"),
733        ]);
734        assert!(collapsed[0].reason.ends_with("+1 more"), "{}", collapsed[0].reason);
735    }
736
737    #[test]
738    fn a_stretch_that_is_the_same_thing_throughout_stays_quiet_about_it() {
739        let collapsed = collapse(vec![
740            met("LNDARE", 1.00, Some(f64::NEG_INFINITY), "land"),
741            met("LNDARE", 1.25, Some(f64::NEG_INFINITY), "land"),
742        ]);
743        assert_eq!(collapsed[0].reason, "land");
744    }
745
746    #[test]
747    fn a_class_with_no_rule_is_never_queried() {
748        assert!(has_rule("DEPARE"));
749        assert!(has_rule("LNDARE"));
750        assert!(!has_rule("SOUNDG"));
751        assert!(!has_rule("BUISGL"));
752    }
753
754    #[test]
755    fn severity_sorts_worst_first() {
756        let mut severities = [
757            Severity::Caution,
758            Severity::Coarse,
759            Severity::Unsurveyed,
760            Severity::Unsafe,
761        ];
762        severities.sort();
763        assert_eq!(
764            severities,
765            [
766                Severity::Unsafe,
767                Severity::Unsurveyed,
768                Severity::Coarse,
769                Severity::Caution
770            ]
771        );
772    }
773
774    #[test]
775    fn half_a_millimetre_at_chart_scale_is_what_a_position_is_worth() {
776        // The two ends of the range this actually meets: an overview cell
777        // and a harbour plan.
778        assert!((accuracy_m(1_500_000.0) - 750.0).abs() < 1e-9);
779        assert!((accuracy_m(12_000.0) - 6.0).abs() < 1e-9);
780    }
781
782    #[test]
783    fn a_harbour_plan_answers_a_narrow_corridor_and_an_overview_chart_does_not() {
784        // The default corridor, fifty metres either side.
785        let corridor_m = 0.05 * METRES_PER_NM;
786
787        assert_eq!(too_coarse(Some(12_000.0), corridor_m), None);
788        assert_eq!(too_coarse(Some(90_000.0), corridor_m), None);
789        assert_eq!(
790            too_coarse(Some(1_500_000.0), corridor_m),
791            Some(Some(1_500_000.0))
792        );
793    }
794
795    #[test]
796    fn a_wide_corridor_forgives_a_coarser_chart() {
797        // The question is not "is this chart detailed" but "is it detailed
798        // enough for what was asked". A mile of tolerance either side is
799        // answerable from a coastal chart.
800        let corridor_m = 1.0 * METRES_PER_NM;
801        assert_eq!(too_coarse(Some(1_500_000.0), corridor_m), None);
802    }
803
804    #[test]
805    fn a_chart_that_does_not_state_its_scale_is_not_assumed_to_be_a_good_one() {
806        assert_eq!(too_coarse(None, 10_000.0), Some(None));
807    }
808
809    #[test]
810    fn the_coarsest_chart_along_a_stretch_is_the_one_reported() {
811        assert_eq!(worse_scale(Some(12_000.0), Some(90_000.0)), Some(90_000.0));
812        // And an unstated scale outranks every number, in both directions.
813        assert_eq!(worse_scale(Some(1_500_000.0), None), None);
814        assert_eq!(worse_scale(None, Some(12_000.0)), None);
815    }
816}