aerocontext-core 0.4.2

Provider-neutral aeronautical-context model and the pluggable ContextProvider contract
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Provider-neutral briefing domain types.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::metar::MetarObservation;

/// A geographic point in WGS84 decimal degrees.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct GeoPoint {
    /// Latitude in degrees, north positive.
    pub lat: f64,
    /// Longitude in degrees, east positive.
    pub lon: f64,
}

/// The geographic area a briefing covers.
///
/// Not every source supports every shape natively; adapters either convert
/// (a point radius encloses a bounding box and vice versa) or return
/// [`crate::ProviderError::Unsupported`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Area {
    /// Axis-aligned bounding box.
    BoundingBox {
        /// South-west corner.
        south_west: GeoPoint,
        /// North-east corner.
        north_east: GeoPoint,
    },
    /// Circle of `radius_nm` nautical miles around a coordinate.
    PointRadius {
        /// Circle center.
        center: GeoPoint,
        /// Radius in nautical miles.
        radius_nm: f64,
    },
    /// Circle of `radius_nm` nautical miles around a published location
    /// (ICAO/FAA aerodrome, navaid or fix identifier).
    LocationRadius {
        /// Location identifier, e.g. `"KSFO"`.
        ident: String,
        /// Radius in nautical miles.
        radius_nm: f64,
    },
    /// A polygon over `vertices`, treated as an implicitly closed ring
    /// (the last vertex connects back to the first). Vertex order may be
    /// either winding; self-intersecting rings give ray-cast semantics.
    Polygon {
        /// Ring vertices, at least three.
        vertices: Vec<GeoPoint>,
    },
}

impl Area {
    /// Smallest axis-aligned bounding box enclosing this area, as
    /// `(south_west, north_east)`, when one can be computed without a
    /// location database.
    ///
    /// Returns `None` for areas anchored to a published identifier; only
    /// the provider can resolve those. The radius conversion uses the
    /// small-area approximation (1 NM of latitude = 1/60 degree) and is
    /// not meaningful within a radius of the poles.
    pub fn enclosing_bbox(&self) -> Option<(GeoPoint, GeoPoint)> {
        match self {
            Self::BoundingBox {
                south_west,
                north_east,
            } => Some((*south_west, *north_east)),
            Self::PointRadius { center, radius_nm } => Some(bbox_around(*center, *radius_nm)),
            Self::LocationRadius { .. } => None,
            Self::Polygon { vertices } => polygon_bbox(vertices),
        }
    }

    /// Whether the area contains `point`, when that is decidable without a
    /// location database: `None` for [`Area::LocationRadius`] (mirroring
    /// the [`Self::enclosing_bbox`] contract), `Some` otherwise. This is
    /// the spatial-association hook TFR/NOTAM/weather modules filter
    /// geo-referenced products with.
    pub fn contains(&self, point: GeoPoint) -> Option<bool> {
        match self {
            Self::BoundingBox {
                south_west,
                north_east,
            } => Some(
                point.lat >= south_west.lat
                    && point.lat <= north_east.lat
                    && point.lon >= south_west.lon
                    && point.lon <= north_east.lon,
            ),
            Self::PointRadius { center, radius_nm } => {
                Some(crate::geo::distance_nm(*center, point) <= *radius_nm)
            }
            Self::LocationRadius { .. } => None,
            Self::Polygon { vertices } => Some(polygon_contains(vertices, point)),
        }
    }
}

/// Bounding box of a ring, unwrapping longitudes relative to the first
/// vertex so antimeridian-crossing rings stay contiguous.
fn polygon_bbox(vertices: &[GeoPoint]) -> Option<(GeoPoint, GeoPoint)> {
    let first = vertices.first()?;
    let mut min = *first;
    let mut max = *first;
    let mut reference = first.lon;
    for vertex in vertices {
        let lon = unwrap_lon(vertex.lon, reference);
        reference = lon;
        min.lat = min.lat.min(vertex.lat);
        min.lon = min.lon.min(lon);
        max.lat = max.lat.max(vertex.lat);
        max.lon = max.lon.max(lon);
    }
    Some((min, max))
}

