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;