Skip to main content

aerocontext_core/navdata/
airspace.rs

1//! Airspace volumes — the class/type, vertical limits, and lateral extent a
2//! go/no-go check needs to reason about controlled airspace (Class B/C/D/E)
3//! and Special Use Airspace (MOAs, restricted/prohibited/alert/warning areas).
4//!
5//! Attributes-first: the lateral extent is carried as a bounding box
6//! ([`Area::BoundingBox`]), enough to answer "which airspace overlies this
7//! airport" and "this point *may* be inside". Precise polygon containment
8//! (reconstructing CIFP arc/circle boundaries) is a later refinement.
9//! Altitudes are advisory context; a decision layer applies 14 CFR 91.155,
10//! never this type.
11
12use serde::{Deserialize, Serialize};
13
14use crate::model::{Area, GeoPoint};
15
16/// What kind of airspace a volume is.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[non_exhaustive]
19pub enum AirspaceKind {
20    /// Controlled airspace of a given class.
21    Controlled(ControlledClass),
22    /// Special Use Airspace of a given restrictive kind.
23    Restrictive(RestrictiveKind),
24}
25
26/// Class of controlled airspace.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[non_exhaustive]
29pub enum ControlledClass {
30    /// Class B.
31    B,
32    /// Class C.
33    C,
34    /// Class D.
35    D,
36    /// Class E.
37    E,
38    /// A class code this crate does not model, kept verbatim.
39    Other(String),
40}
41
42/// Restrictive (Special Use) airspace kind.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[non_exhaustive]
45pub enum RestrictiveKind {
46    /// Prohibited area.
47    Prohibited,
48    /// Restricted area.
49    Restricted,
50    /// Military Operations Area.
51    Moa,
52    /// Alert area.
53    Alert,
54    /// Warning area.
55    Warning,
56    /// Danger area.
57    Danger,
58    /// Training area.
59    Training,
60    /// A restrictive code this crate does not model, kept verbatim.
61    Other(String),
62}
63
64/// Vertical datum an [`AltitudeLimit`] is measured against.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66#[non_exhaustive]
67pub enum AltitudeDatum {
68    /// Surface / ground level.
69    Ground,
70    /// Mean sea level (feet MSL).
71    Msl,
72    /// Above ground level (feet AGL).
73    Agl,
74    /// Flight level (feet, standard pressure).
75    FlightLevel,
76    /// No upper bound.
77    Unlimited,
78    /// The source value did not decode to a known datum.
79    Unknown,
80}
81
82/// One vertical limit of an airspace.
83#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
84#[non_exhaustive]
85pub struct AltitudeLimit {
86    /// Altitude in feet, when the datum carries a numeric value (`None`
87    /// for [`AltitudeDatum::Ground`]/[`AltitudeDatum::Unlimited`]).
88    pub value_ft: Option<f64>,
89    /// What the value is measured against.
90    pub datum: AltitudeDatum,
91}
92
93impl AltitudeLimit {
94    /// A limit with the given value and datum.
95    pub fn new(value_ft: Option<f64>, datum: AltitudeDatum) -> Self {
96        Self { value_ft, datum }
97    }
98
99    /// The surface limit: ground level, no numeric value.
100    pub fn ground() -> Self {
101        Self {
102            value_ft: None,
103            datum: AltitudeDatum::Ground,
104        }
105    }
106
107    /// An unbounded upper limit.
108    pub fn unlimited() -> Self {
109        Self {
110            value_ft: None,
111            datum: AltitudeDatum::Unlimited,
112        }
113    }
114}
115
116/// An airspace volume: its class/type, name, owning airport (for controlled
117/// airspace), vertical limits, and lateral extent.
118///
119/// A layered Class B/C is emitted as one `Airspace` per shelf — each shelf
120/// shares the `center_ident` and class but carries its own altitude band and
121/// `bounds`.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[non_exhaustive]
124pub struct Airspace {
125    /// Class (controlled) or restrictive kind.
126    pub kind: AirspaceKind,
127    /// Source designator: a SUA name (`"BOARDMAN"`) or the owning airport /
128    /// shelf label of a controlled-airspace volume.
129    pub designator: String,
130    /// Human-readable name when the source carries one.
131    pub name: Option<String>,
132    /// Owning airport identifier for controlled airspace (the airspace
133    /// "center"); `None` for Special Use Airspace.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub center_ident: Option<String>,
136    /// Lower vertical limit.
137    pub lower: AltitudeLimit,
138    /// Upper vertical limit.
139    pub upper: AltitudeLimit,
140    /// Lateral extent as a bounding box — over-inclusive versus the true
141    /// boundary; `None` when no boundary point decoded.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub bounds: Option<Area>,
144}
145
146impl Airspace {
147    /// An airspace of `kind` identified by `designator`; fill the rest with
148    /// the `with_*` setters. Defaults to ground..unlimited and no bounds.
149    pub fn new(kind: AirspaceKind, designator: impl Into<String>) -> Self {
150        Self {
151            kind,
152            designator: designator.into(),
153            name: None,
154            center_ident: None,
155            lower: AltitudeLimit::ground(),
156            upper: AltitudeLimit::unlimited(),
157            bounds: None,
158        }
159    }
160
161    /// Set the human-readable name.
162    #[must_use]
163    pub fn with_name(mut self, name: Option<String>) -> Self {
164        self.name = name;
165        self
166    }
167
168    /// Set the owning airport identifier (controlled airspace center).
169    #[must_use]
170    pub fn with_center_ident(mut self, center_ident: Option<String>) -> Self {
171        self.center_ident = center_ident;
172        self
173    }
174
175    /// Set the lower vertical limit.
176    #[must_use]
177    pub fn with_lower(mut self, lower: AltitudeLimit) -> Self {
178        self.lower = lower;
179        self
180    }
181
182    /// Set the upper vertical limit.
183    #[must_use]
184    pub fn with_upper(mut self, upper: AltitudeLimit) -> Self {
185        self.upper = upper;
186        self
187    }
188
189    /// Set the lateral bounding box.
190    #[must_use]
191    pub fn with_bounds(mut self, bounds: Option<Area>) -> Self {
192        self.bounds = bounds;
193        self
194    }
195
196    /// Whether this airspace's *bounding box* may contain `point`. Because
197    /// the bound is the enclosing box, `Some(true)` means "possibly inside"
198    /// (the true lateral boundary is smaller); `Some(false)` is definitive.
199    /// `None` when no bounds decoded, or the bound needs a location database.
200    ///
201    /// Lateral only — a decision layer combines this with the altitude band.
202    #[must_use]
203    pub fn bounds_may_contain(&self, point: GeoPoint) -> Option<bool> {
204        self.bounds.as_ref().and_then(|area| area.contains(point))
205    }
206}
207
208#[cfg(test)]
209mod tests;