aerocontext-core 0.2.0

Provider-neutral aeronautical-context model and the pluggable ContextProvider contract
Documentation
//! Provider-neutral briefing domain types.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// A geographic point in WGS84 decimal degrees.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct GeoPoint {
    /// Latitude in degrees, north positive.
    pub lat: f64,
    /// Longitude in degrees, east positive.
    pub lon: f64,
}

/// The geographic area a briefing covers.
///
/// Not every source supports every shape natively; adapters either convert
/// (a point radius encloses a bounding box and vice versa) or return
/// [`crate::ProviderError::Unsupported`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Area {
    /// Axis-aligned bounding box.
    BoundingBox {
        /// South-west corner.
        south_west: GeoPoint,
        /// North-east corner.
        north_east: GeoPoint,
    },
    /// Circle of `radius_nm` nautical miles around a coordinate.
    PointRadius {
        /// Circle center.
        center: GeoPoint,
        /// Radius in nautical miles.
        radius_nm: f64,
    },
    /// Circle of `radius_nm` nautical miles around a published location
    /// (ICAO/FAA aerodrome, navaid or fix identifier).
    LocationRadius {
        /// Location identifier, e.g. `"KSFO"`.
        ident: String,
        /// Radius in nautical miles.
        radius_nm: f64,
    },
    /// A polygon over `vertices`, treated as an implicitly closed ring
    /// (the last vertex connects back to the first). Vertex order may be
    /// either winding; self-intersecting rings give ray-cast semantics.
    Polygon {
        /// Ring vertices, at least three.
        vertices: Vec<GeoPoint>,
    },
}

impl Area {
    /// Smallest axis-aligned bounding box enclosing this area, as
    /// `(south_west, north_east)`, when one can be computed without a
    /// location database.
    ///
    /// Returns `None` for areas anchored to a published identifier; only
    /// the provider can resolve those. The radius conversion uses the
    /// small-area approximation (1 NM of latitude = 1/60 degree) and is
    /// not meaningful within a radius of the poles.
    pub fn enclosing_bbox(&self) -> Option<(GeoPoint, GeoPoint)> {
        match self {
            Self::BoundingBox {
                south_west,
                north_east,
            } => Some((*south_west, *north_east)),
            Self::PointRadius { center, radius_nm } => Some(bbox_around(*center, *radius_nm)),
            Self::LocationRadius { .. } => None,
            Self::Polygon { vertices } => polygon_bbox(vertices),
        }
    }

    /// Whether the area contains `point`, when that is decidable without a
    /// location database: `None` for [`Area::LocationRadius`] (mirroring
    /// the [`Self::enclosing_bbox`] contract), `Some` otherwise. This is
    /// the spatial-association hook TFR/NOTAM/weather modules filter
    /// geo-referenced products with.
    pub fn contains(&self, point: GeoPoint) -> Option<bool> {
        match self {
            Self::BoundingBox {
                south_west,
                north_east,
            } => Some(
                point.lat >= south_west.lat
                    && point.lat <= north_east.lat
                    && point.lon >= south_west.lon
                    && point.lon <= north_east.lon,
            ),
            Self::PointRadius { center, radius_nm } => {
                Some(crate::geo::distance_nm(*center, point) <= *radius_nm)
            }
            Self::LocationRadius { .. } => None,
            Self::Polygon { vertices } => Some(polygon_contains(vertices, point)),
        }
    }
}

/// Bounding box of a ring, unwrapping longitudes relative to the first
/// vertex so antimeridian-crossing rings stay contiguous.
fn polygon_bbox(vertices: &[GeoPoint]) -> Option<(GeoPoint, GeoPoint)> {
    let first = vertices.first()?;
    let mut min = *first;
    let mut max = *first;
    let mut reference = first.lon;
    for vertex in vertices {
        let lon = unwrap_lon(vertex.lon, reference);
        reference = lon;
        min.lat = min.lat.min(vertex.lat);
        min.lon = min.lon.min(lon);
        max.lat = max.lat.max(vertex.lat);
        max.lon = max.lon.max(lon);
    }
    Some((min, max))
}

/// Even-odd ray cast in lat/lon space with longitudes unwrapped to the
/// test point's neighborhood.
fn polygon_contains(vertices: &[GeoPoint], point: GeoPoint) -> bool {
    if vertices.len() < 3 {
        return false;
    }
    let mut inside = false;
    let mut j = vertices.len() - 1;
    for i in 0..vertices.len() {
        let (vi, vj) = (vertices[i], vertices[j]);
        let lon_i = unwrap_lon(vi.lon, point.lon);
        let lon_j = unwrap_lon(vj.lon, point.lon);
        if (vi.lat > point.lat) != (vj.lat > point.lat) {
            let denominator = vj.lat - vi.lat;
            if denominator.abs() > f64::EPSILON {
                let crossing = lon_i + (point.lat - vi.lat) / denominator * (lon_j - lon_i);
                if point.lon < crossing {
                    inside = !inside;
                }
            }
        }
        j = i;
    }
    inside
}

/// Shift `lon` by whole turns so it lies within 180° of `reference`.
fn unwrap_lon(lon: f64, reference: f64) -> f64 {
    let mut unwrapped = lon;
    while unwrapped - reference > 180.0 {
        unwrapped -= 360.0;
    }
    while unwrapped - reference < -180.0 {
        unwrapped += 360.0;
    }
    unwrapped
}

