Skip to main content

aerocontext_core/
model.rs

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