/// Even-odd ray cast in lat/lon space with longitudes unwrapped to the
/// test point's neighborhood.
fn polygon_contains(vertices: &[GeoPoint], point: GeoPoint) -> bool {
    if vertices.len() < 3 {
        return false;
    }
    let mut inside = false;
    let mut j = vertices.len() - 1;
    for i in 0..vertices.len() {
        let (vi, vj) = (vertices[i], vertices[j]);
        let lon_i = unwrap_lon(vi.lon, point.lon);
        let lon_j = unwrap_lon(vj.lon, point.lon);
        if (vi.lat > point.lat) != (vj.lat > point.lat) {
            let denominator = vj.lat - vi.lat;
            if denominator.abs() > f64::EPSILON {
                let crossing = lon_i + (point.lat - vi.lat) / denominator * (lon_j - lon_i);
                if point.lon < crossing {
                    inside = !inside;
                }
            }
        }
        j = i;
    }
    inside
}

/// Shift `lon` by whole turns so it lies within 180° of `reference`.
fn unwrap_lon(lon: f64, reference: f64) -> f64 {
    // A non-finite input would loop forever (infinity never decreases)
    // or effectively forever (1e308 needs ~3e305 turns); hand it back
    // and let the NaN comparisons downstream evaluate false.
    if !lon.is_finite() || !reference.is_finite() {
        return lon;
    }
    let mut unwrapped = lon;
    while unwrapped - reference > 180.0 {
        unwrapped -= 360.0;
    }
    while unwrapped - reference < -180.0 {
        unwrapped += 360.0;
    }
    unwrapped
}

/// Bounding box of a circle, by the small-area equirectangular
/// approximation.
fn bbox_around(center: GeoPoint, radius_nm: f64) -> (GeoPoint, GeoPoint) {
    let lat_delta = radius_nm / 60.0;
    // Longitude degrees shrink with cos(lat); clamp so a circle near a
    // pole degrades to "all longitudes" instead of dividing by zero.
    let lon_scale = center.lat.to_radians().cos().max(1e-6);
    let lon_delta = (lat_delta / lon_scale).min(180.0);
    (
        GeoPoint {
            lat: (center.lat - lat_delta).max(-90.0),
            lon: center.lon - lon_delta,
        },
        GeoPoint {
            lat: (center.lat + lat_delta).min(90.0),
            lon: center.lon + lon_delta,
        },
    )
}

/// The kind of an individual briefing product.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ProductKind {
    /// Aerodrome routine/special weather report.
    Metar,
    /// Terminal aerodrome forecast.
    Taf,
    /// Pilot weather report.
    Pirep,
    /// Significant meteorological information.
    Sigmet,
    /// Airmen's meteorological information.
    Airmet,
    /// Graphical AIRMET.
    GAirmet,
    /// Center weather advisory.
    Cwa,
    /// Notice to air missions.
    Notam,
    /// A product this crate does not model yet, identified by the
    /// source's own name for it.
    Other(String),
}

/// A request for a simple area briefing.
///
/// Non-exhaustive: future capability parameters (route, altitude band,
/// aircraft type, product filters) land here — construct with
/// [`AreaBriefingRequest::new`] plus the `with_*` setters.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AreaBriefingRequest {
    /// Area the briefing covers.
    pub area: Area,
    /// Products to include. Empty means the source's default set.
    pub products: Vec<ProductKind>,
    /// How many hours of history to include where a product has history
    /// (e.g. past METARs). `None` means the source's default.
    pub lookback_hours: Option<u32>,
    /// Intended departure time the briefing is for. `None` means "now".
    /// Sources that brief relative to a departure instant (Leidos) use
    /// it; observation-oriented sources may ignore it.
    pub departure_at: Option<DateTime<Utc>>,
}

impl AreaBriefingRequest {
    /// A briefing request for `area` with every source default.
    pub fn new(area: Area) -> Self {
        Self {
            area,
            products: Vec::new(),
            lookback_hours: None,
            departure_at: None,
        }
    }

    /// Restrict to the given product kinds (empty = source default set).
    #[must_use]
    pub fn with_products(mut self, products: Vec<ProductKind>) -> Self {
        self.products = products;
        self
    }

    /// Include this many hours of history where a product has history.
    #[must_use]
    pub fn with_lookback_hours(mut self, hours: Option<u32>) -> Self {
        self.lookback_hours = hours;
        self
    }

    /// Anchor the briefing to an intended departure time (ETD).
    #[must_use]
    pub fn with_departure_at(mut self, at: Option<DateTime<Utc>>) -> Self {
        self.departure_at = at;
        self
    }
}

/// A validity window, e.g. a TAF's forecast period `0418/0524`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidPeriod {
    /// Start of the window.
    pub start: DateTime<Utc>,
    /// End of the window.
    pub end: DateTime<Utc>,
}

