aerocontext-core 0.4.2

Provider-neutral aeronautical-context model and the pluggable ContextProvider contract
Documentation
//! Decoded METAR observation fields and the FAA flight-rules category.
//!
//! The raw observation text always rides on [`Product`](crate::Product)
//! verbatim; these structured fields *augment* it for callers that need
//! numbers (a personal-minimums go/no-go check) rather than replacing it.
//!
//! Field semantics follow the aviationweather.gov (AWC) Data API
//! (`reference/awc-openapi.yaml`): visibility in statute miles, altimeter
//! in hectopascals, cloud bases in feet AGL, cover one of
//! `CLR/CAVOK/FEW/SCT/BKN/OVC/OVX`. The flight-category thresholds follow
//! the FAA / AWC definitions cited on [`FlightCategory`].

use serde::{Deserialize, Serialize};

/// FAA flight-rules category, ordered worst-first so `min` selects the
/// more restrictive of two categories.
///
/// Definitions (AWC / FAA), where the category is the **worse** of the
/// ceiling-derived and visibility-derived categories:
///
/// - **LIFR**: ceiling < 500 ft AGL  and/or visibility < 1 sm.
/// - **IFR**:  ceiling 500 to < 1,000 ft  and/or visibility 1 to < 3 sm.
/// - **MVFR**: ceiling 1,000 to 3,000 ft  and/or visibility 3 to 5 sm.
/// - **VFR**:  ceiling > 3,000 ft  and visibility > 5 sm.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[non_exhaustive]
pub enum FlightCategory {
    /// Low IFR — the most restrictive.
    Lifr,
    /// IFR.
    Ifr,
    /// Marginal VFR.
    Mvfr,
    /// VFR — the least restrictive.
    Vfr,
}

impl FlightCategory {
    /// Derive the category from a ceiling (feet AGL; `None` = no ceiling
    /// layer, i.e. unlimited) and visibility (statute miles).
    ///
    /// Returns `None` when visibility is unknown — the dominant VFR
    /// criterion — so a caller never categorizes from missing data. The
    /// result is the worse of the ceiling and visibility categories.
    #[must_use]
    pub fn from_ceiling_visibility(
        ceiling_ft: Option<f64>,
        visibility_sm: Option<f64>,
    ) -> Option<Self> {
        let visibility_sm = visibility_sm?;
        if !visibility_sm.is_finite() {
            return None;
        }
        let by_visibility = if visibility_sm < 1.0 {
            Self::Lifr
        } else if visibility_sm < 3.0 {
            Self::Ifr
        } else if visibility_sm <= 5.0 {
            Self::Mvfr
        } else {
            Self::Vfr
        };
        let by_ceiling = match ceiling_ft {
            // No ceiling layer is unlimited, so ceiling never constrains.
            None => Self::Vfr,
            Some(c) if !c.is_finite() => Self::Vfr,
            Some(c) if c < 500.0 => Self::Lifr,
            Some(c) if c < 1000.0 => Self::Ifr,
            Some(c) if c <= 3000.0 => Self::Mvfr,
            Some(_) => Self::Vfr,
        };
        Some(by_visibility.min(by_ceiling))
    }

    /// Parse AWC's `fltCat` string (`"VFR"`/`"MVFR"`/`"IFR"`/`"LIFR"`),
    /// case-insensitively. `None` for an empty or unrecognized value.
    #[must_use]
    pub fn parse(value: &str) -> Option<Self> {
        match value.trim().to_ascii_uppercase().as_str() {
            "VFR" => Some(Self::Vfr),
            "MVFR" => Some(Self::Mvfr),
            "IFR" => Some(Self::Ifr),
            "LIFR" => Some(Self::Lifr),
            _ => None,
        }
    }

    /// The conventional abbreviation.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Lifr => "LIFR",
            Self::Ifr => "IFR",
            Self::Mvfr => "MVFR",
            Self::Vfr => "VFR",
        }
    }
}

/// Sky-cover of one cloud layer (AWC cover enum).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum CloudCover {
    /// Clear / sky clear.
    Clear,
    /// Ceiling and visibility OK.
    Cavok,
    /// Few (1–2 oktas).
    Few,
    /// Scattered (3–4 oktas).
    Scattered,
    /// Broken (5–7 oktas) — a ceiling.
    Broken,
    /// Overcast (8 oktas) — a ceiling.
    Overcast,
    /// Obscured sky / vertical visibility — a ceiling.
    Obscured,
}

