aerocontext-planning 0.4.2

Flight-route planning over aerocontext: Garmin .fpl import/export, route expansion, corridor generation, and the vertical cross-section
Documentation
//! Garmin FlightPlan v1 `.fpl` flight plans: import, export, and the
//! bridge to a [`aerocontext_core::RouteBriefingRequest`].
//!
//! The schema is Garmin's `http://www8.garmin.com/xmlschemas/FlightPlan/v1`:
//! a `<waypoint-table>` dictionary of `<waypoint>` entries (identifier,
//! type, lat, lon) and an ordered `<route>` of `<route-point>`s that
//! reference it. ForeFlight and other tools add a `<flight-data>` block
//! (ETD, cruise altitude) and an `<aircraft>` block as siblings; those are
//! preserved on import and re-emitted on export. Parsing is lenient —
//! `country-code`/`comment` (strictly required by the XSD) are treated as
//! optional, since real exporters omit them — but a route point that does
//! not resolve to a table waypoint is a hard error: a corridor must never
//! be built from a hole.

mod garmin;

pub use garmin::FplError;

use aerocontext_core::{
    FlightRules, GeoPoint, NavPoint, NavPointKind, RouteBriefingRequest, RouteWaypoint,
};
use chrono::{DateTime, Utc};

use crate::route::{ExpandedRoute, RoutePoint};

/// A Garmin waypoint type. Recognized values round-trip on export;
/// an unknown type is coerced to `USER WAYPOINT`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum WaypointType {
    /// `AIRPORT`.
    Airport,
    /// `NDB`.
    Ndb,
    /// `VOR`.
    Vor,
    /// `INT` (enroute intersection / fix).
    Int,
    /// `INT-VRP` (visual reporting point).
    IntVrp,
    /// `USER WAYPOINT` (operator-defined lat/lon).
    User,
}

impl WaypointType {
    /// The schema string for this type.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Airport => "AIRPORT",
            Self::Ndb => "NDB",
            Self::Vor => "VOR",
            Self::Int => "INT",
            Self::IntVrp => "INT-VRP",
            Self::User => "USER WAYPOINT",
        }
    }

    /// Parse a schema string, `None` for an unknown value.
    #[must_use]
    pub fn parse(value: &str) -> Option<Self> {
        match value.trim() {
            "AIRPORT" => Some(Self::Airport),
            "NDB" => Some(Self::Ndb),
            "VOR" => Some(Self::Vor),
            "INT" => Some(Self::Int),
            "INT-VRP" => Some(Self::IntVrp),
            "USER WAYPOINT" => Some(Self::User),
            _ => None,
        }
    }

    /// The closest [`NavPointKind`]; airports map to airports, navaids to
    /// navaids, everything else to a generic waypoint.
    #[must_use]
    pub fn nav_kind(self) -> NavPointKind {
        match self {
            Self::Airport => NavPointKind::Airport,
            Self::Vor | Self::Ndb => NavPointKind::Navaid,
            Self::Int | Self::IntVrp | Self::User => NavPointKind::Waypoint,
        }
    }
}

/// One ordered waypoint of an imported plan: its identifier, type, and
/// position (carried by the `.fpl` itself, so no snapshot is needed to
/// build geometry).
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct PlanWaypoint {
    /// Identifier from the waypoint table.
    pub identifier: String,
    /// Garmin waypoint type.
    pub kind: WaypointType,
    /// WGS84 position.
    pub position: GeoPoint,
    /// ICAO country code when the exporter included one.
    pub country_code: Option<String>,
}

/// An imported Garmin flight plan: ordered route plus the metadata other
/// tools attach.
#[derive(Debug, Clone, PartialEq, Default)]
#[non_exhaustive]
pub struct FlightPlan {
    /// `<route-name>`, when present.
    pub name: Option<String>,
    /// `<created>` timestamp.
    pub created: Option<DateTime<Utc>>,
    /// Estimated time of departure (`<flight-data><etd-zulu>`).
    pub etd: Option<DateTime<Utc>>,
    /// Planned cruise altitude in feet (`<flight-data><altitude-ft>`).
    pub cruise_altitude_ft: Option<i32>,
    /// Aircraft tail number (`<aircraft><aircraft-tailnumber>`).
    pub aircraft_tail: Option<String>,
    /// Ordered route waypoints, departure first, destination last.
    pub route: Vec<PlanWaypoint>,
}

impl FlightPlan {
    /// Parse a Garmin `.fpl` document. Accepts UTF-16 (with BOM, as
    /// ForeFlight exports) or UTF-8.
    pub fn from_fpl_bytes(bytes: &[u8]) -> Result<Self, FplError> {
        garmin::parse(bytes)
    }

    /// Serialize to a Garmin `.fpl` document (UTF-8), in the widely
    /// accepted ForeFlight dialect: a `<waypoint-table>` dictionary, an
    /// ordered `<route>`, and a `<flight-data>` block carrying ETD and
    /// cruise altitude.
    #[must_use]
    pub fn to_fpl_string(&self) -> String {
        garmin::to_xml(self)
    }

    /// The departure identifier (first route point).
    #[must_use]
    pub fn departure(&self) -> Option<&str> {
        self.route.first().map(|w| w.identifier.as_str())
    }

    /// The destination identifier (last route point).
    #[must_use]
    pub fn destination(&self) -> Option<&str> {
        self.route.last().map(|w| w.identifier.as_str())
    }

    /// The route as [`NavPoint`]s, for snapshot-free resolution or
    /// display.
    #[must_use]
    pub fn nav_points(&self) -> Vec<NavPoint> {
        self.route
            .iter()
            .map(|w| NavPoint::new(&w.identifier, w.kind.nav_kind(), w.position))
            .collect()
    }

    /// The route as an [`ExpandedRoute`] — the `.fpl` already carries
    /// every position, so this needs no navdata snapshot and feeds
    /// [`Corridor::around_route`](crate::Corridor::around_route)
    /// directly.
    #[must_use]
    pub fn to_expanded_route(&self) -> ExpandedRoute {
        let points = self
            .route
            .iter()
            .map(|w| RoutePoint::new(w.position).with_ident(Some(w.identifier.clone())))
            .collect();
        ExpandedRoute {
            points,
            procedures: Vec::new(),
        }
    }

    /// Build a [`RouteBriefingRequest`] over this plan with a
    /// `corridor_half_width_nm` corridor, carrying ETD, cruise altitude,
    /// and flight rules so providers compose the right requests.
    #[must_use]
    pub fn to_route_briefing_request(
        &self,
        corridor_half_width_nm: f64,
        flight_rules: FlightRules,
    ) -> RouteBriefingRequest {
        let waypoints = self
            .route
            .iter()
            .map(|w| RouteWaypoint::new(w.position).with_ident(Some(w.identifier.clone())))
            .collect();
        RouteBriefingRequest::new(waypoints, corridor_half_width_nm)
            .with_cruise_altitude_ft(self.cruise_altitude_ft)
            .with_departure_at(self.etd)
            .with_flight_rules(flight_rules)
    }
}

#[cfg(test)]
mod tests;