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    let mut unwrapped = lon;
147    while unwrapped - reference > 180.0 {
148        unwrapped -= 360.0;
149    }
150    while unwrapped - reference < -180.0 {
151        unwrapped += 360.0;
152    }
153    unwrapped
154}
155
156/// Bounding box of a circle, by the small-area equirectangular
157/// approximation.
158fn bbox_around(center: GeoPoint, radius_nm: f64) -> (GeoPoint, GeoPoint) {
159    let lat_delta = radius_nm / 60.0;
160    // Longitude degrees shrink with cos(lat); clamp so a circle near a
161    // pole degrades to "all longitudes" instead of dividing by zero.
162    let lon_scale = center.lat.to_radians().cos().max(1e-6);
163    let lon_delta = (lat_delta / lon_scale).min(180.0);
164    (
165        GeoPoint {
166            lat: (center.lat - lat_delta).max(-90.0),
167            lon: center.lon - lon_delta,
168        },
169        GeoPoint {
170            lat: (center.lat + lat_delta).min(90.0),
171            lon: center.lon + lon_delta,
172        },
173    )
174}
175
176/// The kind of an individual briefing product.
177#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
178#[non_exhaustive]
179pub enum ProductKind {
180    /// Aerodrome routine/special weather report.
181    Metar,
182    /// Terminal aerodrome forecast.
183    Taf,
184    /// Pilot weather report.
185    Pirep,
186    /// Significant meteorological information.
187    Sigmet,
188    /// Airmen's meteorological information.
189    Airmet,
190    /// Graphical AIRMET.
191    GAirmet,
192    /// Center weather advisory.
193    Cwa,
194    /// Notice to air missions.
195    Notam,
196    /// A product this crate does not model yet, identified by the
197    /// source's own name for it.
198    Other(String),
199}
200
201/// A request for a simple area briefing.
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203pub struct AreaBriefingRequest {
204    /// Area the briefing covers.
205    pub area: Area,
206    /// Products to include. Empty means the source's default set.
207    pub products: Vec<ProductKind>,
208    /// How many hours of history to include where a product has history
209    /// (e.g. past METARs). `None` means the source's default.
210    pub lookback_hours: Option<u32>,
211    /// Intended departure time the briefing is for. `None` means "now".
212    /// Sources that brief relative to a departure instant (Leidos) use
213    /// it; observation-oriented sources may ignore it.
214    pub departure_at: Option<DateTime<Utc>>,
215}
216
217/// A validity window, e.g. a TAF's forecast period `0418/0524`.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219pub struct ValidPeriod {
220    /// Start of the window.
221    pub start: DateTime<Utc>,
222    /// End of the window.
223    pub end: DateTime<Utc>,
224}
225
226impl ValidPeriod {
227    /// A window spanning `start` to `end`.
228    pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
229        Self { start, end }
230    }
231}
232
233/// One discrete weather product, kept verbatim as published.
234///
235/// `#[non_exhaustive]`: construct with [`Product::new`] and the `with_*`
236/// setters, so a future source (e.g. FIS-B) can add fields without breaking
237/// existing construction sites.
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239#[non_exhaustive]
240pub struct Product {
241    /// What kind of product this is.
242    pub kind: ProductKind,
243    /// Station or location identifier when the product is tied to one.
244    pub location: Option<String>,
245    /// Issue/observation time when the source provides one.
246    pub issued_at: Option<DateTime<Utc>>,
247    /// Validity window when the product defines one (a TAF's forecast
248    /// period); `None` for instantaneous reports such as a METAR.
249    pub valid: Option<ValidPeriod>,
250    /// Verbatim product text as published by the source.
251    pub raw_text: String,
252}
253
254impl Product {
255    /// A product of `kind` carrying its verbatim `raw_text`. Optional fields
256    /// default to `None`; set them with the `with_*` builders.
257    pub fn new(kind: ProductKind, raw_text: impl Into<String>) -> Self {
258        Self {
259            kind,
260            location: None,
261            issued_at: None,
262            valid: None,
263            raw_text: raw_text.into(),
264        }
265    }
266
267    /// Set the station/location identifier.
268    #[must_use]
269    pub fn with_location(mut self, location: Option<String>) -> Self {
270        self.location = location;
271        self
272    }
273
274    /// Set the issue/observation time.
275    #[must_use]
276    pub fn with_issued_at(mut self, issued_at: Option<DateTime<Utc>>) -> Self {
277        self.issued_at = issued_at;
278        self
279    }
280
281    /// Set the validity window.
282    #[must_use]
283    pub fn with_valid(mut self, valid: Option<ValidPeriod>) -> Self {
284        self.valid = valid;
285        self
286    }
287}
288
289/// A completed weather briefing.
290///
291/// Sources differ in shape: product-oriented APIs fill [`Self::products`],
292/// document-oriented services (one rendered briefing text) fill
293/// [`Self::narrative`]. Either may be empty, both may be present.
294/// `#[non_exhaustive]`: construct with [`Briefing::new`] and the `with_*`
295/// setters.
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297#[non_exhaustive]
298pub struct Briefing {
299    /// [`crate::ContextProvider::name`] of the producing source.
300    pub source: String,
301    /// When the briefing was generated, if the source reports it.
302    pub generated_at: Option<DateTime<Utc>>,
303    /// Single-document briefing text, for document-oriented sources.
304    pub narrative: Option<String>,
305    /// Discrete products, for product-oriented sources.
306    pub products: Vec<Product>,
307}
308
309impl Briefing {
310    /// An empty briefing attributed to `source`; fill it with the `with_*`
311    /// setters.
312    pub fn new(source: impl Into<String>) -> Self {
313        Self {
314            source: source.into(),
315            generated_at: None,
316            narrative: None,
317            products: Vec::new(),
318        }
319    }
320
321    /// Set the generation time.
322    #[must_use]
323    pub fn with_generated_at(mut self, generated_at: Option<DateTime<Utc>>) -> Self {
324        self.generated_at = generated_at;
325        self
326    }
327
328    /// Set the single-document narrative.
329    #[must_use]
330    pub fn with_narrative(mut self, narrative: Option<String>) -> Self {
331        self.narrative = narrative;
332        self
333    }
334
335    /// Set the discrete products.
336    #[must_use]
337    pub fn with_products(mut self, products: Vec<Product>) -> Self {
338        self.products = products;
339        self
340    }
341}
342
343#[cfg(test)]
344mod tests;