Skip to main content

aerocontext_core/
navdata.rs

1//! Cycle-aware navigation-point data.
2
3pub mod airspace;
4pub mod airway;
5pub mod runway;
6
7use chrono::{Days, NaiveDate};
8use serde::{Deserialize, Serialize};
9
10pub use airspace::{
11    Airspace, AirspaceKind, AltitudeDatum, AltitudeLimit, ControlledClass, RestrictiveKind,
12};
13pub use airway::{Airway, AirwayLocation, AirwayPoint};
14pub use runway::{Runway, RunwayEnd};
15
16use crate::model::GeoPoint;
17
18/// FAA NASR subscriber files advance on a 28-day effective cycle.
19/// The 28-day AIRAC grid every FAA aeronautical product follows.
20pub const AIRAC_CYCLE_DAYS: u64 = 28;
21
22/// NASR subscriber cadence (the AIRAC grid).
23pub const FAA_NASR_CYCLE_DAYS: u64 = AIRAC_CYCLE_DAYS;
24
25/// FAA page that publishes current, preview, and archived NASR subscriber
26/// files.
27pub const FAA_NASR_SUBSCRIPTION_URL: &str =
28    "https://www.faa.gov/air_traffic/flight_info/aeronav/aero_data/NASR_Subscription/";
29
30/// Authority and product family for a navigation-data snapshot.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[non_exhaustive]
33pub enum NavDataAuthority {
34    /// FAA 28 Day NASR Subscription.
35    FaaNasr,
36    /// FAA Coded Instrument Flight Procedures (ARINC 424).
37    FaaCifp,
38}
39
40impl NavDataAuthority {
41    /// Stable lowercase slug used in manifests, store paths, and release
42    /// names, e.g. `"faa-nasr"`. Every variant must map to a unique slug.
43    pub fn slug(self) -> &'static str {
44        match self {
45            Self::FaaNasr => "faa-nasr",
46            Self::FaaCifp => "faa-cifp",
47        }
48    }
49}
50
51/// Failure constructing or using navigation data.
52#[derive(Debug, thiserror::Error)]
53#[non_exhaustive]
54pub enum NavDataError {
55    /// The next effective date cannot be represented.
56    #[error("could not compute {cycle_days}-day navigation-data cycle after {effective_on}")]
57    CycleEndOutOfRange {
58        /// Cycle effective date.
59        effective_on: NaiveDate,
60        /// Cycle length in days.
61        cycle_days: u64,
62    },
63}
64
65/// Publication-cycle metadata for a navigation-data snapshot.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[non_exhaustive]
68pub struct NavDataCycle {
69    /// Authority that published the data.
70    pub authority: NavDataAuthority,
71    /// First date this snapshot is effective.
72    pub effective_on: NaiveDate,
73    /// First date a successor cycle is effective.
74    pub next_effective_on: NaiveDate,
75    /// URL or local product identifier used to create the snapshot.
76    pub source: String,
77}
78
79impl NavDataCycle {
80    /// FAA NASR cycle metadata from an effective date.
81    pub fn faa_nasr(effective_on: NaiveDate) -> Result<Self, NavDataError> {
82        Self::new(
83            NavDataAuthority::FaaNasr,
84            effective_on,
85            FAA_NASR_CYCLE_DAYS,
86            FAA_NASR_SUBSCRIPTION_URL,
87        )
88    }
89
90    /// Cycle metadata from an authority, effective date, cycle length, and
91    /// source identifier.
92    pub fn new(
93        authority: NavDataAuthority,
94        effective_on: NaiveDate,
95        cycle_days: u64,
96        source: impl Into<String>,
97    ) -> Result<Self, NavDataError> {
98        let next_effective_on = effective_on.checked_add_days(Days::new(cycle_days)).ok_or(
99            NavDataError::CycleEndOutOfRange {
100                effective_on,
101                cycle_days,
102            },
103        )?;
104        Ok(Self {
105            authority,
106            effective_on,
107            next_effective_on,
108            source: source.into(),
109        })
110    }
111
112    /// Whether `date` falls inside this effective cycle.
113    pub fn contains(&self, date: NaiveDate) -> bool {
114        self.effective_on <= date && date < self.next_effective_on
115    }
116}
117
118/// Kind of navigation point carried by a snapshot.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[non_exhaustive]
121pub enum NavPointKind {
122    /// Airport, heliport, seaplane base, or similar landing facility.
123    Airport,
124    /// Published waypoint/fix.
125    Waypoint,
126    /// Ground-based navigation aid.
127    Navaid,
128    /// A point kind this crate does not model yet.
129    Other(String),
130}
131
132/// One published navigation point with WGS84 coordinates.
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134#[non_exhaustive]
135pub struct NavPoint {
136    /// Published identifier.
137    pub ident: String,
138    /// Point family.
139    pub kind: NavPointKind,
140    /// WGS84 coordinates.
141    pub position: GeoPoint,
142    /// Human-readable published name when present.
143    pub name: Option<String>,
144    /// ICAO region of the point (e.g. `"K2"`), when the source provides
145    /// one — disambiguates idents that recur across regions.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub region: Option<String>,
148}
149
150impl NavPoint {
151    /// A navigation point with no display name or region.
152    pub fn new(ident: impl Into<String>, kind: NavPointKind, position: GeoPoint) -> Self {
153        Self {
154            ident: ident.into(),
155            kind,
156            position,
157            name: None,
158            region: None,
159        }
160    }
161
162    /// Set the published display name.
163    #[must_use]
164    pub fn with_name(mut self, name: Option<String>) -> Self {
165        self.name = name;
166        self
167    }
168
169    /// Set the ICAO region.
170    #[must_use]
171    pub fn with_region(mut self, region: Option<String>) -> Self {
172        self.region = region;
173        self
174    }
175}
176
177/// A cycle-tagged set of published navigation points and airways.
178#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
179#[non_exhaustive]
180pub struct NavDataSnapshot {
181    /// Publication-cycle metadata.
182    pub cycle: NavDataCycle,
183    /// Airports, waypoints, and navaids in this snapshot.
184    pub points: Vec<NavPoint>,
185    /// Published airways, when the source carries them.
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub airways: Vec<Airway>,
188    /// Published runways, when the source carries them.
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub runways: Vec<Runway>,
191    /// Published airspace volumes, when the source carries them.
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub airspaces: Vec<Airspace>,
194}
195
196impl NavDataSnapshot {
197    /// A snapshot for one publication cycle, without airways.
198    pub fn new(cycle: NavDataCycle, points: Vec<NavPoint>) -> Self {
199        Self {
200            cycle,
201            points,
202            airways: Vec::new(),
203            runways: Vec::new(),
204            airspaces: Vec::new(),
205        }
206    }
207
208    /// Set the airways.
209    #[must_use]
210    pub fn with_airways(mut self, airways: Vec<Airway>) -> Self {
211        self.airways = airways;
212        self
213    }
214
215    /// Set the runways.
216    #[must_use]
217    pub fn with_runways(mut self, runways: Vec<Runway>) -> Self {
218        self.runways = runways;
219        self
220    }
221
222    /// Set the airspaces.
223    #[must_use]
224    pub fn with_airspaces(mut self, airspaces: Vec<Airspace>) -> Self {
225        self.airspaces = airspaces;
226        self
227    }
228
229    /// Runways at an airport, by its identifier, case-insensitively.
230    pub fn runways_at<'a>(&'a self, airport_ident: &'a str) -> impl Iterator<Item = &'a Runway> {
231        let wanted = airport_ident.trim();
232        self.runways
233            .iter()
234            .filter(move |rwy| rwy.airport_ident.eq_ignore_ascii_case(wanted))
235    }
236
237    /// Controlled airspace centered on an airport, by its identifier,
238    /// case-insensitively (a layered Class B/C yields one entry per shelf).
239    pub fn airspaces_at<'a>(&'a self, center_ident: &'a str) -> impl Iterator<Item = &'a Airspace> {
240        let wanted = center_ident.trim();
241        self.airspaces.iter().filter(move |airspace| {
242            airspace
243                .center_ident
244                .as_deref()
245                .is_some_and(|center| center.eq_ignore_ascii_case(wanted))
246        })
247    }
248
249    /// Find a navigation point by identifier, case-insensitively.
250    pub fn resolve(&self, ident: &str) -> Option<&NavPoint> {
251        let wanted = ident.trim();
252        self.points
253            .iter()
254            .find(|point| point.ident.eq_ignore_ascii_case(wanted))
255    }
256
257    /// [`Self::resolve`], preferring an exact `(ident, region)` match when
258    /// a region hint is given (airway points carry one), falling back to
259    /// the first ident match.
260    pub fn resolve_preferring_region(
261        &self,
262        ident: &str,
263        region: Option<&str>,
264    ) -> Option<&NavPoint> {
265        let wanted = ident.trim();
266        if let Some(region) = region
267            && let Some(exact) = self.points.iter().find(|point| {
268                point.ident.eq_ignore_ascii_case(wanted)
269                    && point.region.as_deref().is_some_and(|r| r == region)
270            })
271        {
272            return Some(exact);
273        }
274        self.resolve(wanted)
275    }
276
277    /// Airways matching a designator, case-insensitively (the same ident
278    /// can exist per [`AirwayLocation`]).
279    pub fn airways_named<'a>(&'a self, designator: &'a str) -> impl Iterator<Item = &'a Airway> {
280        let wanted = designator.trim();
281        self.airways
282            .iter()
283            .filter(move |airway| airway.ident.eq_ignore_ascii_case(wanted))
284    }
285
286    /// Whether `date` falls inside this snapshot's effective cycle.
287    pub fn contains(&self, date: NaiveDate) -> bool {
288        self.cycle.contains(date)
289    }
290}
291
292#[cfg(test)]
293mod tests;