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;