1mod bench;
38
39use std::path::{Path, PathBuf};
40use std::process::ExitCode;
41use std::time::Instant;
42
43use clap::{Parser, Subcommand};
44use enc_store::{ChartStore, Finding, Leg, Severity, Vessel, check_leg};
45use nav_math::{Position, rhumb};
46use route_check::{Defaults, RouteReport, check_route};
47use route_find::{FindOptions, bake_land_cache, find_dock_to_dock_route, find_gateway, find_gateway_route, find_route};
48use routes::{Route, Waypoint};
49
50#[derive(Parser, Debug)]
51#[command(about = "Check routes against a chart GeoPackage", version)]
52struct Options {
53 #[command(subcommand)]
54 command: Command,
55}
56
57#[derive(Subcommand, Debug)]
58enum Command {
59 Leg {
61 #[command(flatten)]
62 chart: ChartArgs,
63
64 #[arg(long, value_name = "LAT,LON", value_parser = position)]
66 from: Position,
67
68 #[arg(long, value_name = "LAT,LON", value_parser = position)]
70 to: Position,
71 },
72
73 Route {
75 #[command(flatten)]
76 chart: ChartArgs,
77
78 #[arg(long, value_name = "NAME")]
80 name: Option<String>,
81
82 #[arg(long = "waypoint", value_name = "LAT,LON", value_parser = position,
84 num_args = 1.., required = true)]
85 waypoints: Vec<Position>,
86 },
87
88 Find {
91 #[command(flatten)]
92 chart: ChartArgs,
93
94 #[arg(long, value_name = "LAT,LON", value_parser = position)]
96 from: Position,
97
98 #[arg(long, value_name = "LAT,LON", value_parser = position)]
100 to: Position,
101
102 #[arg(long, value_name = "NM")]
108 margin: Option<f64>,
109
110 #[arg(long, value_name = "NM")]
113 cell: Option<f64>,
114
115 #[arg(long, value_name = "FILE")]
118 gpx: Option<PathBuf>,
119
120 #[arg(long)]
125 avoid_restricted: bool,
126 },
127
128 Gateway {
134 #[command(flatten)]
135 chart: ChartArgs,
136
137 #[arg(long, value_name = "LAT,LON", value_parser = position)]
139 harbor: Position,
140
141 #[arg(long)]
144 avoid_restricted: bool,
145
146 #[arg(long)]
152 route: bool,
153 },
154
155 DockToDock {
160 #[command(flatten)]
161 chart: ChartArgs,
162
163 #[arg(long, value_name = "LAT,LON", value_parser = position)]
167 from: Position,
168
169 #[arg(long, value_name = "LAT,LON", value_parser = position)]
171 to: Position,
172
173 #[arg(long)]
176 avoid_restricted: bool,
177 },
178
179 Bake {
192 #[arg(long, value_name = "FILE")]
194 chart: PathBuf,
195
196 #[arg(long, value_name = "FILE")]
199 out: Option<PathBuf>,
200 },
201
202 Bench {
213 #[command(flatten)]
214 chart: ChartArgs,
215
216 #[arg(long)]
220 avoid_restricted: bool,
221
222 #[arg(long, value_name = "FILE", default_value = concat!(env!("CARGO_MANIFEST_DIR"), "/bench-baseline.tsv"))]
230 baseline: PathBuf,
231
232 #[arg(long)]
236 write_baseline: bool,
237 },
238}
239
240#[derive(clap::Args, Debug)]
242struct ChartArgs {
243 #[arg(long, value_name = "FILE")]
245 chart: PathBuf,
246
247 #[arg(long, value_name = "METRES", default_value_t = 3.0)]
250 safety_contour: f64,
251
252 #[arg(long, value_name = "NM", default_value_t = 0.05)]
255 corridor: f64,
256}
257
258fn main() -> ExitCode {
259 match Options::parse().command {
260 Command::Leg { chart, from, to } => run_leg(&chart, from, to),
261 Command::Route { chart, name, waypoints } => run_route(&chart, name.as_deref(), &waypoints),
262 Command::Find { chart, from, to, margin, cell, gpx, avoid_restricted } => {
263 run_find(&chart, from, to, margin, cell, gpx.as_deref(), avoid_restricted)
264 }
265 Command::Gateway { chart, harbor, avoid_restricted, route } => run_gateway(&chart, harbor, avoid_restricted, route),
266 Command::DockToDock { chart, from, to, avoid_restricted } => run_dock_to_dock(&chart, from, to, avoid_restricted),
267 Command::Bake { chart, out } => run_bake(&chart, out.as_deref()),
268 Command::Bench { chart, avoid_restricted, baseline, write_baseline } => {
269 run_bench(&chart, avoid_restricted, &baseline, write_baseline)
270 }
271 }
272}
273
274fn run_leg(chart: &ChartArgs, from: Position, to: Position) -> ExitCode {
275 let store = match ChartStore::open(&chart.chart) {
276 Ok(store) => store,
277 Err(error) => return fail(&error),
278 };
279
280 let leg = Leg::symmetric(from, to, chart.corridor);
281 let vessel = Vessel {
282 safety_contour_m: chart.safety_contour,
283 };
284 let findings = match check_leg(&store, leg, vessel) {
285 Ok(findings) => findings,
286 Err(error) => return fail(&error),
287 };
288
289 println!(
290 "{:.2} NM on {:03.0} degrees, corridor {:.2} NM either side, safety contour {:.1} m",
291 leg.length_nm(),
292 leg.course_at(leg.from, 0.0),
293 leg.narrowest_xtd_nm(),
294 vessel.safety_contour_m,
295 );
296 if findings.is_empty() {
297 println!("nothing found in the corridor");
298 }
299 for finding in &findings {
300 println!("{}", line(finding, 0.0));
301 }
302
303 verdict(findings.iter())
304}
305
306fn run_route(chart: &ChartArgs, name: Option<&str>, waypoints: &[Position]) -> ExitCode {
307 let charts = match ChartStore::open(&chart.chart) {
308 Ok(store) => store,
309 Err(error) => return fail(&error),
310 };
311
312 let mut route = Route::new("enc-check", waypoints.iter().copied().map(Waypoint::at).collect());
316 route.name = name.map(str::to_owned);
317
318 let defaults = Defaults {
319 safety_contour_m: chart.safety_contour,
320 port_xtd_nm: chart.corridor,
321 starboard_xtd_nm: chart.corridor,
322 };
323 let report = match check_route(&charts, &route, defaults) {
324 Ok(report) => report,
325 Err(error) => return fail(&error),
326 };
327
328 print_report(&report);
329 verdict(report.legs.iter().flat_map(|leg| leg.findings.iter()))
330}
331
332fn run_find(
333 chart: &ChartArgs,
334 from: Position,
335 to: Position,
336 margin_nm: Option<f64>,
337 cell_nm: Option<f64>,
338 gpx: Option<&Path>,
339 avoid_restricted: bool,
340) -> ExitCode {
341 let store = match ChartStore::open(&chart.chart) {
342 Ok(store) => store,
343 Err(error) => return fail(&error),
344 };
345
346 let options = FindOptions {
347 safety_contour_m: chart.safety_contour,
348 port_xtd_nm: chart.corridor,
349 starboard_xtd_nm: chart.corridor,
350 margin_nm,
351 cell_nm,
352 avoid_restricted_areas: avoid_restricted,
353 };
354
355 let waypoints = match find_route(&store, from, to, options) {
356 Ok(Some(waypoints)) => waypoints,
357 Ok(None) => {
358 match margin_nm {
359 Some(margin_nm) => println!("no safe route found within {margin_nm:.1} NM of the direct line"),
360 None => println!("no safe route found, even after widening the search margin on its own"),
361 }
362 return ExitCode::from(2);
363 }
364 Err(error) => return fail(&error),
365 };
366
367 let distance_nm: f64 = waypoints.windows(2).map(|pair| rhumb::distance_nm(pair[0].position, pair[1].position)).sum();
368 println!("{} waypoint(s), {distance_nm:.2} NM", waypoints.len());
369 for (index, waypoint) in waypoints.iter().enumerate() {
370 println!(" {}: {}", index + 1, format_position(waypoint.position));
371 }
372
373 if let Some(path) = gpx {
374 if let Err(error) = write_gpx(path, &waypoints) {
375 eprintln!("enc-check: writing {}: {error}", path.display());
376 return ExitCode::FAILURE;
377 }
378 println!("wrote {}", path.display());
379 }
380
381 ExitCode::SUCCESS
382}
383
384fn run_gateway(chart: &ChartArgs, harbor: Position, avoid_restricted: bool, route: bool) -> ExitCode {
385 let store = match ChartStore::open(&chart.chart) {
386 Ok(store) => store,
387 Err(error) => return fail(&error),
388 };
389
390 let options = FindOptions {
391 safety_contour_m: chart.safety_contour,
392 port_xtd_nm: chart.corridor,
393 starboard_xtd_nm: chart.corridor,
394 margin_nm: None,
395 cell_nm: None,
396 avoid_restricted_areas: avoid_restricted,
397 };
398
399 if route {
400 return match find_gateway_route(&store, harbor, &options) {
401 Ok(Some(waypoints)) => {
402 let distance_nm: f64 = waypoints.windows(2).map(|pair| rhumb::distance_nm(pair[0], pair[1])).sum();
403 println!("{} waypoint(s), {distance_nm:.2} NM", waypoints.len());
404 for (index, position) in waypoints.iter().enumerate() {
405 println!(" {}: {}", index + 1, format_position(*position));
406 }
407 ExitCode::SUCCESS
408 }
409 Ok(None) => {
410 println!("no gateway route found within reach of {}", format_position(harbor));
411 ExitCode::from(2)
412 }
413 Err(error) => fail(&error),
414 };
415 }
416
417 match find_gateway(&store, harbor, &options) {
418 Ok(Some(gateway)) if gateway == harbor => {
419 println!("{} is already clear -- no gateway needed", format_position(harbor));
420 }
421 Ok(Some(gateway)) => {
422 let distance_nm = rhumb::distance_nm(harbor, gateway);
423 println!("{} ({distance_nm:.2} NM from {})", format_position(gateway), format_position(harbor));
424 }
425 Ok(None) => {
426 println!("no gateway found within reach of {}", format_position(harbor));
427 return ExitCode::from(2);
428 }
429 Err(error) => return fail(&error),
430 }
431
432 ExitCode::SUCCESS
433}
434
435fn run_dock_to_dock(chart: &ChartArgs, from: Position, to: Position, avoid_restricted: bool) -> ExitCode {
436 let store = match ChartStore::open(&chart.chart) {
437 Ok(store) => store,
438 Err(error) => return fail(&error),
439 };
440
441 let options = FindOptions {
442 safety_contour_m: chart.safety_contour,
443 port_xtd_nm: chart.corridor,
444 starboard_xtd_nm: chart.corridor,
445 margin_nm: None,
446 cell_nm: None,
447 avoid_restricted_areas: avoid_restricted,
448 };
449
450 match find_dock_to_dock_route(&store, from, to, &options) {
451 Ok(Some(waypoints)) => {
452 let distance_nm: f64 = waypoints.windows(2).map(|pair| rhumb::distance_nm(pair[0], pair[1])).sum();
453 println!("{} waypoint(s), {distance_nm:.2} NM", waypoints.len());
454 for (index, position) in waypoints.iter().enumerate() {
455 println!(" {}: {}", index + 1, format_position(*position));
456 }
457 ExitCode::SUCCESS
458 }
459 Ok(None) => {
460 println!("no dock-to-dock passage found between {} and {}", format_position(from), format_position(to));
461 ExitCode::from(2)
462 }
463 Err(error) => fail(&error),
464 }
465}
466
467fn run_bake(chart: &Path, out: Option<&Path>) -> ExitCode {
468 let store = match ChartStore::open(chart) {
469 Ok(store) => store,
470 Err(error) => return fail(&error),
471 };
472
473 match bake_land_cache(&store, out) {
474 Ok(path) => {
475 println!("wrote {}", path.display());
476 ExitCode::SUCCESS
477 }
478 Err(error) => fail(&error),
479 }
480}
481
482fn run_bench(chart: &ChartArgs, avoid_restricted: bool, baseline_path: &Path, write_baseline: bool) -> ExitCode {
483 let store = match ChartStore::open(&chart.chart) {
484 Ok(store) => store,
485 Err(error) => return fail(&error),
486 };
487 let options = FindOptions {
488 safety_contour_m: chart.safety_contour,
489 port_xtd_nm: chart.corridor,
490 starboard_xtd_nm: chart.corridor,
491 margin_nm: None,
492 cell_nm: None,
493 avoid_restricted_areas: avoid_restricted,
494 };
495
496 let baseline = if write_baseline {
500 bench::Baseline::new()
501 } else {
502 match bench::load_baseline(baseline_path) {
503 Ok(baseline) => baseline,
504 Err(error) => {
505 eprintln!("enc-check: reading baseline {}: {error}", baseline_path.display());
506 return ExitCode::FAILURE;
507 }
508 }
509 };
510
511 println!("{:<38} {:>10} {:>4} {:>10} {:>10} ", "case", "direct NM", "wp", "route NM", "time");
512 let mut all_found = true;
513 let mut any_regressed = false;
514 let mut measured: Vec<(String, bench::BaselineEntry)> = Vec::new();
515 for case in bench::REFERENCE_ROUTES {
516 let direct_nm = rhumb::distance_nm(case.from, case.to);
517 let start = Instant::now();
518 let result = find_route(&store, case.from, case.to, options);
519 let elapsed = start.elapsed();
520
521 match result {
522 Ok(Some(waypoints)) => {
523 let route_nm: f64 = waypoints.windows(2).map(|pair| rhumb::distance_nm(pair[0].position, pair[1].position)).sum();
524 let elapsed_ms = elapsed.as_millis();
525
526 let note = match baseline.get(case.name) {
527 _ if write_baseline => String::new(),
528 None => " (no baseline)".to_string(),
529 Some(entry) => {
530 let dist_regressed = bench::distance_regressed(entry.route_nm, route_nm);
531 let time_regressed = bench::time_regressed(entry.elapsed_ms, elapsed_ms);
532 if dist_regressed || time_regressed {
533 any_regressed = true;
534 format!(
535 " REGRESSED (baseline {:.2} NM / {} ms){}{}",
536 entry.route_nm,
537 entry.elapsed_ms,
538 if dist_regressed { ", length" } else { "" },
539 if time_regressed { ", time" } else { "" },
540 )
541 } else {
542 String::new()
543 }
544 }
545 };
546 println!(
547 "{:<38} {direct_nm:>10.2} {:>4} {route_nm:>10.2} {elapsed:>10.2?}{note}",
548 case.name,
549 waypoints.len()
550 );
551 measured.push((case.name.to_string(), bench::BaselineEntry { route_nm, elapsed_ms }));
552 }
553 Ok(None) => {
554 all_found = false;
555 println!("{:<38} {direct_nm:>10.2} {:>4} {:>10} {elapsed:>10.2?} NO ROUTE FOUND", case.name, "-", "-");
556 }
557 Err(error) => {
558 all_found = false;
559 println!("{:<38} error: {error}", case.name);
560 }
561 }
562 }
563
564 if write_baseline {
565 if let Err(error) = bench::write_baseline(baseline_path, &measured) {
566 eprintln!("enc-check: writing baseline {}: {error}", baseline_path.display());
567 return ExitCode::FAILURE;
568 }
569 println!("wrote baseline to {}", baseline_path.display());
570 }
571
572 if all_found && !any_regressed { ExitCode::SUCCESS } else { ExitCode::FAILURE }
573}
574
575fn write_gpx(path: &Path, waypoints: &[Waypoint]) -> std::io::Result<()> {
584 let mut gpx = String::from(
585 "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
586 <gpx version=\"1.1\" creator=\"nav-core route-find\" \
587 xmlns=\"http://www.topografix.com/GPX/1/1\" \
588 xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" \
589 xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 \
590 http://www.topografix.com/GPX/1/1/gpx.xsd\">\n\
591 <rte>\n <name>route-find</name>\n",
592 );
593 for (index, waypoint) in waypoints.iter().enumerate() {
594 gpx.push_str(&format!(
595 " <rtept lat=\"{:.7}\" lon=\"{:.7}\"><name>WP{:02}</name></rtept>\n",
596 waypoint.position.lat_deg,
597 waypoint.position.lon_deg,
598 index + 1
599 ));
600 }
601 gpx.push_str("</rte>\n</gpx>\n");
602 std::fs::write(path, gpx)
603}
604
605fn print_report(report: &RouteReport) {
606 println!(
607 "{} -- {:.2} NM over {} leg(s)",
608 report.name.as_deref().unwrap_or("(unnamed)"),
609 report.distance_nm,
610 report.legs.len(),
611 );
612
613 for leg in &report.legs {
614 let named = leg
615 .to_name
616 .as_deref()
617 .map_or_else(String::new, |name| format!(" to {name}"));
618 println!(
619 "\nleg {}{}: {:.2} NM, from {:.2} NM along the route",
620 leg.index + 1,
621 named,
622 leg.length_nm,
623 leg.starts_at_nm,
624 );
625 if leg.findings.is_empty() {
626 println!(" nothing found in the corridor");
627 }
628 for finding in &leg.findings {
629 println!(" {}", line(finding, leg.starts_at_nm));
630 }
631 }
632}
633
634fn line(finding: &Finding, leg_starts_at_nm: f64) -> String {
636 let mark = match finding.severity {
637 Severity::Unsafe => "UNSAFE",
638 Severity::Unsurveyed => "NODATA",
639 Severity::Coarse => "COARSE",
640 Severity::Caution => "note ",
641 };
642 let along = if finding.is_stretch() {
643 format!("{:.2}-{:.2} NM", finding.along_track_nm, finding.until_nm)
644 } else {
645 format!("{:.2} NM", finding.along_track_nm)
646 };
647 let on_route = if leg_starts_at_nm > 0.0 {
648 format!(" (route {:.2} NM)", leg_starts_at_nm + finding.along_track_nm)
649 } else {
650 String::new()
651 };
652
653 format!(
654 "{mark} {along:>15}{on_route} {} {:<7} {}",
655 format_position(finding.position),
656 finding.class,
657 finding.reason,
658 )
659}
660
661fn verdict<'a>(findings: impl Iterator<Item = &'a Finding>) -> ExitCode {
663 let mut worst: Option<Severity> = None;
664 for finding in findings {
665 worst = Some(worst.map_or(finding.severity, |seen| seen.min(finding.severity)));
666 }
667
668 match worst {
669 Some(Severity::Unsafe) => ExitCode::FAILURE,
670 Some(Severity::Unsurveyed | Severity::Coarse) => ExitCode::from(2),
671 _ => ExitCode::SUCCESS,
672 }
673}
674
675fn fail(error: &dyn std::error::Error) -> ExitCode {
676 eprintln!("enc-check: {error}");
677 ExitCode::FAILURE
678}
679
680fn position(text: &str) -> Result<Position, String> {
682 let (lat, lon) = text
683 .split_once(',')
684 .ok_or_else(|| format!("expected lat,lon but got {text:?}"))?;
685 let lat: f64 = lat
686 .trim()
687 .parse()
688 .map_err(|_| format!("{lat:?} is not a latitude"))?;
689 let lon: f64 = lon
690 .trim()
691 .parse()
692 .map_err(|_| format!("{lon:?} is not a longitude"))?;
693
694 let position = Position::new(lat, lon);
695 if !position.is_valid() {
696 return Err(format!("{lat}, {lon} is not a position on earth"));
697 }
698 Ok(position)
699}
700
701fn format_position(position: Position) -> String {
703 format!(
704 "{}{} {}{}",
705 degrees_minutes(position.lat_deg.abs(), 2),
706 if position.lat_deg < 0.0 { 'S' } else { 'N' },
707 degrees_minutes(position.lon_deg.abs(), 3),
708 if position.lon_deg < 0.0 { 'W' } else { 'E' },
709 )
710}
711
712fn degrees_minutes(value: f64, width: usize) -> String {
713 let degrees = value.trunc();
714 let minutes = (value - degrees) * 60.0;
715 format!("{degrees:0width$.0}\u{00b0}{minutes:05.2}\u{2032}")
716}