aerocontext-core 0.4.1

Provider-neutral aeronautical-context model and the pluggable ContextProvider contract
Documentation
//! The route-shaped briefing request: a corridor along an ordered set of
//! waypoints, the seam that lets a provider answer for a whole flight
//! instead of one area.
//!
//! Mirrors [`AreaBriefingRequest`](crate::AreaBriefingRequest) for routes.
//! A provider that has a native route product (Leidos `RouteBriefing`)
//! serves it directly; a provider that only knows areas (AWC) fans the
//! corridor out into per-segment bounding boxes via [`RouteBriefingRequest::segment_bboxes`].

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

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

/// One ordered point on a route: an identifier when published, always a
/// position.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RouteWaypoint {
    /// Published identifier (`"KEWR"`, `"PSB"`), `None` for a bare
    /// lat/lon point.
    pub ident: Option<String>,
    /// WGS84 position.
    pub position: GeoPoint,
}

impl RouteWaypoint {
    /// A waypoint at `position` with no identifier.
    pub fn new(position: GeoPoint) -> Self {
        Self {
            ident: None,
            position,
        }
    }

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

/// Flight rules the route is flown under; selects the briefing scope a
/// source applies (IFR pulls FDC/route NOTAMs a VFR brief omits).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum FlightRules {
    /// Visual flight rules.
    Vfr,
    /// Instrument flight rules.
    Ifr,
}

/// A briefing request for a route corridor.
///
/// Non-exhaustive: construct with [`RouteBriefingRequest::new`] and the
/// `with_*` setters; future parameters (per-leg altitudes, alternates as
/// structured points) are additive.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RouteBriefingRequest {
    /// Ordered route waypoints, departure first, destination last.
    pub waypoints: Vec<RouteWaypoint>,
    /// Half-width of the corridor around the centerline, nautical miles.
    pub corridor_half_width_nm: f64,
    /// Planned cruise altitude, feet MSL.
    pub cruise_altitude_ft: Option<i32>,
    /// Planned cruise true airspeed, knots.
    pub cruise_tas_kt: Option<f64>,
    /// Intended departure time (ETD); anchors departure-relative sources.
    pub departure_at: Option<DateTime<Utc>>,
    /// Flight rules the route is flown under.
    pub flight_rules: FlightRules,
    /// Products to include; empty means the source's default set.
    pub products: Vec<ProductKind>,
}

impl RouteBriefingRequest {
    /// A request over `waypoints` with a `corridor_half_width_nm`
    /// corridor, IFR, and source-default products.
    pub fn new(waypoints: Vec<RouteWaypoint>, corridor_half_width_nm: f64) -> Self {
        Self {
            waypoints,
            corridor_half_width_nm,
            cruise_altitude_ft: None,
            cruise_tas_kt: None,
            departure_at: None,
            flight_rules: FlightRules::Ifr,
            products: Vec::new(),
        }
    }

    /// Set the cruise altitude (feet MSL).
    #[must_use]
    pub fn with_cruise_altitude_ft(mut self, ft: Option<i32>) -> Self {
        self.cruise_altitude_ft = ft;
        self
    }

    /// Set the cruise true airspeed (knots).
    #[must_use]
    pub fn with_cruise_tas_kt(mut self, kt: Option<f64>) -> Self {
        self.cruise_tas_kt = kt;
        self
    }

    /// Anchor the briefing to an intended departure time.
    #[must_use]
    pub fn with_departure_at(mut self, at: Option<DateTime<Utc>>) -> Self {
        self.departure_at = at;
        self
    }

    /// Set the flight rules.
    #[must_use]
    pub fn with_flight_rules(mut self, rules: FlightRules) -> Self {
        self.flight_rules = rules;
        self
    }

    /// Restrict to the given product kinds (empty = source default set).
    #[must_use]
    pub fn with_products(mut self, products: Vec<ProductKind>) -> Self {
        self.products = products;
        self
    }

