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 single-document narrative.
378 #[must_use]
379 pub fn with_narrative(mut self, narrative: Option<String>) -> Self {
380 self.narrative = narrative;
381 self
382 }
383
384 /// Set the discrete products.
385 #[must_use]
386 pub fn with_products(mut self, products: Vec<Product>) -> Self {
387 self.products = products;
388 self
389 }
390}
391
392#[cfg(test)]
393mod tests;