aerocontext-core 0.4.2

Provider-neutral aeronautical-context model and the pluggable ContextProvider contract
Documentation
//! Runways and their ends — the geometry a go/no-go check needs for
//! crosswind, runway length, and field elevation.
//!
//! Provider-neutral by design: a [`Runway`] is just geometry, so a future
//! Jeppesen or foreign-authority ingest produces the same type. Lengths
//! and widths are in **feet** and headings in **degrees true** (the units
//! a US NASR source reports directly); a metric source converts on
//! ingest so the domain model stays unit-consistent.

use serde::{Deserialize, Serialize};

/// One end of a runway (e.g. `"01L"` of the `"01L/19R"` pair).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RunwayEnd {
    /// End identifier, e.g. `"01L"`.
    pub id: String,
    /// True alignment of the landing direction, degrees true. Crosswind
    /// is computed against this (METAR wind is also degrees true), not
    /// against the magnetic designator.
    pub true_alignment_deg: Option<f64>,
    /// Threshold elevation, feet MSL.
    pub elevation_ft: Option<f64>,
}

impl RunwayEnd {
    /// An end with only its identifier; fill the rest with `with_*`.
    pub fn new(id: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            true_alignment_deg: None,
            elevation_ft: None,
        }
    }

    /// Set the true alignment (degrees true).
    #[must_use]
    pub fn with_true_alignment_deg(mut self, deg: Option<f64>) -> Self {
        self.true_alignment_deg = deg;
        self
    }

    /// Set the threshold elevation (feet MSL).
    #[must_use]
    pub fn with_elevation_ft(mut self, ft: Option<f64>) -> Self {
        self.elevation_ft = ft;
        self
    }

    /// Headwind and crosswind components, knots, for a wind from
    /// `wind_dir_true` degrees at `wind_speed_kt`. Headwind is positive
    /// down the runway; the crosswind magnitude is unsigned. `None` when
    /// this end has no alignment.
    ///
    /// Advisory geometry: a decision layer compares the crosswind against
    /// a personal minimum, never this function.
    #[must_use]
    pub fn wind_components_kt(&self, wind_dir_true: f64, wind_speed_kt: f64) -> Option<(f64, f64)> {
        let alignment = self.true_alignment_deg?;
        if !wind_dir_true.is_finite() || !wind_speed_kt.is_finite() {
            return None;
        }
        let angle = (wind_dir_true - alignment).to_radians();
        let headwind = wind_speed_kt * angle.cos();
        let crosswind = (wind_speed_kt * angle.sin()).abs();
        Some((headwind, crosswind))
    }
}

/// A runway, identified by its `designator` pair (e.g. `"01L/19R"`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Runway {
    /// Owning airport identifier (FAA LID / authority ident).
    pub airport_ident: String,
    /// Runway designator pair, e.g. `"01L/19R"` or `"10/28"`.
    pub designator: String,
    /// Full length, feet.
    pub length_ft: Option<f64>,
    /// Width, feet.
    pub width_ft: Option<f64>,
    /// Surface composition code (e.g. `"ASPH"`, `"CONC"`, `"TURF"`).
    pub surface: Option<String>,
    /// ISO country code, for multi-authority coverage; US NASR reports
    /// `"US"`.
    pub country_code: Option<String>,
    /// The runway's ends (usually two), in report order.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub ends: Vec<RunwayEnd>,
}

impl Runway {
    /// A runway at `airport_ident` with the given `designator`.
    pub fn new(airport_ident: impl Into<String>, designator: impl Into<String>) -> Self {
        Self {
            airport_ident: airport_ident.into(),
            designator: designator.into(),
            length_ft: None,
            width_ft: None,
            surface: None,
            country_code: None,
            ends: Vec::new(),
        }
    }

    /// Set the length (feet).
    #[must_use]
    pub fn with_length_ft(mut self, ft: Option<f64>) -> Self {
        self.length_ft = ft;
        self
    }

    /// Set the width (feet).
    #[must_use]
    pub fn with_width_ft(mut self, ft: Option<f64>) -> Self {
        self.width_ft = ft;
        self
    }

    /// Set the surface composition code.
    #[must_use]
    pub fn with_surface(mut self, surface: Option<String>) -> Self {
        self.surface = surface;
        self
    }

    /// Set the country code.
    #[must_use]
    pub fn with_country_code(mut self, country_code: Option<String>) -> Self {
        self.country_code = country_code;
        self
    }

    /// Set the ends.
    #[must_use]
    pub fn with_ends(mut self, ends: Vec<RunwayEnd>) -> Self {
        self.ends = ends;
        self
    }

    /// The crosswind component, knots, for the given true wind. This is a
    /// property of the runway, not of which end is used — reciprocal ends
    /// have equal crosswind (the difference is headwind vs tailwind), so
    /// any aligned end answers. `None` when no end has an alignment.
    ///
    /// "Which runway minimizes crosswind" is then a min of this over an
    /// airport's runways — a decision-layer concern, not this type's.
    #[must_use]
    pub fn crosswind_kt(&self, wind_dir_true: f64, wind_speed_kt: f64) -> Option<f64> {
        self.ends
            .iter()
            .find_map(|end| end.wind_components_kt(wind_dir_true, wind_speed_kt))
            .map(|(_, crosswind)| crosswind)
    }
}

#[cfg(test)]
mod tests;