impl CloudCover {
    /// Parse an AWC cover string, `None` for an unrecognized value.
    #[must_use]
    pub fn parse(value: &str) -> Option<Self> {
        match value.trim().to_ascii_uppercase().as_str() {
            "CLR" | "SKC" | "NSC" | "NCD" => Some(Self::Clear),
            "CAVOK" => Some(Self::Cavok),
            "FEW" => Some(Self::Few),
            "SCT" => Some(Self::Scattered),
            "BKN" => Some(Self::Broken),
            "OVC" => Some(Self::Overcast),
            "OVX" | "VV" => Some(Self::Obscured),
            _ => None,
        }
    }

    /// Whether a layer of this cover constitutes a ceiling (broken,
    /// overcast, or obscured/vertical-visibility).
    #[must_use]
    pub fn is_ceiling(self) -> bool {
        matches!(self, Self::Broken | Self::Overcast | Self::Obscured)
    }
}

/// One reported cloud layer.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CloudLayer {
    /// Sky cover of the layer.
    pub cover: CloudCover,
    /// Layer base, feet AGL; `None` when the report omits a base (e.g.
    /// `CLR`).
    pub base_ft: Option<f64>,
}

impl CloudLayer {
    /// A layer with the given cover and optional base.
    pub fn new(cover: CloudCover, base_ft: Option<f64>) -> Self {
        Self { cover, base_ft }
    }
}

/// Hectopascals per inch of mercury (exact: 1 inHg = 33.8638866667 hPa).
const HPA_PER_INHG: f64 = 33.863_886_666_7;

/// A decoded METAR observation. Every field is optional: a real report
/// may omit any of them, and a caller must treat absence as "unknown",
/// never as a safe default.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MetarObservation {
    /// Air temperature, °C.
    pub temperature_c: Option<f64>,
    /// Dewpoint, °C.
    pub dewpoint_c: Option<f64>,
    /// Wind direction, degrees true; `None` when variable or unreported.
    pub wind_dir_deg: Option<u16>,
    /// Whether the wind direction is reported variable (`VRB`).
    pub wind_variable: bool,
    /// Sustained wind speed, knots.
    pub wind_speed_kt: Option<u16>,
    /// Gust speed, knots.
    pub wind_gust_kt: Option<u16>,
    /// Prevailing visibility, statute miles. `"10+"` decodes to `10.0`.
    pub visibility_sm: Option<f64>,
    /// Altimeter setting, hectopascals (the AWC source unit).
    pub altimeter_hpa: Option<f64>,
    /// Cloud layers, lowest first as reported.
    pub clouds: Vec<CloudLayer>,
    /// The category AWC itself reported (`fltCat`), for cross-checking
    /// against [`Self::flight_category`].
    pub reported_category: Option<FlightCategory>,
}

impl MetarObservation {
    /// An empty observation; fill it with the `with_*` setters.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Lowest ceiling base with a known, finite height (broken / overcast
    /// / obscured), feet AGL. `None` when no ceiling layer has a known
    /// height — which may mean unlimited *or* a ceiling whose height was
    /// not reported; use [`Self::has_unknown_height_ceiling`] to tell
    /// them apart.
    #[must_use]
    pub fn ceiling_ft(&self) -> Option<f64> {
        self.clouds
            .iter()
            .filter(|layer| layer.cover.is_ceiling())
            .filter_map(|layer| layer.base_ft)
            .filter(|base| base.is_finite())
            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
    }

    /// Whether a ceiling layer (broken/overcast/obscured) is present but
    /// its height is missing or non-finite. Such a ceiling must never be
    /// read as "unlimited": the true ceiling could be at any height.
    #[must_use]
    pub fn has_unknown_height_ceiling(&self) -> bool {
        self.clouds
            .iter()
            .any(|layer| layer.cover.is_ceiling() && !layer.base_ft.is_some_and(f64::is_finite))
    }

