1use 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#[derive(Debug, Clone, Copy, PartialEq)]
26pub struct Vessel {
27 pub safety_contour_m: f64,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39pub enum Severity {
40 Unsafe,
43 Unsurveyed,
46 Coarse,
51 Caution,
54}
55
56const PLOTTING_ACCURACY_MM: f64 = 0.5;
64
65#[must_use]
67fn accuracy_m(compilation_scale: f64) -> f64 {
68 compilation_scale * PLOTTING_ACCURACY_MM / 1000.0
69}
70
71#[derive(Debug, Clone, PartialEq)]
73pub struct Finding {
74 pub severity: Severity,
76 pub class: String,
78 pub along_track_nm: f64,
80 pub until_nm: f64,
87 pub position: Position,
91 pub reason: String,
93}
94
95impl Finding {
96 #[must_use]
98 pub fn is_stretch(&self) -> bool {
99 self.until_nm > self.along_track_nm
100 }
101}
102
103const ALWAYS_UNSAFE: [&str; 1] = ["LNDARE"];
105
106const DEPTH_AREAS: [&str; 2] = ["DEPARE", "DRGARE"];
112
113const SOUNDED_DANGERS: [&str; 3] = ["OBSTRN", "UWTROC", "WRECKS"];
115
116const CAUTIONS: [&str; 6] = ["BRIDGE", "RESARE", "TSSLPT", "ACHARE", "CBLARE", "PIPARE"];
118
119const COVERAGE: &str = "M_COVR";
121
122const SCALE_ATTRIBUTE: &str = "COMPILATION_SCALE";
130
131pub 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 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
197struct Verdict {
199 severity: Severity,
200 reason: String,
201 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
225struct Met {
227 finding: Finding,
228 depth_m: Option<f64>,
229 reasons: Vec<String>,
233}
234
235fn 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
243fn 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
276fn adjoins(open: &Met, next: &Met) -> bool {
278 open.finding.class == next.finding.class
279 && open.finding.severity == next.finding.severity
280 && next.finding.along_track_nm <= open.finding.until_nm + crate::corridor::SEGMENT_NM + 1e-9
283}
284
285fn absorb(open: &mut Met, next: Met) {
287 open.finding.until_nm = open.finding.until_nm.max(next.finding.until_nm);
288
289 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
307fn 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 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 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 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
368fn 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 .filter(|feature| feature.number("CATCOV").unwrap_or(1.0) == 1.0)
394 .collect();
395
396 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 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 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 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
466struct Run {
469 start_nm: f64,
470 start: Position,
471 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 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
521fn metres(value: f64) -> String {
527 if value >= 10.0 {
528 format!("{value:.0} m")
529 } else {
530 format!("{value:.1} m")
531 }
532}
533
534fn 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
548fn 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 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 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 let unknown = feature("DEPARE", &[]);
615 let verdict = judge(&unknown, vessel()).expect("unknown water is a finding");
616 assert_eq!(verdict.severity, Severity::Unsafe);
617 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 assert!(judge(&feature("BUISGL", &[]), vessel()).is_none());
653 }
654
655 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 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 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 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 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 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 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 assert_eq!(worse_scale(Some(1_500_000.0), None), None);
814 assert_eq!(worse_scale(None, Some(12_000.0)), None);
815 }
816}