aerocontext_planning/flightplan.rs
1//! Garmin FlightPlan v1 `.fpl` flight plans: import, export, and the
2//! bridge to a [`aerocontext_core::RouteBriefingRequest`].
3//!
4//! The schema is Garmin's `http://www8.garmin.com/xmlschemas/FlightPlan/v1`:
5//! a `<waypoint-table>` dictionary of `<waypoint>` entries (identifier,
6//! type, lat, lon) and an ordered `<route>` of `<route-point>`s that
7//! reference it. ForeFlight and other tools add a `<flight-data>` block
8//! (ETD, cruise altitude) and an `<aircraft>` block as siblings; those are
9//! preserved on import and re-emitted on export. Parsing is lenient —
10//! `country-code`/`comment` (strictly required by the XSD) are treated as
11//! optional, since real exporters omit them — but a route point that does
12//! not resolve to a table waypoint is a hard error: a corridor must never
13//! be built from a hole.
14
15mod garmin;
16
17pub use garmin::FplError;
18
19use aerocontext_core::{
20 FlightRules, GeoPoint, NavPoint, NavPointKind, RouteBriefingRequest, RouteWaypoint,
21};
22use chrono::{DateTime, Utc};
23
24use crate::route::{ExpandedRoute, RoutePoint};
25
26/// A Garmin waypoint type. Recognized values round-trip on export;
27/// an unknown type is coerced to `USER WAYPOINT`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29#[non_exhaustive]
30pub enum WaypointType {
31 /// `AIRPORT`.
32 Airport,
33 /// `NDB`.
34 Ndb,
35 /// `VOR`.
36 Vor,
37 /// `INT` (enroute intersection / fix).
38 Int,
39 /// `INT-VRP` (visual reporting point).
40 IntVrp,
41 /// `USER WAYPOINT` (operator-defined lat/lon).
42 User,
43}
44
45impl WaypointType {
46 /// The schema string for this type.
47 #[must_use]
48 pub fn as_str(self) -> &'static str {
49 match self {
50 Self::Airport => "AIRPORT",
51 Self::Ndb => "NDB",
52 Self::Vor => "VOR",
53 Self::Int => "INT",
54 Self::IntVrp => "INT-VRP",
55 Self::User => "USER WAYPOINT",
56 }
57 }
58
59 /// Parse a schema string, `None` for an unknown value.
60 #[must_use]
61 pub fn parse(value: &str) -> Option<Self> {
62 match value.trim() {
63 "AIRPORT" => Some(Self::Airport),
64 "NDB" => Some(Self::Ndb),
65 "VOR" => Some(Self::Vor),
66 "INT" => Some(Self::Int),
67 "INT-VRP" => Some(Self::IntVrp),
68 "USER WAYPOINT" => Some(Self::User),
69 _ => None,
70 }
71 }
72
73 /// The closest [`NavPointKind`]; airports map to airports, navaids to
74 /// navaids, everything else to a generic waypoint.
75 #[must_use]
76 pub fn nav_kind(self) -> NavPointKind {
77 match self {
78 Self::Airport => NavPointKind::Airport,
79 Self::Vor | Self::Ndb => NavPointKind::Navaid,
80 Self::Int | Self::IntVrp | Self::User => NavPointKind::Waypoint,
81 }
82 }
83}
84
85/// One ordered waypoint of an imported plan: its identifier, type, and
86/// position (carried by the `.fpl` itself, so no snapshot is needed to
87/// build geometry).
88#[derive(Debug, Clone, PartialEq)]
89#[non_exhaustive]
90pub struct PlanWaypoint {
91 /// Identifier from the waypoint table.
92 pub identifier: String,
93 /// Garmin waypoint type.
94 pub kind: WaypointType,
95 /// WGS84 position.
96 pub position: GeoPoint,
97 /// ICAO country code when the exporter included one.
98 pub country_code: Option<String>,
99}
100
101/// An imported Garmin flight plan: ordered route plus the metadata other
102/// tools attach.
103#[derive(Debug, Clone, PartialEq, Default)]
104#[non_exhaustive]
105pub struct FlightPlan {
106 /// `<route-name>`, when present.
107 pub name: Option<String>,
108 /// `<created>` timestamp.
109 pub created: Option<DateTime<Utc>>,
110 /// Estimated time of departure (`<flight-data><etd-zulu>`).
111 pub etd: Option<DateTime<Utc>>,
112 /// Planned cruise altitude in feet (`<flight-data><altitude-ft>`).
113 pub cruise_altitude_ft: Option<i32>,
114 /// Aircraft tail number (`<aircraft><aircraft-tailnumber>`).
115 pub aircraft_tail: Option<String>,
116 /// Ordered route waypoints, departure first, destination last.
117 pub route: Vec<PlanWaypoint>,
118}
119
120impl FlightPlan {
121 /// Parse a Garmin `.fpl` document. Accepts UTF-16 (with BOM, as
122 /// ForeFlight exports) or UTF-8.
123 pub fn from_fpl_bytes(bytes: &[u8]) -> Result<Self, FplError> {
124 garmin::parse(bytes)
125 }
126
127 /// Serialize to a Garmin `.fpl` document (UTF-8), in the widely
128 /// accepted ForeFlight dialect: a `<waypoint-table>` dictionary, an
129 /// ordered `<route>`, and a `<flight-data>` block carrying ETD and
130 /// cruise altitude.
131 #[must_use]
132 pub fn to_fpl_string(&self) -> String {
133 garmin::to_xml(self)
134 }
135
136 /// The departure identifier (first route point).
137 #[must_use]
138 pub fn departure(&self) -> Option<&str> {
139 self.route.first().map(|w| w.identifier.as_str())
140 }
141
142 /// The destination identifier (last route point).
143 #[must_use]
144 pub fn destination(&self) -> Option<&str> {
145 self.route.last().map(|w| w.identifier.as_str())
146 }
147
148 /// The route as [`NavPoint`]s, for snapshot-free resolution or
149 /// display.
150 #[must_use]
151 pub fn nav_points(&self) -> Vec<NavPoint> {
152 self.route
153 .iter()
154 .map(|w| NavPoint::new(&w.identifier, w.kind.nav_kind(), w.position))
155 .collect()
156 }
157
158 /// The route as an [`ExpandedRoute`] — the `.fpl` already carries
159 /// every position, so this needs no navdata snapshot and feeds
160 /// [`Corridor::around_route`](crate::Corridor::around_route)
161 /// directly.
162 #[must_use]
163 pub fn to_expanded_route(&self) -> ExpandedRoute {
164 let points = self
165 .route
166 .iter()
167 .map(|w| RoutePoint::new(w.position).with_ident(Some(w.identifier.clone())))
168 .collect();
169 ExpandedRoute {
170 points,
171 procedures: Vec::new(),
172 }
173 }
174
175 /// Build a [`RouteBriefingRequest`] over this plan with a
176 /// `corridor_half_width_nm` corridor, carrying ETD, cruise altitude,
177 /// and flight rules so providers compose the right requests.
178 #[must_use]
179 pub fn to_route_briefing_request(
180 &self,
181 corridor_half_width_nm: f64,
182 flight_rules: FlightRules,
183 ) -> RouteBriefingRequest {
184 let waypoints = self
185 .route
186 .iter()
187 .map(|w| RouteWaypoint::new(w.position).with_ident(Some(w.identifier.clone())))
188 .collect();
189 RouteBriefingRequest::new(waypoints, corridor_half_width_nm)
190 .with_cruise_altitude_ft(self.cruise_altitude_ft)
191 .with_departure_at(self.etd)
192 .with_flight_rules(flight_rules)
193 }
194}
195
196#[cfg(test)]
197mod tests;