navcore_route_find/lib.rs
1//! Automatic route generation: the shortest path [`enc_store::check_leg`]
2//! calls safe between two positions.
3//!
4//! Safety decisions belong to [`enc_store::check_leg`], the same function
5//! [`route_check`](../route_check) uses to validate a route a human drew.
6//! This crate generates candidate waypoints and searches them:
7//!
8//! 1. `grid` lays a lattice of candidate positions over the area between
9//! `from` and `to`, and `hazards` marks which of them a vessel cannot
10//! stand on, from a single chart query rather than one per candidate.
11//! 2. `search` finds the shortest safe path over that lattice with A*.
12//! 3. `smooth` straightens the resulting zig-zag into a short waypoint
13//! list and calls `check_leg` on every straightened leg, so every leg
14//! this crate hands back is approved at the vessel's real corridor,
15//! not the lattice's approximation of it.
16//!
17//! The lattice, hazard test and search generalize to weather routing:
18//! only the edge cost (distance here) would change to sailing time under
19//! a wind forecast.
20
21#![forbid(unsafe_code)]
22
23mod gateway;
24mod grid;
25mod hazards;
26mod land_cache;
27mod search;
28mod smooth;
29mod visibility;
30
31pub use gateway::{find_dock_to_dock_route, find_gateway, find_gateway_route};
32pub use land_cache::{LandCacheError, bake as bake_land_cache};
33
34use std::fmt;
35
36use geo::{Coord, Rect};
37use nav_math::{Position, rhumb};
38
39use enc_store::{ChartStore, StoreError, Vessel};
40use grid::Grid;
41use hazards::Hazards;
42use routes::Waypoint;
43
44/// What a route is being planned for, and how far around the direct line
45/// to search.
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct FindOptions {
48 /// The vessel's safety contour, in metres -- the same setting
49 /// `enc_store::Vessel` takes, and the same one the chart is drawn with.
50 pub safety_contour_m: f64,
51 /// How far the vessel may stray to port of any leg the route ends up
52 /// with, in nautical miles.
53 pub port_xtd_nm: f64,
54 /// The same to starboard.
55 pub starboard_xtd_nm: f64,
56 /// How far beyond the direct line between `from` and `to` the search
57 /// is allowed to reach, in nautical miles. `Some` is tried exactly
58 /// once, at the given value, and not escalated further. `None`
59 /// auto-scales the margin from the direct distance and escalates it
60 /// a few times before giving up if the search finds nothing (see
61 /// `margin_schedule`).
62 pub margin_nm: Option<f64>,
63 /// Lattice spacing, in nautical miles. `None` auto-scales it from the
64 /// direct distance, so a long passage does not blow up the lattice
65 /// size and a short one is not searched more coarsely than it needs.
66 pub cell_nm: Option<f64>,
67 /// Whether a restricted area (`RESARE` -- a marine reserve, a firing
68 /// range, or another area a mariner must stay out of, not merely be
69 /// cautioned about) blocks the route outright rather than only being
70 /// crossed and reported. Off by default, the same posture `check_leg`
71 /// itself takes: most restricted areas restrict anchoring or require
72 /// a licence rather than prohibiting transit, and only the mariner
73 /// planning the route knows which applies.
74 pub avoid_restricted_areas: bool,
75}
76
77/// Everything that can go wrong finding a route.
78#[derive(Debug)]
79pub enum FindError {
80 /// The chart could not be read.
81 Store(StoreError),
82}
83
84impl fmt::Display for FindError {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 match self {
87 Self::Store(error) => write!(f, "chart: {error}"),
88 }
89 }
90}
91
92impl std::error::Error for FindError {
93 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
94 match self {
95 Self::Store(error) => Some(error),
96 }
97 }
98}
99
100impl From<StoreError> for FindError {
101 fn from(error: StoreError) -> Self {
102 Self::Store(error)
103 }
104}
105
106/// The shortest route between `from` and `to` that `check_leg` calls safe
107/// for the vessel described in `options`.
108///
109/// `Ok(None)` when no route was found even after `margin_schedule`'s
110/// escalation (or, with an explicit `margin_nm`, after that one attempt).
111/// This means either the route is genuinely blocked (an island fills the
112/// whole search area) or the lattice is too coarse to find a way through
113/// (see `smooth::straighten`'s own doc). A caller that supplied
114/// `margin_nm` can retry with a larger value; auto-escalation has
115/// already been tried when `margin_nm` was `None`.
116///
117/// The returned waypoints carry no identity: turning them into a
118/// [`routes::Route`] needs a fresh id, and this layer has no source of
119/// randomness, the same reason `nav-math` has no clock.
120///
121/// # Errors
122///
123/// If the chart cannot be read.
124pub fn find_route(
125 chart: &ChartStore,
126 from: Position,
127 to: Position,
128 options: FindOptions,
129) -> Result<Option<Vec<Waypoint>>, FindError> {
130 let direct_distance_nm = rhumb::distance_nm(from, to);
131 if let Some(route) = lattice_route(chart, from, to, &options, direct_distance_nm)? {
132 return Ok(Some(route));
133 }
134
135 // Only in auto mode: an explicit `margin_nm` is a caller's own "is N
136 // NM enough", and reaching for a materially more expensive search
137 // past it would quietly answer a different question than the one
138 // asked -- see `margin_schedule`'s own doc for the same rule applied
139 // to the lattice's escalation.
140 if options.margin_nm.is_none() {
141 if let Some(route) = attempt_around(chart, from, to, &options, direct_distance_nm)? {
142 return Ok(Some(route));
143 }
144 }
145
146 Ok(None)
147}
148
149/// [`margin_schedule`]'s escalation, run on its own. Both `find_route`
150/// and each macro-hop inside [`attempt_around`] call this directly,
151/// rather than `attempt_around` calling itself: a macro-hop is already a
152/// piece of a route `attempt_around` chose specifically to avoid a
153/// detour this escalation cannot reach, so a nested `attempt_around`
154/// call on a shorter leg would only turn one visibility-graph search
155/// into a cascade of them, each fetching and solving its own graph over
156/// a search area that does not shrink as fast as the leg does. A
157/// macro-hop that cannot find a lattice route on its own is reported as
158/// a failure of the fallback as a whole, not retried further.
159fn lattice_route(
160 chart: &ChartStore,
161 from: Position,
162 to: Position,
163 options: &FindOptions,
164 direct_distance_nm: f64,
165) -> Result<Option<Vec<Waypoint>>, FindError> {
166 for margin_nm in margin_schedule(direct_distance_nm, options.margin_nm) {
167 if let Some(route) = attempt(chart, from, to, options, margin_nm)? {
168 return Ok(Some(route));
169 }
170 }
171 Ok(None)
172}
173
174/// One search at a fixed `margin_nm`, the unit [`find_route`]'s own
175/// margin retry schedule calls repeatedly.
176fn attempt(
177 chart: &ChartStore,
178 from: Position,
179 to: Position,
180 options: &FindOptions,
181 margin_nm: f64,
182) -> Result<Option<Vec<Waypoint>>, FindError> {
183 let vessel = Vessel { safety_contour_m: options.safety_contour_m };
184 let area = search_area(from, to, margin_nm);
185 let cell_nm = options
186 .cell_nm
187 .unwrap_or_else(|| auto_cell_nm(rhumb::distance_nm(from, to), margin_nm));
188
189 let grid = Grid::covering(area, cell_nm);
190 let hazards = Hazards::fetch(chart, area, options.avoid_restricted_areas)?;
191 // The corridor's own half-width, not the cell spacing: the coarse
192 // search has to know how much room a real leg through a node would
193 // need either side of it, or a hazard just off to one side clears
194 // every point test and only turns up later, in smooth::straighten's
195 // real check_leg calls, as a route that got this far and then found
196 // no safe step at all.
197 let radius_nm = options.port_xtd_nm.max(options.starboard_xtd_nm);
198
199 // `from`/`to` themselves, tested directly rather than through
200 // whichever lattice node they happen to snap to. A coarser cell size
201 // (which a wider auto-escalated margin produces, since auto_cell_nm
202 // scales with it) can snap an otherwise clear point onto a node close
203 // enough to a real hazard to test blocked -- a rounding artefact of
204 // the lattice, not a fact about the position the caller actually
205 // asked for. This is the one place in the search that has to answer
206 // for the exact position rather than the lattice's approximation of
207 // it.
208 if hazards.blocked(from, radius_nm, vessel) || hazards.blocked(to, radius_nm, vessel) {
209 return Ok(None);
210 }
211
212 let start = grid.nearest(from);
213 let goal = grid.nearest(to);
214 // Beyond that direct check, the start/goal *nodes* are exempted from
215 // their own lattice point test for the same reason: they stand in
216 // for `from`/`to`, already found clear above, not for whatever
217 // lies exactly at the node's own rounded position.
218 //
219 // The node test itself runs against a land-simplified copy, not
220 // `hazards` -- see `Hazards::with_simplified_land`'s own doc for why
221 // that is safe here: `smooth::straighten` below re-validates every
222 // real leg of the final route against the exact chart regardless, so
223 // this coarse test only ever decides which candidate nodes the A*
224 // search bothers to consider.
225 let coarse = hazards.with_simplified_land();
226 let blocked = |node: (usize, usize)| {
227 node != start
228 && node != goal
229 && coarse.blocked(grid.position(node.0, node.1), radius_nm, vessel)
230 };
231 let Some(raw_path) = search::shortest_path(&grid, start, goal, blocked) else {
232 return Ok(None);
233 };
234
235 let mut positions: Vec<Position> =
236 raw_path.iter().map(|&(row, col)| grid.position(row, col)).collect();
237 // The promised route runs from `from` to `to`, not from whichever
238 // lattice node happened to be nearest them; `smooth::straighten`
239 // re-validates every leg it touches anyway, so substituting the real
240 // endpoints costs nothing and the first and last legs it checks are
241 // the ones that will actually be sailed.
242 if let Some(first) = positions.first_mut() {
243 *first = from;
244 }
245 if let Some(last) = positions.last_mut() {
246 *last = to;
247 }
248
249 // `coarse`, not `hazards`: `Safety::is_clear`'s own cheap pre-filter
250 // runs once per candidate leg `straighten` considers, and land is the
251 // one part of it simplification cannot get meaningfully wrong for
252 // this purpose -- see `Hazards::with_simplified_land`'s own doc.
253 let safety = smooth::Safety { chart, hazards: &coarse };
254 let straightened =
255 smooth::straighten(&positions, options.port_xtd_nm, options.starboard_xtd_nm, vessel, &safety)?;
256 Ok(straightened.map(|path| path.into_iter().map(Waypoint::at).collect()))
257}
258
259/// How far beyond the direct line [`attempt_around`] fetches hazards
260/// from, as a multiple of the direct distance.
261///
262/// Set generous: this fallback exists for a detour the lattice's much
263/// narrower escalation cannot reach (see `visibility`'s own doc), such
264/// as a peninsula or a gulf whose far shore is the real way round.
265/// [`visibility::MAX_VERTICES`] bounds the resulting search's cost, not
266/// this constant.
267const VISIBILITY_MARGIN_FRACTION: f64 = 1.0;
268
269/// The fallback [`find_route`] uses once every attempt in
270/// `margin_schedule` has failed: a visibility graph over land obstacles
271/// found in a much wider area than the lattice searches, for a route
272/// whose detour is significantly wider than the direct line.
273fn attempt_around(
274 chart: &ChartStore,
275 from: Position,
276 to: Position,
277 options: &FindOptions,
278 direct_distance_nm: f64,
279) -> Result<Option<Vec<Waypoint>>, FindError> {
280 let vessel = Vessel { safety_contour_m: options.safety_contour_m };
281 let area = search_area(from, to, direct_distance_nm * VISIBILITY_MARGIN_FRACTION);
282 let hazards = Hazards::fetch(chart, area, options.avoid_restricted_areas)?;
283
284 // The same direct, exact-position check `attempt` already makes for
285 // every margin it tries: if `from` or `to` itself is blocked, no
286 // graph over any obstacle *between* them is going to change that, and
287 // building one anyway would pay this fallback's own, much wider
288 // fetch and search cost to confirm what this already knows for free.
289 let radius_nm = options.port_xtd_nm.max(options.starboard_xtd_nm);
290 if hazards.blocked(from, radius_nm, vessel) || hazards.blocked(to, radius_nm, vessel) {
291 return Ok(None);
292 }
293
294 let Some(waypoints) =
295 visibility::find_path(&hazards, from, to, vessel, options.port_xtd_nm, options.starboard_xtd_nm)
296 else {
297 return Ok(None);
298 };
299
300 // A visibility-graph edge only promises to clear the land and
301 // no-entry areas this attempt's own fetch found along a straight
302 // screening rectangle -- not everything a real `check_leg` would
303 // catch (charted-shallow water among others; see
304 // `Hazards::coarsened_for_screening`'s own doc for why depth is left
305 // to the lattice search below rather than checked here). What it is
306 // trusted for is the macro shape: which side of the obstacle to pass
307 // on. Each hop between its waypoints is
308 // handed back to the lattice search proper, the same margin
309 // escalation and real, corridor-aware validation this crate already
310 // trusts locally -- just run once per macro-leg instead of once for
311 // a passage possibly a hundred miles long.
312 let mut route: Vec<Waypoint> = Vec::new();
313 for pair in waypoints.windows(2) {
314 let hop_distance_nm = rhumb::distance_nm(pair[0], pair[1]);
315 let Some(leg) = lattice_route(chart, pair[0], pair[1], options, hop_distance_nm)? else {
316 return Ok(None);
317 };
318 if route.is_empty() {
319 route.push(Waypoint::at(pair[0]));
320 }
321 route.extend(leg.into_iter().skip(1));
322 }
323 Ok(Some(route))
324}
325
326/// A baseline for [`margin_schedule`]'s auto mode, in nautical miles: 30%
327/// of the direct distance, clamped so a short hop still gets enough room
328/// to clear a headland and a long passage does not get an unreasonably
329/// wide search area to start with.
330const AUTO_MARGIN_FRACTION: f64 = 0.3;
331const MIN_AUTO_MARGIN_NM: f64 = 3.0;
332const MAX_AUTO_MARGIN_NM: f64 = 12.0;
333
334/// How far [`margin_schedule`]'s auto mode widens its baseline before
335/// giving up -- three attempts, each doubling the last, the same
336/// escalate-rather-than-guess-right-first-time idea
337/// [`smooth::Safety::is_clear`] applies to a Coarse corridor.
338const AUTO_MARGIN_MULTIPLIERS: [f64; 3] = [1.0, 2.0, 4.0];
339
340/// The sequence of `margin_nm` values [`find_route`] tries, in order.
341///
342/// `explicit` (`FindOptions::margin_nm`) is used exactly once,
343/// unmodified: escalating past a margin the caller specified would
344/// silently change the meaning of "no route within 1 NM" from a
345/// debugging answer into a different question. `None` builds a short
346/// escalating schedule from `direct_distance_nm` instead.
347fn margin_schedule(direct_distance_nm: f64, explicit: Option<f64>) -> Vec<f64> {
348 let Some(margin_nm) = explicit else {
349 let base = (direct_distance_nm * AUTO_MARGIN_FRACTION).clamp(MIN_AUTO_MARGIN_NM, MAX_AUTO_MARGIN_NM);
350 return AUTO_MARGIN_MULTIPLIERS.iter().map(|multiplier| base * multiplier).collect();
351 };
352 vec![margin_nm]
353}
354
355/// How many lattice cells [`auto_cell_nm`] aims to span the longer of the
356/// search area's two axes with. Coarse enough to keep a long ocean passage
357/// tractable, fine enough that a short coastal hop is not searched with a
358/// handful of giant cells.
359const TARGET_LATTICE_SPAN: f64 = 150.0;
360
361/// A floor under [`auto_cell_nm`], so a `from` and `to` that sit on top of
362/// each other (or a tiny `margin_nm`) do not produce a cell size of zero.
363const MIN_CELL_NM: f64 = 0.05;
364
365/// The bounding box of `from` and `to`, expanded by `margin_nm` on every
366/// side.
367///
368/// Expanded in true nautical miles via [`rhumb::destination`] from each
369/// corner, not by adding degrees -- the same reason [`grid::Grid`] steps
370/// its lattice that way.
371pub(crate) fn search_area(from: Position, to: Position, margin_nm: f64) -> Rect<f64> {
372 let south_west = Position::new(from.lat_deg.min(to.lat_deg), from.lon_deg.min(to.lon_deg));
373 let north_east = Position::new(from.lat_deg.max(to.lat_deg), from.lon_deg.max(to.lon_deg));
374
375 let south_west = rhumb::destination(
376 rhumb::destination(south_west, 180.0, margin_nm),
377 270.0,
378 margin_nm,
379 );
380 let north_east =
381 rhumb::destination(rhumb::destination(north_east, 0.0, margin_nm), 90.0, margin_nm);
382
383 Rect::new(
384 Coord { x: south_west.lon_deg, y: south_west.lat_deg },
385 Coord { x: north_east.lon_deg, y: north_east.lat_deg },
386 )
387}
388
389/// A lattice spacing that keeps the search area's longer axis to roughly
390/// [`TARGET_LATTICE_SPAN`] cells, however long the passage is.
391fn auto_cell_nm(direct_distance_nm: f64, margin_nm: f64) -> f64 {
392 ((direct_distance_nm + 2.0 * margin_nm) / TARGET_LATTICE_SPAN).max(MIN_CELL_NM)
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[test]
400 fn the_search_area_covers_both_endpoints_with_margin_on_every_side() {
401 let from = Position::new(45.50, 13.60);
402 let to = Position::new(45.55, 13.70);
403 let area = search_area(from, to, 2.0);
404
405 assert!(area.min().y < from.lat_deg.min(to.lat_deg));
406 assert!(area.max().y > from.lat_deg.max(to.lat_deg));
407 assert!(area.min().x < from.lon_deg.min(to.lon_deg));
408 assert!(area.max().x > from.lon_deg.max(to.lon_deg));
409 }
410
411 #[test]
412 fn auto_cell_nm_grows_with_distance_and_never_falls_below_the_floor() {
413 assert_eq!(auto_cell_nm(0.0, 0.0), MIN_CELL_NM);
414 assert!(auto_cell_nm(1500.0, 5.0) > auto_cell_nm(15.0, 5.0));
415 }
416
417 #[test]
418 fn an_explicit_margin_is_tried_exactly_once_and_unmodified() {
419 assert_eq!(margin_schedule(15.0, Some(1.0)), vec![1.0]);
420 }
421
422 #[test]
423 fn auto_margin_escalates_from_a_baseline_that_scales_with_distance() {
424 let short = margin_schedule(1.0, None);
425 let long = margin_schedule(40.0, None);
426
427 assert_eq!(short.len(), 3, "an escalating schedule, not one guess");
428 assert!(short.windows(2).all(|pair| pair[1] > pair[0]), "each step widens");
429 assert!(short[0] >= MIN_AUTO_MARGIN_NM, "a short hop still gets room to clear a headland");
430 assert!(long[0] > short[0], "a longer passage starts with a wider margin");
431 }
432
433 #[test]
434 fn auto_margin_never_exceeds_the_clamp_before_escalating() {
435 let schedule = margin_schedule(1000.0, None);
436 assert_eq!(schedule[0], MAX_AUTO_MARGIN_NM);
437 }
438}