    /// Flight category derived from [`Self::ceiling_ft`] and
    /// [`Self::visibility_sm`] — the source of truth for a go/no-go
    /// check, independent of AWC's reported value.
    ///
    /// Returns `None` (uncategorizable) when visibility is unknown, or
    /// when a ceiling layer of unknown height could be lower than
    /// anything known and the result is not already the most restrictive
    /// category. A go/no-go caller must treat `None` conservatively, never
    /// as VFR.
    #[must_use]
    pub fn flight_category(&self) -> Option<FlightCategory> {
        let category =
            FlightCategory::from_ceiling_visibility(self.ceiling_ft(), self.visibility_sm)?;
        // A ceiling of unknown height could be lower than any known
        // ceiling; we can only stand behind a category that is already
        // the worst possible (LIFR).
        if category != FlightCategory::Lifr && self.has_unknown_height_ceiling() {
            return None;
        }
        Some(category)
    }

    /// Altimeter setting converted to inches of mercury.
    #[must_use]
    pub fn altimeter_inhg(&self) -> Option<f64> {
        self.altimeter_hpa.map(|hpa| hpa / HPA_PER_INHG)
    }

    /// Set the temperature (°C).
    #[must_use]
    pub fn with_temperature_c(mut self, value: Option<f64>) -> Self {
        self.temperature_c = value;
        self
    }

    /// Set the dewpoint (°C).
    #[must_use]
    pub fn with_dewpoint_c(mut self, value: Option<f64>) -> Self {
        self.dewpoint_c = value;
        self
    }

    /// Set the wind direction (degrees true) and whether it is variable.
    #[must_use]
    pub fn with_wind_dir(mut self, deg: Option<u16>, variable: bool) -> Self {
        self.wind_dir_deg = deg;
        self.wind_variable = variable;
        self
    }

    /// Set the wind speed and gust (knots).
    #[must_use]
    pub fn with_wind_speed(mut self, speed_kt: Option<u16>, gust_kt: Option<u16>) -> Self {
        self.wind_speed_kt = speed_kt;
        self.wind_gust_kt = gust_kt;
        self
    }

    /// Set the prevailing visibility (statute miles).
    #[must_use]
    pub fn with_visibility_sm(mut self, value: Option<f64>) -> Self {
        self.visibility_sm = value;
        self
    }

    /// Set the altimeter setting (hectopascals).
    #[must_use]
    pub fn with_altimeter_hpa(mut self, value: Option<f64>) -> Self {
        self.altimeter_hpa = value;
        self
    }

    /// Set the cloud layers.
    #[must_use]
    pub fn with_clouds(mut self, clouds: Vec<CloudLayer>) -> Self {
        self.clouds = clouds;
        self
    }

    /// Set AWC's reported category.
    #[must_use]
    pub fn with_reported_category(mut self, value: Option<FlightCategory>) -> Self {
        self.reported_category = value;
        self
    }
}

/// Parse an AWC `visib` value (`reference/awc-openapi.yaml`): a number, a
/// `"10+"` / `"6+"` (greater-than) string, or a fraction like `"1 1/2"` /
/// `"M1/4"` (`M` = "less than"). Returns statute miles, or `None` for a
/// negative, non-finite, or unparseable value.
///
/// The `M`/`+` qualifiers carry an inequality direction (`M1/4` is
/// *strictly* below 1/4 sm). This returns the magnitude; modeling the
/// bound for a numeric personal-minimums comparison is the decision
/// layer's job. The flight *category* is unaffected — `M1/4` is `< 1` so
/// it is LIFR either way.
#[must_use]
pub fn parse_visibility_sm(raw: &str) -> Option<f64> {
    let raw = raw.trim().trim_start_matches('M').trim_end_matches('+');
    let value = if let Ok(value) = raw.parse::<f64>() {
        value
    } else {
        // Mixed number or bare fraction, e.g. "1 1/2" or "3/4".
        let (whole, frac) = match raw.split_once(' ') {
            Some((w, f)) => (w.parse::<f64>().ok()?, f),
            None => (0.0, raw),
        };
        let (num, den) = frac.split_once('/')?;
        whole + num.parse::<f64>().ok()? / den.parse::<f64>().ok()?
    };
    // A negative or non-finite "statute miles" is garbage, not a safe
    // default — surface it as unknown.
    (value.is_finite() && value >= 0.0).then_some(value)
}

#[cfg(test)]
mod tests;