aerocontext-core 0.4.2

Provider-neutral aeronautical-context model and the pluggable ContextProvider contract
Documentation
//! Airspace volumes — the class/type, vertical limits, and lateral extent a
//! go/no-go check needs to reason about controlled airspace (Class B/C/D/E)
//! and Special Use Airspace (MOAs, restricted/prohibited/alert/warning areas).
//!
//! Attributes-first: the lateral extent is carried as a bounding box
//! ([`Area::BoundingBox`]), enough to answer "which airspace overlies this
//! airport" and "this point *may* be inside". Precise polygon containment
//! (reconstructing CIFP arc/circle boundaries) is a later refinement.
//! Altitudes are advisory context; a decision layer applies 14 CFR 91.155,
//! never this type.

use serde::{Deserialize, Serialize};

use crate::model::{Area, GeoPoint};

/// What kind of airspace a volume is.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AirspaceKind {
    /// Controlled airspace of a given class.
    Controlled(ControlledClass),
    /// Special Use Airspace of a given restrictive kind.
    Restrictive(RestrictiveKind),
}

/// Class of controlled airspace.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ControlledClass {
    /// Class B.
    B,
    /// Class C.
    C,
    /// Class D.
    D,
    /// Class E.
    E,
    /// A class code this crate does not model, kept verbatim.
    Other(String),
}

/// Restrictive (Special Use) airspace kind.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum RestrictiveKind {
    /// Prohibited area.
    Prohibited,
    /// Restricted area.
    Restricted,
    /// Military Operations Area.
    Moa,
    /// Alert area.
    Alert,
    /// Warning area.
    Warning,
    /// Danger area.
    Danger,
    /// Training area.
    Training,
    /// A restrictive code this crate does not model, kept verbatim.
    Other(String),
}

/// Vertical datum an [`AltitudeLimit`] is measured against.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum AltitudeDatum {
    /// Surface / ground level.
    Ground,
    /// Mean sea level (feet MSL).
    Msl,
    /// Above ground level (feet AGL).
    Agl,
    /// Flight level (feet, standard pressure).
    FlightLevel,
    /// No upper bound.
    Unlimited,
    /// The source value did not decode to a known datum.
    Unknown,
}

/// One vertical limit of an airspace.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AltitudeLimit {
    /// Altitude in feet, when the datum carries a numeric value (`None`
    /// for [`AltitudeDatum::Ground`]/[`AltitudeDatum::Unlimited`]).
    pub value_ft: Option<f64>,
    /// What the value is measured against.
    pub datum: AltitudeDatum,
}

impl AltitudeLimit {
    /// A limit with the given value and datum.
    pub fn new(value_ft: Option<f64>, datum: AltitudeDatum) -> Self {
        Self { value_ft, datum }
    }

    /// The surface limit: ground level, no numeric value.
    pub fn ground() -> Self {
        Self {
            value_ft: None,
            datum: AltitudeDatum::Ground,
        }
    }

    /// An unbounded upper limit.
    pub fn unlimited() -> Self {
        Self {
            value_ft: None,
            datum: AltitudeDatum::Unlimited,
        }
    }
}

/// An airspace volume: its class/type, name, owning airport (for controlled
/// airspace), vertical limits, and lateral extent.
///
/// A layered Class B/C is emitted as one `Airspace` per shelf — each shelf
/// shares the `center_ident` and class but carries its own altitude band and
/// `bounds`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Airspace {
    /// Class (controlled) or restrictive kind.
    pub kind: AirspaceKind,
    /// Source designator: a SUA name (`"BOARDMAN"`) or the owning airport /
    /// shelf label of a controlled-airspace volume.
    pub designator: String,
    /// Human-readable name when the source carries one.
    pub name: Option<String>,
    /// Owning airport identifier for controlled airspace (the airspace
    /// "center"); `None` for Special Use Airspace.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub center_ident: Option<String>,
    /// Lower vertical limit.
    pub lower: AltitudeLimit,
    /// Upper vertical limit.
    pub upper: AltitudeLimit,
    /// Lateral extent as a bounding box — over-inclusive versus the true
    /// boundary; `None` when no boundary point decoded.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bounds: Option<Area>,
}

impl Airspace {
    /// An airspace of `kind` identified by `designator`; fill the rest with
    /// the `with_*` setters. Defaults to ground..unlimited and no bounds.
    pub fn new(kind: AirspaceKind, designator: impl Into<String>) -> Self {
        Self {
            kind,
            designator: designator.into(),
            name: None,
            center_ident: None,
            lower: AltitudeLimit::ground(),
            upper: AltitudeLimit::unlimited(),
            bounds: None,
        }
    }

    /// Set the human-readable name.
    #[must_use]
    pub fn with_name(mut self, name: Option<String>) -> Self {
        self.name = name;
        self
    }

    /// Set the owning airport identifier (controlled airspace center).
    #[must_use]
    pub fn with_center_ident(mut self, center_ident: Option<String>) -> Self {
        self.center_ident = center_ident;
        self
    }

    /// Set the lower vertical limit.
    #[must_use]
    pub fn with_lower(mut self, lower: AltitudeLimit) -> Self {
        self.lower = lower;
        self
    }

    /// Set the upper vertical limit.
    #[must_use]
    pub fn with_upper(mut self, upper: AltitudeLimit) -> Self {
        self.upper = upper;
        self
    }

    /// Set the lateral bounding box.
    #[must_use]
    pub fn with_bounds(mut self, bounds: Option<Area>) -> Self {
        self.bounds = bounds;
        self
    }

    /// Whether this airspace's *bounding box* may contain `point`. Because
    /// the bound is the enclosing box, `Some(true)` means "possibly inside"
    /// (the true lateral boundary is smaller); `Some(false)` is definitive.
    /// `None` when no bounds decoded, or the bound needs a location database.
    ///
    /// Lateral only — a decision layer combines this with the altitude band.
    #[must_use]
    pub fn bounds_may_contain(&self, point: GeoPoint) -> Option<bool> {
        self.bounds.as_ref().and_then(|area| area.contains(point))
    }
}

#[cfg(test)]
mod tests;