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}
46
47impl Area {
48    /// Smallest axis-aligned bounding box enclosing this area, as
49    /// `(south_west, north_east)`, when one can be computed without a
50    /// location database.
51    ///
52    /// Returns `None` for areas anchored to a published identifier; only
53    /// the provider can resolve those. The radius conversion uses the
54    /// small-area approximation (1 NM of latitude = 1/60 degree) and is
55    /// not meaningful within a radius of the poles.
56    pub fn enclosing_bbox(&self) -> Option<(GeoPoint, GeoPoint)> {
57        match self {
58            Self::BoundingBox {
59                south_west,
60                north_east,
61            } => Some((*south_west, *north_east)),
62            Self::PointRadius { center, radius_nm } => Some(bbox_around(*center, *radius_nm)),
63            Self::LocationRadius { .. } => None,
64        }
65    }
66}
67
68/// Bounding box of a circle, by the small-area equirectangular
69/// approximation.
70fn bbox_around(center: GeoPoint, radius_nm: f64) -> (GeoPoint, GeoPoint) {
71    let lat_delta = radius_nm / 60.0;
72    // Longitude degrees shrink with cos(lat); clamp so a circle near a
73    // pole degrades to "all longitudes" instead of dividing by zero.
74    let lon_scale = center.lat.to_radians().cos().max(1e-6);
75    let lon_delta = (lat_delta / lon_scale).min(180.0);
76    (
77        GeoPoint {
78            lat: (center.lat - lat_delta).max(-90.0),
79            lon: center.lon - lon_delta,
80        },
81        GeoPoint {
82            lat: (center.lat + lat_delta).min(90.0),
83            lon: center.lon + lon_delta,
84        },
85    )
86}
87
88/// The kind of an individual briefing product.
89#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
90#[non_exhaustive]
91pub enum ProductKind {
92    /// Aerodrome routine/special weather report.
93    Metar,
94    /// Terminal aerodrome forecast.
95    Taf,
96    /// Pilot weather report.
97    Pirep,
98    /// Significant meteorological information.
99    Sigmet,
100    /// Airmen's meteorological information.
101    Airmet,
102    /// Graphical AIRMET.
103    GAirmet,
104    /// Center weather advisory.
105    Cwa,
106    /// Notice to air missions.
107    Notam,
108    /// A product this crate does not model yet, identified by the
109    /// source's own name for it.
110    Other(String),
111}
112
113/// A request for a simple area briefing.
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115pub struct AreaBriefingRequest {
116    /// Area the briefing covers.
117    pub area: Area,
118    /// Products to include. Empty means the source's default set.
119    pub products: Vec<ProductKind>,
120    /// How many hours of history to include where a product has history
121    /// (e.g. past METARs). `None` means the source's default.
122    pub lookback_hours: Option<u32>,
123    /// Intended departure time the briefing is for. `None` means "now".
124    /// Sources that brief relative to a departure instant (Leidos) use
125    /// it; observation-oriented sources may ignore it.
126    pub departure_at: Option<DateTime<Utc>>,
127}
128
129/// A validity window, e.g. a TAF's forecast period `0418/0524`.
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ValidPeriod {
132    /// Start of the window.
133    pub start: DateTime<Utc>,
134    /// End of the window.
135    pub end: DateTime<Utc>,
136}
137
138impl ValidPeriod {
139    /// A window spanning `start` to `end`.
140    pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
141        Self { start, end }
142    }
143}
144
145/// One discrete weather product, kept verbatim as published.
146///
147/// `#[non_exhaustive]`: construct with [`Product::new`] and the `with_*`
148/// setters, so a future source (e.g. FIS-B) can add fields without breaking
149/// existing construction sites.
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151#[non_exhaustive]
152pub struct Product {
153    /// What kind of product this is.
154    pub kind: ProductKind,
155    /// Station or location identifier when the product is tied to one.
156    pub location: Option<String>,
157    /// Issue/observation time when the source provides one.
158    pub issued_at: Option<DateTime<Utc>>,
159    /// Validity window when the product defines one (a TAF's forecast
160    /// period); `None` for instantaneous reports such as a METAR.
161    pub valid: Option<ValidPeriod>,
162    /// Verbatim product text as published by the source.
163    pub raw_text: String,
164}
165
166impl Product {
167    /// A product of `kind` carrying its verbatim `raw_text`. Optional fields
168    /// default to `None`; set them with the `with_*` builders.
169    pub fn new(kind: ProductKind, raw_text: impl Into<String>) -> Self {
170        Self {
171            kind,
172            location: None,
173            issued_at: None,
174            valid: None,
175            raw_text: raw_text.into(),
176        }
177    }
178
179    /// Set the station/location identifier.
180    #[must_use]
181    pub fn with_location(mut self, location: Option<String>) -> Self {
182        self.location = location;
183        self
184    }
185
186    /// Set the issue/observation time.
187    #[must_use]
188    pub fn with_issued_at(mut self, issued_at: Option<DateTime<Utc>>) -> Self {
189        self.issued_at = issued_at;
190        self
191    }
192
193    /// Set the validity window.
194    #[must_use]
195    pub fn with_valid(mut self, valid: Option<ValidPeriod>) -> Self {
196        self.valid = valid;
197        self
198    }
199}
200
201/// A completed weather briefing.
202///
203/// Sources differ in shape: product-oriented APIs fill [`Self::products`],
204/// document-oriented services (one rendered briefing text) fill
205/// [`Self::narrative`]. Either may be empty, both may be present.
206/// `#[non_exhaustive]`: construct with [`Briefing::new`] and the `with_*`
207/// setters.
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209#[non_exhaustive]
210pub struct Briefing {
211    /// [`crate::ContextProvider::name`] of the producing source.
212    pub source: String,
213    /// When the briefing was generated, if the source reports it.
214    pub generated_at: Option<DateTime<Utc>>,
215    /// Single-document briefing text, for document-oriented sources.
216    pub narrative: Option<String>,
217    /// Discrete products, for product-oriented sources.
218    pub products: Vec<Product>,
219}
220
221impl Briefing {
222    /// An empty briefing attributed to `source`; fill it with the `with_*`
223    /// setters.
224    pub fn new(source: impl Into<String>) -> Self {
225        Self {
226            source: source.into(),
227            generated_at: None,
228            narrative: None,
229            products: Vec::new(),
230        }
231    }
232
233    /// Set the generation time.
234    #[must_use]
235    pub fn with_generated_at(mut self, generated_at: Option<DateTime<Utc>>) -> Self {
236        self.generated_at = generated_at;
237        self
238    }
239
240    /// Set the single-document narrative.
241    #[must_use]
242    pub fn with_narrative(mut self, narrative: Option<String>) -> Self {
243        self.narrative = narrative;
244        self
245    }
246
247    /// Set the discrete products.
248    #[must_use]
249    pub fn with_products(mut self, products: Vec<Product>) -> Self {
250        self.products = products;
251        self
252    }
253}
254
255#[cfg(test)]
256mod tests;