impl ValidPeriod {
    /// A window spanning `start` to `end`.
    pub fn new(start: DateTime<Utc>, end: DateTime<Utc>) -> Self {
        Self { start, end }
    }
}

/// One discrete weather product, kept verbatim as published.
///
/// `#[non_exhaustive]`: construct with [`Product::new`] and the `with_*`
/// setters, so a future source (e.g. FIS-B) can add fields without breaking
/// existing construction sites.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Product {
    /// What kind of product this is.
    pub kind: ProductKind,
    /// Station or location identifier when the product is tied to one.
    pub location: Option<String>,
    /// Issue/observation time when the source provides one.
    pub issued_at: Option<DateTime<Utc>>,
    /// Validity window when the product defines one (a TAF's forecast
    /// period); `None` for instantaneous reports such as a METAR.
    pub valid: Option<ValidPeriod>,
    /// Verbatim product text as published by the source.
    pub raw_text: String,
    /// Decoded observation fields, for METAR/SPECI products that carry
    /// them; the raw text above is always authoritative.
    pub observation: Option<MetarObservation>,
}

impl Product {
    /// A product of `kind` carrying its verbatim `raw_text`. Optional fields
    /// default to `None`; set them with the `with_*` builders.
    pub fn new(kind: ProductKind, raw_text: impl Into<String>) -> Self {
        Self {
            kind,
            location: None,
            issued_at: None,
            valid: None,
            raw_text: raw_text.into(),
            observation: None,
        }
    }

    /// Set the station/location identifier.
    #[must_use]
    pub fn with_location(mut self, location: Option<String>) -> Self {
        self.location = location;
        self
    }

    /// Set the issue/observation time.
    #[must_use]
    pub fn with_issued_at(mut self, issued_at: Option<DateTime<Utc>>) -> Self {
        self.issued_at = issued_at;
        self
    }

    /// Set the validity window.
    #[must_use]
    pub fn with_valid(mut self, valid: Option<ValidPeriod>) -> Self {
        self.valid = valid;
        self
    }

    /// Attach decoded observation fields (METAR/SPECI).
    #[must_use]
    pub fn with_observation(mut self, observation: Option<MetarObservation>) -> Self {
        self.observation = observation;
        self
    }
}

/// A completed weather briefing.
///
/// Sources differ in shape: product-oriented APIs fill [`Self::products`],
/// document-oriented services (one rendered briefing text) fill
/// [`Self::narrative`]. Either may be empty, both may be present.
/// `#[non_exhaustive]`: construct with [`Briefing::new`] and the `with_*`
/// setters.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Briefing {
    /// [`crate::ContextProvider::name`] of the producing source.
    pub source: String,
    /// When this briefing was assembled by the adapter (fetch time,
    /// UTC). Provider-stated product times live on each [`Product`].
    pub generated_at: Option<DateTime<Utc>>,
    /// The departure time this briefing answered for, when the request
    /// was ETD-anchored — `None` means "conditions now".
    pub departure_at: Option<DateTime<Utc>>,
    /// Single-document briefing text, for document-oriented sources.
    pub narrative: Option<String>,
    /// Discrete products, for product-oriented sources.
    pub products: Vec<Product>,
}

impl Briefing {
    /// An empty briefing attributed to `source`; fill it with the `with_*`
    /// setters.
    pub fn new(source: impl Into<String>) -> Self {
        Self {
            source: source.into(),
            generated_at: None,
            departure_at: None,
            narrative: None,
            products: Vec::new(),
        }
    }

    /// Set the generation time.
    #[must_use]
    pub fn with_generated_at(mut self, generated_at: Option<DateTime<Utc>>) -> Self {
        self.generated_at = generated_at;
        self
    }

    /// Set the departure time this briefing answered for — only for
    /// sources that genuinely anchor their window to it.
    #[must_use]
    pub fn with_departure_at(mut self, at: Option<DateTime<Utc>>) -> Self {
        self.departure_at = at;
        self
    }

    /// Set the single-document narrative.
    #[must_use]
    pub fn with_narrative(mut self, narrative: Option<String>) -> Self {
        self.narrative = narrative;
        self
    }

    /// Set the discrete products.
    #[must_use]
    pub fn with_products(mut self, products: Vec<Product>) -> Self {
        self.products = products;
        self
    }
}

#[cfg(test)]
mod tests;