Skip to main content

aerocontext_core/
model.rs

1//! Provider-neutral briefing domain types.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// A geographic point in WGS84 decimal degrees.
7#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
8pub struct GeoPoint {
9    /// Latitude in degrees, north positive.
10    pub lat: f64,
11    /// Longitude in degrees, east positive.
12    pub lon: f64,
13}
14
15/// The geographic area a briefing covers.
16///
17/// Not every source supports every shape natively; adapters either convert
18/// (a point radius encloses a bounding box and vice versa) or return
19/// [`crate::ProviderError::Unsupported`].
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21#[non_exhaustive]
22pub enum Area {
23    /// Axis-aligned bounding box.
24    BoundingBox {
25        /// South-west corner.
26        south_west: GeoPoint,
27        /// North-east corner.
28        north_east: GeoPoint,
29    },
30    /// Circle of `radius_nm` nautical miles around a coordinate.
31    PointRadius {
32        /// Circle center.
33        center: GeoPoint,
34        /// Radius in nautical miles.
35        radius_nm: f64,
36    },
37    /// Circle of `radius_nm` nautical miles around a published location
38    /// (ICAO/FAA aerodrome, navaid or fix identifier).
39    LocationRadius {
40        /// Location identifier, e.g. `"KSFO"`.
41        ident: String,
42        /// Radius in nautical miles.
43        radius_nm: f64,
44    },
45    /// A polygon over `vertices`, treated as an implicitly closed ring
46    /// (the last vertex connects back to the first). Vertex order may be
47    /// either winding; self-intersecting rings give ray-cast semantics.
48    Polygon {
49        /// Ring vertices, at least three.
50        vertices: Vec<GeoPoint>,
51    },
52}
53
54impl Area {
55    /// Smallest axis-aligned bounding box enclosing this area, as
56    /// `(south_west, north_east)`, when one can be computed without a
57    /// location database.
58    ///
59    /// Returns `None` for areas anchored to a published identifier; only
60    /// the provider can resolve those. The radius conversion uses the
61    /// small-area approximation (1 NM of latitude = 1/60 degree) and is
62    /// not meaningful within a radius of the poles.
63    pub fn enclosing_bbox(&self) -> Option<(GeoPoint, GeoPoint)> {
64        match self {
65            Self::BoundingBox {
66                south_west,
67                north_east,
68            } => Some((*south_west, *north_east)),
69            Self::PointRadius { center, radius_nm } => Some(bbox_around(*center, *radius_nm)),
70            Self::LocationRadius { .. } => None,
71            Self::Polygon { vertices } => polygon_bbox(vertices),
72        }
73    }
74
75    /// Whether the area contains `point`, when that is decidable without a
76    /// location database: `None` for [`Area::LocationRadius`] (mirroring
77    /// the [`Self::enclosing_bbox`] contract), `Some` otherwise. This is
78    /// the spatial-association hook TFR/NOTAM/weather modules filter
79    /// geo-referenced products with.
80    pub fn contains(&self, point: GeoPoint) -> Option<bool> {
81        match self {
82            Self::BoundingBox {
83                south_west,
84                north_east,
85            } => Some(
86                point.lat >= south_west.lat
87                    && point.lat <= north_east.lat
88                    && point.lon >= south_west.lon
89                    && point.lon <= north_east.lon,
90            ),
91            Self::PointRadius { center, radius_nm } => {
92                Some(crate::geo::distance_nm(*center, point) <= *radius_nm)
93            }
94            Self::LocationRadius { .. } => None,
95            Self::Polygon { vertices } => Some(polygon_contains(vertices, point)),
96        }
97    }
98}
99
100/// Bounding box of a ring, unwrapping longitudes relative to the first
101/// vertex so antimeridian-crossing rings stay contiguous.
102fn polygon_bbox(vertices: &[GeoPoint]) -> Option<(GeoPoint, GeoPoint)> {
103    let first = vertices.first()?;
104    let mut min = *first;
105    let mut max = *first;
106    let mut reference = first.lon;
107    for vertex in vertices {
108        let lon = unwrap_lon(vertex.lon, reference);
109        reference = lon;
110        min.lat = min.lat.min(vertex.lat);
111        min.lon = min.lon.min(lon);
112        max.lat = max.lat.max(vertex.lat);
113        max.lon = max.lon.max(lon);
114    }
115    Some((min, max))
116}
117
118/// Even-odd ray cast in lat/lon space with longitudes unwrapped to the
119/// test point's neighborhood.
120fn polygon_contains(vertices: &[GeoPoint], point: GeoPoint) -> bool {
121    if vertices.len() < 3 {
122        return false;
123    }
124    let mut inside = false;
125    let mut j = vertices.len() - 1;
126    for i in 0..vertices.len() {
127        let (vi, vj) = (vertices[i], vertices[j]);
128        let lon_i = unwrap_lon(vi.lon, point.lon);
129        let lon_j = unwrap_lon(vj.lon, point.lon);
130        if (vi.lat > point.lat) != (vj.lat > point.lat) {
131            let denominator = vj.lat - vi.lat;
132            if denominator.abs() > f64::EPSILON {
133                let crossing = lon_i + (point.lat - vi.lat) / denominator * (lon_j - lon_i);
134                if point.lon < crossing {
135                    inside = !inside;
136                }
137            }
138        }
139        j = i;
140    }
141    inside
142}
143
144/// Shift `lon` by whole turns so it lies within 180° of `reference`.
145fn unwrap_lon(lon: f64, reference: f64) -> f64 {
146    // A non-finite input would loop forever (infinity never decreases)
147    // or effectively forever (1e308 needs ~3e305 turns); hand it back
148    // and let the NaN comparisons downstream evaluate false.
149    if !lon.is_finite() || !reference.is_finite() {
150        return lon;
151    }
152    let mut unwrapped = lon;
153    while unwrapped - reference > 180.0 {
154        unwrapped -= 360.0;
155    }
156    while unwrapped - reference < -180.0 {
157        unwrapped += 360.0;
158    }
159    unwrapped
160}
161
162/// Bounding box of a circle, by the small-area equirectangular
163/// approximation.
164fn bbox_around(center: GeoPoint, radius_nm: f64) -> (GeoPoint, GeoPoint) {
165    let lat_delta = radius_nm / 60.0;
166    // Longitude degrees shrink with cos(lat); clamp so a circle near a
167    // pole degrades to "all longitudes" instead of dividing by zero.
168    let lon_scale = center.lat.to_radians().cos().max(1e-6);
169    let lon_delta = (lat_delta / lon_scale).min(180.0);
170    (
171        GeoPoint {
172            lat: (center.lat - lat_delta).max(-90.0),
173            lon: center.lon - lon_delta,
174        },
175        GeoPoint {
176            lat: (center.lat + lat_delta).min(90.0),
177            lon: center.lon + lon_delta,
178        },
179    )
180}
181
182/// The kind of an individual briefing product.
183#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
184#[non_exhaustive]
185pub enum ProductKind {
186    /// Aerodrome routine/special weather report.
187    Metar,
188    /// Terminal aerodrome forecast.
189    Taf,
190    /// Pilot weather report.
191    Pirep,
192    /// Significant meteorological information.
193    Sigmet,
194    /// Airmen's meteorological information.
195    Airmet,
196    /// Graphical AIRMET.
197    GAirmet,
198    /// Center weather advisory.
199    Cwa,
200    /// Notice to air missions.
201    Notam,
202    /// A product this crate does not model yet, identified by the
203    /// source's own name for it.
204    Other(String),
205}
206
207/// A request for a simple area briefing.
208///
209/// Non-exhaustive: future capability parameters (route, altitude band,
210/// aircraft type, product filters) land here — construct with
211/// [`AreaBriefingRequest::new`] plus the `with_*` setters.
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[non_exhaustive]
214pub struct AreaBriefingRequest {
215    /// Area the briefing covers.
216    pub area: Area,
217    /// Products to include. Empty means the source's default set.
218    pub products: Vec<ProductKind>,
219    /// How many hours of history to include where a product has history
220    /// (e.g. past METARs). `None` means the source's default.
221    pub lookback_hours: Option<u32>,
222    /// Intended departure time the briefing is for. `None` means "now".
223    /// Sources that brief relative to a departure instant (Leidos) use
224    /// it; observation-oriented sources may ignore it.
225    pub departure_at: Option<DateTime<Utc>>,
226}
227
228impl AreaBriefingRequest {
229    /// A briefing request for `area` with every source default.
230    pub fn new(area: Area) -> Self {
231        Self {
232            area,
233            products: Vec::new(),
234            lookback_hours: None,
235            departure_at: None,
236        }
237    }
238
239    /// Restrict to the given product kinds (empty = source default set).
240    #[must_use]
241    pub fn with_products(mut self, products: Vec<ProductKind>) -> Self {
242        self.products = products;
243        self
244    }
245
246    /// Include this many hours of history where a product has history.
247    #[must_use]
248    pub fn with_lookback_hours(mut self, hours: Option<u32>) -> Self {
249        self.lookback_hours = hours;
250        self
251    }
252
253    /// Anchor the briefing to an intended departure time (ETD).
254    #[must_use]
255    pub fn with_departure_at(mut self, at: Option<DateTime<Utc>>) -> Self {
256        self.departure_at = at;
257        self
258    }
259}
260
261/// A validity window, e.g. a TAF's forecast period `0418/0524`.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
263pub struct ValidPeriod {
264    /// Start of the window.
265    pub start: DateTime<Utc>,
266    /// End of the window.
267    pub end: DateTime<Utc>,
268}
269
270impl ValidPeriod {
271    /// A window spanning `start` to `end`.
272    pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
273        Self { start, end }
274    }
275}
276
277/// One discrete weather product, kept verbatim as published.
278///
279/// `#[non_exhaustive]`: construct with [`Product::new`] and the `with_*`
280/// setters, so a future source (e.g. FIS-B) can add fields without breaking
281/// existing construction sites.
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283#[non_exhaustive]
284pub struct Product {
285    /// What kind of product this is.
286    pub kind: ProductKind,
287    /// Station or location identifier when the product is tied to one.
288    pub location: Option<String>,
289    /// Issue/observation time when the source provides one.
290    pub issued_at: Option<DateTime<Utc>>,
291    /// Validity window when the product defines one (a TAF's forecast
292    /// period); `None` for instantaneous reports such as a METAR.
293    pub valid: Option<ValidPeriod>,
294    /// Verbatim product text as published by the source.
295    pub raw_text: String,
296}
297
298impl Product {
299    /// A product of `kind` carrying its verbatim `raw_text`. Optional fields
300    /// default to `None`; set them with the `with_*` builders.
301    pub fn new(kind: ProductKind, raw_text: impl Into<String>) -> Self {
302        Self {
303            kind,
304            location: None,
305            issued_at: None,
306            valid: None,
307            raw_text: raw_text.into(),
308        }
309    }
310
311    /// Set the station/location identifier.
312    #[must_use]
313    pub fn with_location(mut self, location: Option<String>) -> Self {
314        self.location = location;
315        self
316    }
317
318    /// Set the issue/observation time.
319    #[must_use]
320    pub fn with_issued_at(mut self, issued_at: Option<DateTime<Utc>>) -> Self {
321        self.issued_at = issued_at;
322        self
323    }
324
325    /// Set the validity window.
326    #[must_use]
327    pub fn with_valid(mut self, valid: Option<ValidPeriod>) -> Self {
328        self.valid = valid;
329        self
330    }
331}
332
333/// A completed weather briefing.
334///
335/// Sources differ in shape: product-oriented APIs fill [`Self::products`],
336/// document-oriented services (one rendered briefing text) fill
337/// [`Self::narrative`]. Either may be empty, both may be present.
338/// `#[non_exhaustive]`: construct with [`Briefing::new`] and the `with_*`
339/// setters.
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
341#[non_exhaustive]
342pub struct Briefing {
343    /// [`crate::ContextProvider::name`] of the producing source.
344    pub source: String,
345    /// When this briefing was assembled by the adapter (fetch time,
346    /// UTC). Provider-stated product times live on each [`Product`].
347    pub generated_at: Option<DateTime<Utc>>,
348    /// The departure time this briefing answered for, when the request
349    /// was ETD-anchored — `None` means "conditions now".
350    pub departure_at: Option<DateTime<Utc>>,
351    /// Single-document briefing text, for document-oriented sources.
352    pub narrative: Option<String>,
353    /// Discrete products, for product-oriented sources.
354    pub products: Vec<Product>,
355}
356
357impl Briefing {
358    /// An empty briefing attributed to `source`; fill it with the `with_*`
359    /// setters.
360    pub fn new(source: impl Into<String>) -> Self {
361        Self {
362            source: source.into(),
363            generated_at: None,
364            departure_at: None,
365            narrative: None,
366            products: Vec::new(),
367        }
368    }
369
370    /// Set the generation time.
371    #[must_use]
372    pub fn with_generated_at(mut self, generated_at: Option<DateTime<Utc>>) -> Self {
373        self.generated_at = generated_at;
374        self
375    }
376
377    /// Set the departure time this briefing answered for — only for
378    /// sources that genuinely anchor their window to it.
379    #[must_use]
380    pub fn with_departure_at(mut self, at: Option<DateTime<Utc>>) -> Self {
381        self.departure_at = at;
382        self
383    }
384
385    /// Set the single-document narrative.
386    #[must_use]
387    pub fn with_narrative(mut self, narrative: Option<String>) -> Self {
388        self.narrative = narrative;
389        self
390    }
391
392    /// Set the discrete products.
393    #[must_use]
394    pub fn with_products(mut self, products: Vec<Product>) -> Self {
395        self.products = products;
396        self
397    }
398}
399
400#[cfg(test)]
401mod tests;