Skip to main content

aerocontext_core/
metar.rs

1//! Decoded METAR observation fields and the FAA flight-rules category.
2//!
3//! The raw observation text always rides on [`Product`](crate::Product)
4//! verbatim; these structured fields *augment* it for callers that need
5//! numbers (a personal-minimums go/no-go check) rather than replacing it.
6//!
7//! Field semantics follow the aviationweather.gov (AWC) Data API
8//! (`reference/awc-openapi.yaml`): visibility in statute miles, altimeter
9//! in hectopascals, cloud bases in feet AGL, cover one of
10//! `CLR/CAVOK/FEW/SCT/BKN/OVC/OVX`. The flight-category thresholds follow
11//! the FAA / AWC definitions cited on [`FlightCategory`].
12
13use serde::{Deserialize, Serialize};
14
15/// FAA flight-rules category, ordered worst-first so `min` selects the
16/// more restrictive of two categories.
17///
18/// Definitions (AWC / FAA), where the category is the **worse** of the
19/// ceiling-derived and visibility-derived categories:
20///
21/// - **LIFR**: ceiling < 500 ft AGL  and/or visibility < 1 sm.
22/// - **IFR**:  ceiling 500 to < 1,000 ft  and/or visibility 1 to < 3 sm.
23/// - **MVFR**: ceiling 1,000 to 3,000 ft  and/or visibility 3 to 5 sm.
24/// - **VFR**:  ceiling > 3,000 ft  and visibility > 5 sm.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26#[non_exhaustive]
27pub enum FlightCategory {
28    /// Low IFR — the most restrictive.
29    Lifr,
30    /// IFR.
31    Ifr,
32    /// Marginal VFR.
33    Mvfr,
34    /// VFR — the least restrictive.
35    Vfr,
36}
37
38impl FlightCategory {
39    /// Derive the category from a ceiling (feet AGL; `None` = no ceiling
40    /// layer, i.e. unlimited) and visibility (statute miles).
41    ///
42    /// Returns `None` when visibility is unknown — the dominant VFR
43    /// criterion — so a caller never categorizes from missing data. The
44    /// result is the worse of the ceiling and visibility categories.
45    #[must_use]
46    pub fn from_ceiling_visibility(
47        ceiling_ft: Option<f64>,
48        visibility_sm: Option<f64>,
49    ) -> Option<Self> {
50        let visibility_sm = visibility_sm?;
51        if !visibility_sm.is_finite() {
52            return None;
53        }
54        let by_visibility = if visibility_sm < 1.0 {
55            Self::Lifr
56        } else if visibility_sm < 3.0 {
57            Self::Ifr
58        } else if visibility_sm <= 5.0 {
59            Self::Mvfr
60        } else {
61            Self::Vfr
62        };
63        let by_ceiling = match ceiling_ft {
64            // No ceiling layer is unlimited, so ceiling never constrains.
65            None => Self::Vfr,
66            Some(c) if !c.is_finite() => Self::Vfr,
67            Some(c) if c < 500.0 => Self::Lifr,
68            Some(c) if c < 1000.0 => Self::Ifr,
69            Some(c) if c <= 3000.0 => Self::Mvfr,
70            Some(_) => Self::Vfr,
71        };
72        Some(by_visibility.min(by_ceiling))
73    }
74
75    /// Parse AWC's `fltCat` string (`"VFR"`/`"MVFR"`/`"IFR"`/`"LIFR"`),
76    /// case-insensitively. `None` for an empty or unrecognized value.
77    #[must_use]
78    pub fn parse(value: &str) -> Option<Self> {
79        match value.trim().to_ascii_uppercase().as_str() {
80            "VFR" => Some(Self::Vfr),
81            "MVFR" => Some(Self::Mvfr),
82            "IFR" => Some(Self::Ifr),
83            "LIFR" => Some(Self::Lifr),
84            _ => None,
85        }
86    }
87
88    /// The conventional abbreviation.
89    #[must_use]
90    pub fn as_str(self) -> &'static str {
91        match self {
92            Self::Lifr => "LIFR",
93            Self::Ifr => "IFR",
94            Self::Mvfr => "MVFR",
95            Self::Vfr => "VFR",
96        }
97    }
98}
99
100/// Sky-cover of one cloud layer (AWC cover enum).
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[non_exhaustive]
103pub enum CloudCover {
104    /// Clear / sky clear.
105    Clear,
106    /// Ceiling and visibility OK.
107    Cavok,
108    /// Few (1–2 oktas).
109    Few,
110    /// Scattered (3–4 oktas).
111    Scattered,
112    /// Broken (5–7 oktas) — a ceiling.
113    Broken,
114    /// Overcast (8 oktas) — a ceiling.
115    Overcast,
116    /// Obscured sky / vertical visibility — a ceiling.
117    Obscured,
118}
119
120impl CloudCover {
121    /// Parse an AWC cover string, `None` for an unrecognized value.
122    #[must_use]
123    pub fn parse(value: &str) -> Option<Self> {
124        match value.trim().to_ascii_uppercase().as_str() {
125            "CLR" | "SKC" | "NSC" | "NCD" => Some(Self::Clear),
126            "CAVOK" => Some(Self::Cavok),
127            "FEW" => Some(Self::Few),
128            "SCT" => Some(Self::Scattered),
129            "BKN" => Some(Self::Broken),
130            "OVC" => Some(Self::Overcast),
131            "OVX" | "VV" => Some(Self::Obscured),
132            _ => None,
133        }
134    }
135
136    /// Whether a layer of this cover constitutes a ceiling (broken,
137    /// overcast, or obscured/vertical-visibility).
138    #[must_use]
139    pub fn is_ceiling(self) -> bool {
140        matches!(self, Self::Broken | Self::Overcast | Self::Obscured)
141    }
142}
143
144/// One reported cloud layer.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146#[non_exhaustive]
147pub struct CloudLayer {
148    /// Sky cover of the layer.
149    pub cover: CloudCover,
150    /// Layer base, feet AGL; `None` when the report omits a base (e.g.
151    /// `CLR`).
152    pub base_ft: Option<f64>,
153}
154
155impl CloudLayer {
156    /// A layer with the given cover and optional base.
157    pub fn new(cover: CloudCover, base_ft: Option<f64>) -> Self {
158        Self { cover, base_ft }
159    }
160}
161
162/// Hectopascals per inch of mercury (exact: 1 inHg = 33.8638866667 hPa).
163const HPA_PER_INHG: f64 = 33.863_886_666_7;
164
165/// A decoded METAR observation. Every field is optional: a real report
166/// may omit any of them, and a caller must treat absence as "unknown",
167/// never as a safe default.
168#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
169#[non_exhaustive]
170pub struct MetarObservation {
171    /// Air temperature, °C.
172    pub temperature_c: Option<f64>,
173    /// Dewpoint, °C.
174    pub dewpoint_c: Option<f64>,
175    /// Wind direction, degrees true; `None` when variable or unreported.
176    pub wind_dir_deg: Option<u16>,
177    /// Whether the wind direction is reported variable (`VRB`).
178    pub wind_variable: bool,
179    /// Sustained wind speed, knots.
180    pub wind_speed_kt: Option<u16>,
181    /// Gust speed, knots.
182    pub wind_gust_kt: Option<u16>,
183    /// Prevailing visibility, statute miles. `"10+"` decodes to `10.0`.
184    pub visibility_sm: Option<f64>,
185    /// Altimeter setting, hectopascals (the AWC source unit).
186    pub altimeter_hpa: Option<f64>,
187    /// Cloud layers, lowest first as reported.
188    pub clouds: Vec<CloudLayer>,
189    /// The category AWC itself reported (`fltCat`), for cross-checking
190    /// against [`Self::flight_category`].
191    pub reported_category: Option<FlightCategory>,
192}
193
194impl MetarObservation {
195    /// An empty observation; fill it with the `with_*` setters.
196    #[must_use]
197    pub fn new() -> Self {
198        Self::default()
199    }
200
201    /// Lowest ceiling base with a known, finite height (broken / overcast
202    /// / obscured), feet AGL. `None` when no ceiling layer has a known
203    /// height — which may mean unlimited *or* a ceiling whose height was
204    /// not reported; use [`Self::has_unknown_height_ceiling`] to tell
205    /// them apart.
206    #[must_use]
207    pub fn ceiling_ft(&self) -> Option<f64> {
208        self.clouds
209            .iter()
210            .filter(|layer| layer.cover.is_ceiling())
211            .filter_map(|layer| layer.base_ft)
212            .filter(|base| base.is_finite())
213            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
214    }
215
216    /// Whether a ceiling layer (broken/overcast/obscured) is present but
217    /// its height is missing or non-finite. Such a ceiling must never be
218    /// read as "unlimited": the true ceiling could be at any height.
219    #[must_use]
220    pub fn has_unknown_height_ceiling(&self) -> bool {
221        self.clouds
222            .iter()
223            .any(|layer| layer.cover.is_ceiling() && !layer.base_ft.is_some_and(f64::is_finite))
224    }
225
226    /// Flight category derived from [`Self::ceiling_ft`] and
227    /// [`Self::visibility_sm`] — the source of truth for a go/no-go
228    /// check, independent of AWC's reported value.
229    ///
230    /// Returns `None` (uncategorizable) when visibility is unknown, or
231    /// when a ceiling layer of unknown height could be lower than
232    /// anything known and the result is not already the most restrictive
233    /// category. A go/no-go caller must treat `None` conservatively, never
234    /// as VFR.
235    #[must_use]
236    pub fn flight_category(&self) -> Option<FlightCategory> {
237        let category =
238            FlightCategory::from_ceiling_visibility(self.ceiling_ft(), self.visibility_sm)?;
239        // A ceiling of unknown height could be lower than any known
240        // ceiling; we can only stand behind a category that is already
241        // the worst possible (LIFR).
242        if category != FlightCategory::Lifr && self.has_unknown_height_ceiling() {
243            return None;
244        }
245        Some(category)
246    }
247
248    /// Altimeter setting converted to inches of mercury.
249    #[must_use]
250    pub fn altimeter_inhg(&self) -> Option<f64> {
251        self.altimeter_hpa.map(|hpa| hpa / HPA_PER_INHG)
252    }
253
254    /// Set the temperature (°C).
255    #[must_use]
256    pub fn with_temperature_c(mut self, value: Option<f64>) -> Self {
257        self.temperature_c = value;
258        self
259    }
260
261    /// Set the dewpoint (°C).
262    #[must_use]
263    pub fn with_dewpoint_c(mut self, value: Option<f64>) -> Self {
264        self.dewpoint_c = value;
265        self
266    }
267
268    /// Set the wind direction (degrees true) and whether it is variable.
269    #[must_use]
270    pub fn with_wind_dir(mut self, deg: Option<u16>, variable: bool) -> Self {
271        self.wind_dir_deg = deg;
272        self.wind_variable = variable;
273        self
274    }
275
276    /// Set the wind speed and gust (knots).
277    #[must_use]
278    pub fn with_wind_speed(mut self, speed_kt: Option<u16>, gust_kt: Option<u16>) -> Self {
279        self.wind_speed_kt = speed_kt;
280        self.wind_gust_kt = gust_kt;
281        self
282    }
283
284    /// Set the prevailing visibility (statute miles).
285    #[must_use]
286    pub fn with_visibility_sm(mut self, value: Option<f64>) -> Self {
287        self.visibility_sm = value;
288        self
289    }
290
291    /// Set the altimeter setting (hectopascals).
292    #[must_use]
293    pub fn with_altimeter_hpa(mut self, value: Option<f64>) -> Self {
294        self.altimeter_hpa = value;
295        self
296    }
297
298    /// Set the cloud layers.
299    #[must_use]
300    pub fn with_clouds(mut self, clouds: Vec<CloudLayer>) -> Self {
301        self.clouds = clouds;
302        self
303    }
304
305    /// Set AWC's reported category.
306    #[must_use]
307    pub fn with_reported_category(mut self, value: Option<FlightCategory>) -> Self {
308        self.reported_category = value;
309        self
310    }
311}
312
313/// Parse an AWC `visib` value (`reference/awc-openapi.yaml`): a number, a
314/// `"10+"` / `"6+"` (greater-than) string, or a fraction like `"1 1/2"` /
315/// `"M1/4"` (`M` = "less than"). Returns statute miles, or `None` for a
316/// negative, non-finite, or unparseable value.
317///
318/// The `M`/`+` qualifiers carry an inequality direction (`M1/4` is
319/// *strictly* below 1/4 sm). This returns the magnitude; modeling the
320/// bound for a numeric personal-minimums comparison is the decision
321/// layer's job. The flight *category* is unaffected — `M1/4` is `< 1` so
322/// it is LIFR either way.
323#[must_use]
324pub fn parse_visibility_sm(raw: &str) -> Option<f64> {
325    let raw = raw.trim().trim_start_matches('M').trim_end_matches('+');
326    let value = if let Ok(value) = raw.parse::<f64>() {
327        value
328    } else {
329        // Mixed number or bare fraction, e.g. "1 1/2" or "3/4".
330        let (whole, frac) = match raw.split_once(' ') {
331            Some((w, f)) => (w.parse::<f64>().ok()?, f),
332            None => (0.0, raw),
333        };
334        let (num, den) = frac.split_once('/')?;
335        whole + num.parse::<f64>().ok()? / den.parse::<f64>().ok()?
336    };
337    // A negative or non-finite "statute miles" is garbage, not a safe
338    // default — surface it as unknown.
339    (value.is_finite() && value >= 0.0).then_some(value)
340}
341
342#[cfg(test)]
343mod tests;