Skip to main content

aerocontext_core/
route_request.rs

1//! The route-shaped briefing request: a corridor along an ordered set of
2//! waypoints, the seam that lets a provider answer for a whole flight
3//! instead of one area.
4//!
5//! Mirrors [`AreaBriefingRequest`](crate::AreaBriefingRequest) for routes.
6//! A provider that has a native route product (Leidos `RouteBriefing`)
7//! serves it directly; a provider that only knows areas (AWC) fans the
8//! corridor out into per-segment bounding boxes via [`RouteBriefingRequest::segment_bboxes`].
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::geo;
14use crate::model::{Area, GeoPoint, ProductKind};
15
16/// One ordered point on a route: an identifier when published, always a
17/// position.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct RouteWaypoint {
21    /// Published identifier (`"KEWR"`, `"PSB"`), `None` for a bare
22    /// lat/lon point.
23    pub ident: Option<String>,
24    /// WGS84 position.
25    pub position: GeoPoint,
26}
27
28impl RouteWaypoint {
29    /// A waypoint at `position` with no identifier.
30    pub fn new(position: GeoPoint) -> Self {
31        Self {
32            ident: None,
33            position,
34        }
35    }
36
37    /// Set the identifier.
38    #[must_use]
39    pub fn with_ident(mut self, ident: Option<String>) -> Self {
40        self.ident = ident;
41        self
42    }
43}
44
45/// Flight rules the route is flown under; selects the briefing scope a
46/// source applies (IFR pulls FDC/route NOTAMs a VFR brief omits).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[non_exhaustive]
49pub enum FlightRules {
50    /// Visual flight rules.
51    Vfr,
52    /// Instrument flight rules.
53    Ifr,
54}
55
56/// A briefing request for a route corridor.
57///
58/// Non-exhaustive: construct with [`RouteBriefingRequest::new`] and the
59/// `with_*` setters; future parameters (per-leg altitudes, alternates as
60/// structured points) are additive.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[non_exhaustive]
63pub struct RouteBriefingRequest {
64    /// Ordered route waypoints, departure first, destination last.
65    pub waypoints: Vec<RouteWaypoint>,
66    /// Half-width of the corridor around the centerline, nautical miles.
67    pub corridor_half_width_nm: f64,
68    /// Planned cruise altitude, feet MSL.
69    pub cruise_altitude_ft: Option<i32>,
70    /// Planned cruise true airspeed, knots.
71    pub cruise_tas_kt: Option<f64>,
72    /// Intended departure time (ETD); anchors departure-relative sources.
73    pub departure_at: Option<DateTime<Utc>>,
74    /// Flight rules the route is flown under.
75    pub flight_rules: FlightRules,
76    /// Products to include; empty means the source's default set.
77    pub products: Vec<ProductKind>,
78}
79
80impl RouteBriefingRequest {
81    /// A request over `waypoints` with a `corridor_half_width_nm`
82    /// corridor, IFR, and source-default products.
83    pub fn new(waypoints: Vec<RouteWaypoint>, corridor_half_width_nm: f64) -> Self {
84        Self {
85            waypoints,
86            corridor_half_width_nm,
87            cruise_altitude_ft: None,
88            cruise_tas_kt: None,
89            departure_at: None,
90            flight_rules: FlightRules::Ifr,
91            products: Vec::new(),
92        }
93    }
94
95    /// Set the cruise altitude (feet MSL).
96    #[must_use]
97    pub fn with_cruise_altitude_ft(mut self, ft: Option<i32>) -> Self {
98        self.cruise_altitude_ft = ft;
99        self
100    }
101
102    /// Set the cruise true airspeed (knots).
103    #[must_use]
104    pub fn with_cruise_tas_kt(mut self, kt: Option<f64>) -> Self {
105        self.cruise_tas_kt = kt;
106        self
107    }
108
109    /// Anchor the briefing to an intended departure time.
110    #[must_use]
111    pub fn with_departure_at(mut self, at: Option<DateTime<Utc>>) -> Self {
112        self.departure_at = at;
113        self
114    }
115
116    /// Set the flight rules.
117    #[must_use]
118    pub fn with_flight_rules(mut self, rules: FlightRules) -> Self {
119        self.flight_rules = rules;
120        self
121    }
122
123    /// Restrict to the given product kinds (empty = source default set).
124    #[must_use]
125    pub fn with_products(mut self, products: Vec<ProductKind>) -> Self {
126        self.products = products;
127        self
128    }
129
130    /// Total great-circle length of the route, nautical miles.
131    #[must_use]
132    pub fn total_distance_nm(&self) -> f64 {
133        self.waypoints
134            .windows(2)
135            .map(|leg| geo::distance_nm(leg[0].position, leg[1].position))
136            .sum()
137    }
138
139    /// The published identifiers along the route, in order, skipping bare
140    /// lat/lon points.
141    #[must_use]
142    pub fn idents(&self) -> Vec<&str> {
143        self.waypoints
144            .iter()
145            .filter_map(|w| w.ident.as_deref())
146            .collect()
147    }
148
149    /// Cover the corridor with axis-aligned bounding boxes, each spanning
150    /// at most `max_segment_nm` of route, padded by the corridor
151    /// half-width. An area-only provider (AWC) issues one request per box
152    /// — fewer, larger boxes near the cap; one box for a short route.
153    #[must_use]
154    pub fn segment_bboxes(&self, max_segment_nm: f64) -> Vec<Area> {
155        let max_segment_nm = max_segment_nm.max(1.0);
156        let centerline = self.densified_centerline(max_segment_nm);
157        let Some((first, rest)) = centerline.split_first() else {
158            return Vec::new();
159        };
160        if rest.is_empty() {
161            return vec![padded_bbox(&[*first], self.corridor_half_width_nm.max(1.0))];
162        }
163        let mut boxes = Vec::new();
164        let mut segment = vec![*first];
165        let mut span = 0.0;
166        let mut prev = *first;
167        for &point in rest {
168            span += geo::distance_nm(prev, point);
169            segment.push(point);
170            prev = point;
171            if span >= max_segment_nm {
172                boxes.push(padded_bbox(&segment, self.corridor_half_width_nm));
173                segment = vec![point];
174                span = 0.0;
175            }
176        }
177        if segment.len() >= 2 {
178            boxes.push(padded_bbox(&segment, self.corridor_half_width_nm));
179        }
180        boxes
181    }
182
183    /// The route centerline with interior points inserted so no gap
184    /// exceeds `step_nm` — a single long DCT leg yields tight boxes
185    /// instead of one continent-spanning one.
186    fn densified_centerline(&self, step_nm: f64) -> Vec<GeoPoint> {
187        let mut points = Vec::new();
188        let mut iter = self.waypoints.iter();
189        let Some(first) = iter.next() else {
190            return points;
191        };
192        points.push(first.position);
193        let mut from = first.position;
194        for next in iter {
195            let to = next.position;
196            let leg = geo::distance_nm(from, to);
197            if leg > step_nm {
198                let bearing = geo::initial_bearing_deg(from, to);
199                let steps = (leg / step_nm).floor() as u32;
200                for i in 1..=steps {
201                    let along = step_nm * f64::from(i);
202                    if along < leg {
203                        points.push(geo::destination(from, bearing, along));
204                    }
205                }
206            }
207            points.push(to);
208            from = to;
209        }
210        points
211    }
212}
213
214/// Bounding box enclosing `points`, padded outward by `pad_nm` on every
215/// side (latitude degrees are uniform; longitude degrees widen with the
216/// cosine of the box's worst-case latitude so the pad never shrinks).
217fn padded_bbox(points: &[GeoPoint], pad_nm: f64) -> Area {
218    let mut min_lat = f64::INFINITY;
219    let mut max_lat = f64::NEG_INFINITY;
220    let mut min_lon = f64::INFINITY;
221    let mut max_lon = f64::NEG_INFINITY;
222    for p in points {
223        min_lat = min_lat.min(p.lat);
224        max_lat = max_lat.max(p.lat);
225        min_lon = min_lon.min(p.lon);
226        max_lon = max_lon.max(p.lon);
227    }
228    let lat_pad = pad_nm / 60.0;
229    let worst_lat = min_lat.abs().max(max_lat.abs()).min(89.0);
230    let lon_pad = pad_nm / (60.0 * worst_lat.to_radians().cos().max(0.01));
231    Area::BoundingBox {
232        south_west: GeoPoint {
233            lat: (min_lat - lat_pad).max(-90.0),
234            lon: min_lon - lon_pad,
235        },
236        north_east: GeoPoint {
237            lat: (max_lat + lat_pad).min(90.0),
238            lon: max_lon + lon_pad,
239        },
240    }
241}
242
243#[cfg(test)]
244mod tests;