use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[non_exhaustive]
pub enum FlightCategory {
Lifr,
Ifr,
Mvfr,
Vfr,
}
impl FlightCategory {
#[must_use]
pub fn from_ceiling_visibility(
ceiling_ft: Option<f64>,
visibility_sm: Option<f64>,
) -> Option<Self> {
let visibility_sm = visibility_sm?;
if !visibility_sm.is_finite() {
return None;
}
let by_visibility = if visibility_sm < 1.0 {
Self::Lifr
} else if visibility_sm < 3.0 {
Self::Ifr
} else if visibility_sm <= 5.0 {
Self::Mvfr
} else {
Self::Vfr
};
let by_ceiling = match ceiling_ft {
None => Self::Vfr,
Some(c) if !c.is_finite() => Self::Vfr,
Some(c) if c < 500.0 => Self::Lifr,
Some(c) if c < 1000.0 => Self::Ifr,
Some(c) if c <= 3000.0 => Self::Mvfr,
Some(_) => Self::Vfr,
};
Some(by_visibility.min(by_ceiling))
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_uppercase().as_str() {
"VFR" => Some(Self::Vfr),
"MVFR" => Some(Self::Mvfr),
"IFR" => Some(Self::Ifr),
"LIFR" => Some(Self::Lifr),
_ => None,
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Lifr => "LIFR",
Self::Ifr => "IFR",
Self::Mvfr => "MVFR",
Self::Vfr => "VFR",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum CloudCover {
Clear,
Cavok,
Few,
Scattered,
Broken,
Overcast,
Obscured,
}
impl CloudCover {
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_uppercase().as_str() {
"CLR" | "SKC" | "NSC" | "NCD" => Some(Self::Clear),
"CAVOK" => Some(Self::Cavok),
"FEW" => Some(Self::Few),
"SCT" => Some(Self::Scattered),
"BKN" => Some(Self::Broken),
"OVC" => Some(Self::Overcast),
"OVX" | "VV" => Some(Self::Obscured),
_ => None,
}
}
#[must_use]
pub fn is_ceiling(self) -> bool {
matches!(self, Self::Broken | Self::Overcast | Self::Obscured)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct CloudLayer {
pub cover: CloudCover,
pub base_ft: Option<f64>,
}
impl CloudLayer {
pub fn new(cover: CloudCover, base_ft: Option<f64>) -> Self {
Self { cover, base_ft }
}
}
const HPA_PER_INHG: f64 = 33.863_886_666_7;
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MetarObservation {
pub temperature_c: Option<f64>,
pub dewpoint_c: Option<f64>,
pub wind_dir_deg: Option<u16>,
pub wind_variable: bool,
pub wind_speed_kt: Option<u16>,
pub wind_gust_kt: Option<u16>,
pub visibility_sm: Option<f64>,
pub altimeter_hpa: Option<f64>,
pub clouds: Vec<CloudLayer>,
pub reported_category: Option<FlightCategory>,
}
impl MetarObservation {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn ceiling_ft(&self) -> Option<f64> {
self.clouds
.iter()
.filter(|layer| layer.cover.is_ceiling())
.filter_map(|layer| layer.base_ft)
.filter(|base| base.is_finite())
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
}
#[must_use]
pub fn has_unknown_height_ceiling(&self) -> bool {
self.clouds
.iter()
.any(|layer| layer.cover.is_ceiling() && !layer.base_ft.is_some_and(f64::is_finite))
}
#[must_use]
pub fn flight_category(&self) -> Option<FlightCategory> {
let category =
FlightCategory::from_ceiling_visibility(self.ceiling_ft(), self.visibility_sm)?;
if category != FlightCategory::Lifr && self.has_unknown_height_ceiling() {
return None;
}
Some(category)
}
#[must_use]
pub fn altimeter_inhg(&self) -> Option<f64> {
self.altimeter_hpa.map(|hpa| hpa / HPA_PER_INHG)
}
#[must_use]
pub fn with_temperature_c(mut self, value: Option<f64>) -> Self {
self.temperature_c = value;
self
}
#[must_use]
pub fn with_dewpoint_c(mut self, value: Option<f64>) -> Self {
self.dewpoint_c = value;
self
}
#[must_use]
pub fn with_wind_dir(mut self, deg: Option<u16>, variable: bool) -> Self {
self.wind_dir_deg = deg;
self.wind_variable = variable;
self
}
#[must_use]
pub fn with_wind_speed(mut self, speed_kt: Option<u16>, gust_kt: Option<u16>) -> Self {
self.wind_speed_kt = speed_kt;
self.wind_gust_kt = gust_kt;
self
}
#[must_use]
pub fn with_visibility_sm(mut self, value: Option<f64>) -> Self {
self.visibility_sm = value;
self
}
#[must_use]
pub fn with_altimeter_hpa(mut self, value: Option<f64>) -> Self {
self.altimeter_hpa = value;
self
}
#[must_use]
pub fn with_clouds(mut self, clouds: Vec<CloudLayer>) -> Self {
self.clouds = clouds;
self
}
#[must_use]
pub fn with_reported_category(mut self, value: Option<FlightCategory>) -> Self {
self.reported_category = value;
self
}
}
#[must_use]
pub fn parse_visibility_sm(raw: &str) -> Option<f64> {
let raw = raw.trim().trim_start_matches('M').trim_end_matches('+');
let value = if let Ok(value) = raw.parse::<f64>() {
value
} else {
let (whole, frac) = match raw.split_once(' ') {
Some((w, f)) => (w.parse::<f64>().ok()?, f),
None => (0.0, raw),
};
let (num, den) = frac.split_once('/')?;
whole + num.parse::<f64>().ok()? / den.parse::<f64>().ok()?
};
(value.is_finite() && value >= 0.0).then_some(value)
}
#[cfg(test)]
mod tests;