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;