Skip to main content

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}