    /// Total great-circle length of the route, nautical miles.
    #[must_use]
    pub fn total_distance_nm(&self) -> f64 {
        self.waypoints
            .windows(2)
            .map(|leg| geo::distance_nm(leg[0].position, leg[1].position))
            .sum()
    }

    /// The published identifiers along the route, in order, skipping bare
    /// lat/lon points.
    #[must_use]
    pub fn idents(&self) -> Vec<&str> {
        self.waypoints
            .iter()
            .filter_map(|w| w.ident.as_deref())
            .collect()
    }

    /// Cover the corridor with axis-aligned bounding boxes, each spanning
    /// at most `max_segment_nm` of route, padded by the corridor
    /// half-width. An area-only provider (AWC) issues one request per box
    /// — fewer, larger boxes near the cap; one box for a short route.
    #[must_use]
    pub fn segment_bboxes(&self, max_segment_nm: f64) -> Vec<Area> {
        let max_segment_nm = max_segment_nm.max(1.0);
        let centerline = self.densified_centerline(max_segment_nm);
        let Some((first, rest)) = centerline.split_first() else {
            return Vec::new();
        };
        if rest.is_empty() {
            return vec![padded_bbox(&[*first], self.corridor_half_width_nm.max(1.0))];
        }
        let mut boxes = Vec::new();
        let mut segment = vec![*first];
        let mut span = 0.0;
        let mut prev = *first;
        for &point in rest {
            span += geo::distance_nm(prev, point);
            segment.push(point);
            prev = point;
            if span >= max_segment_nm {
                boxes.push(padded_bbox(&segment, self.corridor_half_width_nm));
                segment = vec![point];
                span = 0.0;
            }
        }
        if segment.len() >= 2 {
            boxes.push(padded_bbox(&segment, self.corridor_half_width_nm));
        }
        boxes
    }

    /// The route centerline with interior points inserted so no gap
    /// exceeds `step_nm` — a single long DCT leg yields tight boxes
    /// instead of one continent-spanning one.
    fn densified_centerline(&self, step_nm: f64) -> Vec<GeoPoint> {
        let mut points = Vec::new();
        let mut iter = self.waypoints.iter();
        let Some(first) = iter.next() else {
            return points;
        };
        points.push(first.position);
        let mut from = first.position;
        for next in iter {
            let to = next.position;
            let leg = geo::distance_nm(from, to);
            if leg > step_nm {
                let bearing = geo::initial_bearing_deg(from, to);
                let steps = (leg / step_nm).floor() as u32;
                for i in 1..=steps {
                    let along = step_nm * f64::from(i);
                    if along < leg {
                        points.push(geo::destination(from, bearing, along));
                    }
                }
            }
            points.push(to);
            from = to;
        }
        points
    }
}

/// Bounding box enclosing `points`, padded outward by `pad_nm` on every
/// side (latitude degrees are uniform; longitude degrees widen with the
/// cosine of the box's worst-case latitude so the pad never shrinks).
fn padded_bbox(points: &[GeoPoint], pad_nm: f64) -> Area {
    let mut min_lat = f64::INFINITY;
    let mut max_lat = f64::NEG_INFINITY;
    let mut min_lon = f64::INFINITY;
    let mut max_lon = f64::NEG_INFINITY;
    for p in points {
        min_lat = min_lat.min(p.lat);
        max_lat = max_lat.max(p.lat);
        min_lon = min_lon.min(p.lon);
        max_lon = max_lon.max(p.lon);
    }
    let lat_pad = pad_nm / 60.0;
    let worst_lat = min_lat.abs().max(max_lat.abs()).min(89.0);
    let lon_pad = pad_nm / (60.0 * worst_lat.to_radians().cos().max(0.01));
    Area::BoundingBox {
        south_west: GeoPoint {
            lat: (min_lat - lat_pad).max(-90.0),
            lon: min_lon - lon_pad,
        },
        north_east: GeoPoint {
            lat: (max_lat + lat_pad).min(90.0),
            lon: max_lon + lon_pad,
        },
    }
}

#[cfg(test)]
mod tests;