aerocontext-planning 0.4.2

Flight-route planning over aerocontext: Garmin .fpl import/export, route expansion, corridor generation, and the vertical cross-section
Documentation
//! Route expansion against a [`NavDataSnapshot`].

use aerocontext_core::{Airway, NavDataSnapshot};

use super::{ExpandedRoute, RouteError, RoutePoint, RouteToken, token};

/// Parse and expand in one step.
pub fn expand_str(route: &str, snapshot: &NavDataSnapshot) -> Result<ExpandedRoute, RouteError> {
    expand(&token::parse(route)?, snapshot)
}

/// Expand tokens into ordered geometry.
///
/// `FIX1 AWY FIX2` emits the published points strictly between entry and
/// exit (exclusive entry — it was already emitted by its own token —
/// inclusive exit), traversing the airway in whichever direction
/// entry→exit requires; all airways are treated bidirectional in v0.1.
/// SID/STAR tokens contribute no geometry and are collected separately;
/// an unresolvable ident at the route edge that ends in a digit is
/// reclassified as a procedure, so real-world strings expand without a
/// procedure engine.
pub fn expand(
    tokens: &[RouteToken],
    snapshot: &NavDataSnapshot,
) -> Result<ExpandedRoute, RouteError> {
    let mut route = ExpandedRoute::default();
    let last_index = tokens.len().saturating_sub(1);
    let mut pending_airway: Option<String> = None;
    for (index, current) in tokens.iter().enumerate() {
        match current {
            RouteToken::Dct => {}
            RouteToken::Procedure { .. } => route.procedures.push(current.clone()),
            RouteToken::LatLon(position) => {
                if let Some(airway) = pending_airway.take() {
                    return Err(RouteError::ExitFixNotOnAirway {
                        fix: format!("{:.4},{:.4}", position.lat, position.lon),
                        airway,
                    });
                }
                route.points.push(RoutePoint::new(*position));
            }
            RouteToken::Airway(designator) => {
                // Structural checks guarantee a preceding point token.
                pending_airway = Some(designator.clone());
            }
            RouteToken::Ident(ident) => {
                let is_edge = index == 0 || index == last_index;
                match resolve_ident(snapshot, ident, None) {
                    Ok(point) => {
                        if let Some(airway) = pending_airway.take() {
                            traverse_airway(snapshot, &airway, &mut route, ident)?;
                        } else {
                            route.points.push(point);
                        }
                    }
                    Err(error) => {
                        // Edge tokens ending in a digit read as SID/STAR
                        // computer codes (TRUKN2, PUCKY1).
                        if is_edge
                            && ident.chars().last().is_some_and(|c| c.is_ascii_digit())
                            && pending_airway.is_none()
                        {
                            route.procedures.push(RouteToken::Procedure {
                                name: ident.clone(),
                                transition: None,
                            });
                        } else {
                            return Err(error);
                        }
                    }
                }
            }
        }
    }
    Ok(route)
}

fn resolve_ident(
    snapshot: &NavDataSnapshot,
    ident: &str,
    region: Option<&str>,
) -> Result<RoutePoint, RouteError> {
    snapshot
        .resolve_preferring_region(ident, region)
        .map(|point| RoutePoint::new(point.position).with_ident(Some(point.ident.clone())))
        .ok_or_else(|| RouteError::UnresolvedIdent {
            ident: ident.to_owned(),
            cycle: snapshot.cycle.effective_on,
        })
}

/// Emit the airway points between the previously emitted entry fix and
/// `exit_ident`.
fn traverse_airway(
    snapshot: &NavDataSnapshot,
    designator: &str,
    route: &mut ExpandedRoute,
    exit_ident: &str,
) -> Result<(), RouteError> {
    let entry_ident = route
        .points
        .last()
        .and_then(|point| point.ident.clone())
        .ok_or_else(|| RouteError::AirwayAtRouteEdge {
            airway: designator.to_owned(),
        })?;
    let candidates: Vec<&Airway> = snapshot.airways_named(designator).collect();
    if candidates.is_empty() {
        return Err(RouteError::UnknownAirway {
            airway: designator.to_owned(),
        });
    }
    let mut traversals = Vec::new();
    for airway in &candidates {
        if let Some(span) = best_span(airway, &entry_ident, exit_ident) {
            traversals.push((airway, span));
        }
    }
    match traversals.len() {
        0 => {
            // Name the missing side precisely.
            let on_airway = |ident: &str| {
                candidates.iter().any(|airway| {
                    airway
                        .points
                        .iter()
                        .any(|point| point.ident.eq_ignore_ascii_case(ident))
                })
            };
            if !on_airway(&entry_ident) {
                Err(RouteError::EntryFixNotOnAirway {
                    fix: entry_ident,
                    airway: designator.to_owned(),
                })
            } else {
                Err(RouteError::ExitFixNotOnAirway {
                    fix: exit_ident.to_owned(),
                    airway: designator.to_owned(),
                })
            }
        }
        1 => {
            let (airway, (entry_idx, exit_idx)) = traversals.remove(0);
            emit_span(snapshot, airway, entry_idx, exit_idx, route)
        }
        _ => Err(RouteError::AmbiguousAirway {
            airway: designator.to_owned(),
            entry: entry_ident,
            exit: exit_ident.to_owned(),
        }),
    }
}

/// The (entry, exit) index pair on this airway minimizing the traversal
/// span, when both idents are present (an ident can recur on a looping
/// airway).
fn best_span(airway: &Airway, entry: &str, exit: &str) -> Option<(usize, usize)> {
    let positions = |ident: &str| {
        airway
            .points
            .iter()
            .enumerate()
            .filter(|(_, point)| point.ident.eq_ignore_ascii_case(ident))
            .map(|(index, _)| index)
            .collect::<Vec<_>>()
    };
    let entries = positions(entry);
    let exits = positions(exit);
    let mut best: Option<(usize, usize)> = None;
    for &entry_idx in &entries {
        for &exit_idx in &exits {
            if entry_idx == exit_idx {
                continue;
            }
            let span = entry_idx.abs_diff(exit_idx);
            if best.is_none_or(|(a, b)| span < a.abs_diff(b)) {
                best = Some((entry_idx, exit_idx));
            }
        }
    }
    best
}

/// Emit points exclusive-entry / inclusive-exit, checking gaps in the
/// traversal direction.
fn emit_span(
    snapshot: &NavDataSnapshot,
    airway: &Airway,
    entry_idx: usize,
    exit_idx: usize,
    route: &mut ExpandedRoute,
) -> Result<(), RouteError> {
    let forward = entry_idx < exit_idx;
    let indices: Vec<usize> = if forward {
        (entry_idx..=exit_idx).collect()
    } else {
        (exit_idx..=entry_idx).rev().collect()
    };
    for window in indices.windows(2) {
        let (from, to) = (window[0], window[1]);
        // Published gaps live on the lower-index point of each segment.
        let gap_index = from.min(to);
        if airway.points[gap_index].gap_to_next {
            return Err(RouteError::DiscontinuedSegment {
                airway: airway.ident.clone(),
                from: airway.points[from].ident.clone(),
                to: airway.points[to].ident.clone(),
            });
        }
    }
    for &index in indices.iter().skip(1) {
        let airway_point = &airway.points[index];
        let resolved = resolve_ident(
            snapshot,
            &airway_point.ident,
            airway_point.icao_region.as_deref(),
        )?;
        route
            .points
            .push(resolved.with_via_airway(Some(airway.ident.clone())));
    }
    Ok(())
}