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;