/// Bounding box of a circle, by the small-area equirectangular
/// approximation.
fn bbox_around(center: GeoPoint, radius_nm: f64) -> (GeoPoint, GeoPoint) {
    let lat_delta = radius_nm / 60.0;
    // Longitude degrees shrink with cos(lat); clamp so a circle near a
    // pole degrades to "all longitudes" instead of dividing by zero.
    let lon_scale = center.lat.to_radians().cos().max(1e-6);
    let lon_delta = (lat_delta / lon_scale).min(180.0);
    (
        GeoPoint {
            lat: (center.lat - lat_delta).max(-90.0),
            lon: center.lon - lon_delta,
        },
        GeoPoint {
            lat: (center.lat + lat_delta).min(90.0),
            lon: center.lon + lon_delta,
        },
    )
}

/// The kind of an individual briefing product.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ProductKind {
    /// Aerodrome routine/special weather report.
    Metar,
    /// Terminal aerodrome forecast.
    Taf,
    /// Pilot weather report.
    Pirep,
    /// Significant meteorological information.
    Sigmet,
    /// Airmen's meteorological information.
    Airmet,
    /// Graphical AIRMET.
    GAirmet,
    /// Center weather advisory.
    Cwa,
    /// Notice to air missions.
    Notam,
    /// A product this crate does not model yet, identified by the
    /// source's own name for it.
    Other(String),
}

/// A request for a simple area briefing.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AreaBriefingRequest {
    /// Area the briefing covers.
    pub area: Area,
    /// Products to include. Empty means the source's default set.
    pub products: Vec<ProductKind>,
    /// How many hours of history to include where a product has history
    /// (e.g. past METARs). `None` means the source's default.
    pub lookback_hours: Option<u32>,
    /// Intended departure time the briefing is for. `None` means "now".
    /// Sources that brief relative to a departure instant (Leidos) use
    /// it; observation-oriented sources may ignore it.
    pub departure_at: Option<DateTime<Utc>>,
}

/// A validity window, e.g. a TAF's forecast period `0418/0524`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidPeriod {
    /// Start of the window.
    pub start: DateTime<Utc>,
    /// End of the window.
    pub end: DateTime<Utc>,
}

impl ValidPeriod {
    /// A window spanning `start` to `end`.
    pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
        Self { start, end }
    }
}

/// One discrete weather product, kept verbatim as published.
///
/// `#[non_exhaustive]`: construct with [`Product::new`] and the `with_*`
/// setters, so a future source (e.g. FIS-B) can add fields without breaking
/// existing construction sites.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Product {
    /// What kind of product this is.
    pub kind: ProductKind,
    /// Station or location identifier when the product is tied to one.
    pub location: Option<String>,
    /// Issue/observation time when the source provides one.
    pub issued_at: Option<DateTime<Utc>>,
    /// Validity window when the product defines one (a TAF's forecast
    /// period); `None` for instantaneous reports such as a METAR.
    pub valid: Option<ValidPeriod>,
    /// Verbatim product text as published by the source.
    pub raw_text: String,
}

impl Product {
    /// A product of `kind` carrying its verbatim `raw_text`. Optional fields
    /// default to `None`; set them with the `with_*` builders.
    pub fn new(kind: ProductKind, raw_text: impl Into<String>) -> Self {
        Self {
            kind,
            location: None,
            issued_at: None,
            valid: None,
            raw_text: raw_text.into(),
        }
    }

    /// Set the station/location identifier.
    #[must_use]
    pub fn with_location(mut self, location: Option<String>) -> Self {
        self.location = location;
        self
    }

    /// Set the issue/observation time.
    #[must_use]
    pub fn with_issued_at(mut self, issued_at: Option<DateTime<Utc>>) -> Self {
        self.issued_at = issued_at;
        self
    }

    /// Set the validity window.
    #[must_use]
    pub fn with_valid(mut self, valid: Option<ValidPeriod>) -> Self {
        self.valid = valid;
        self
    }
}

/// A completed weather briefing.
///
/// Sources differ in shape: product-oriented APIs fill [`Self::products`],
/// document-oriented services (one rendered briefing text) fill
/// [`Self::narrative`]. Either may be empty, both may be present.
/// `#[non_exhaustive]`: construct with [`Briefing::new`] and the `with_*`
/// setters.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Briefing {
    /// [`crate::ContextProvider::name`] of the producing source.
    pub source: String,
    /// When the briefing was generated, if the source reports it.
    pub generated_at: Option<DateTime<Utc>>,
    /// Single-document briefing text, for document-oriented sources.
    pub narrative: Option<String>,
    /// Discrete products, for product-oriented sources.
    pub products: Vec<Product>,
}

impl Briefing {
    /// An empty briefing attributed to `source`; fill it with the `with_*`
    /// setters.
    pub fn new(source: impl Into<String>) -> Self {
        Self {
            source: source.into(),
            generated_at: None,
            narrative: None,
            products: Vec::new(),
        }
    }

    /// Set the generation time.
    #[must_use]
    pub fn with_generated_at(mut self, generated_at: Option<DateTime<Utc>>) -> Self {
        self.generated_at = generated_at;
        self
    }

    /// Set the single-document narrative.
    #[must_use]
    pub fn with_narrative(mut self, narrative: Option<String>) -> Self {
        self.narrative = narrative;
        self
    }

    /// Set the discrete products.
    #[must_use]
    pub fn with_products(mut self, products: Vec<Product>) -> Self {
        self.products = products;
        self
    }
}

#[cfg(test)]
mod tests;