use aerocontext_core::{
CloudCover, CloudLayer, FlightCategory, GeoPoint, MetarObservation, Product, ProductKind,
ValidPeriod, metar,
};
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::Deserialize;
use tracing::warn;
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum NumOrText {
Num(f64),
Text(String),
}
#[derive(Debug, Deserialize)]
struct CloudJson {
#[serde(default)]
cover: Option<String>,
#[serde(default)]
base: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct MetarJson {
#[serde(rename = "icaoId", default)]
icao_id: Option<String>,
#[serde(rename = "rawOb", default)]
raw_ob: Option<String>,
#[serde(rename = "obsTime", default)]
obs_time: Option<i64>,
#[serde(default)]
temp: Option<f64>,
#[serde(default)]
dewp: Option<f64>,
#[serde(default)]
wdir: Option<NumOrText>,
#[serde(default)]
wspd: Option<i64>,
#[serde(default)]
wgst: Option<i64>,
#[serde(default)]
visib: Option<NumOrText>,
#[serde(default)]
altim: Option<f64>,
#[serde(default)]
clouds: Vec<CloudJson>,
#[serde(rename = "vertVis", default)]
vert_vis: Option<f64>,
#[serde(rename = "fltCat", default)]
flt_cat: Option<String>,
}
impl MetarJson {
fn observation(&self) -> MetarObservation {
let (wind_dir_deg, wind_variable) = match &self.wdir {
Some(NumOrText::Num(deg)) if deg.is_finite() && *deg >= 0.0 && *deg <= 360.0 => {
(Some(*deg as u16), false)
}
Some(NumOrText::Text(text)) if text.trim().eq_ignore_ascii_case("VRB") => (None, true),
_ => (None, false),
};
let visibility_sm = match &self.visib {
Some(NumOrText::Num(value)) if value.is_finite() => Some(*value),
Some(NumOrText::Text(text)) => metar::parse_visibility_sm(text),
_ => None,
};
let mut dropped = 0u32;
let clouds = self
.clouds
.iter()
.filter_map(|layer| {
let Some(cover) = layer.cover.as_deref().and_then(CloudCover::parse) else {
dropped = dropped.wrapping_add(1);
return None;
};
let base = match (cover, layer.base) {
(CloudCover::Obscured, None) => self.vert_vis,
(_, base) => base,
};
Some(CloudLayer::new(cover, base))
})
.collect();
if dropped > 0 {
warn!(dropped, "dropped cloud layers with an unrecognized cover");
}
MetarObservation::new()
.with_temperature_c(self.temp)
.with_dewpoint_c(self.dewp)
.with_wind_dir(wind_dir_deg, wind_variable)
.with_wind_speed(
self.wspd.and_then(|s| u16::try_from(s).ok()),
self.wgst.and_then(|g| u16::try_from(g).ok()),
)
.with_visibility_sm(visibility_sm)
.with_altimeter_hpa(self.altim)
.with_clouds(clouds)
.with_reported_category(self.flt_cat.as_deref().and_then(FlightCategory::parse))
}
}
#[derive(Debug, Deserialize)]
struct TafJson {
#[serde(rename = "icaoId", default)]
icao_id: Option<String>,
#[serde(rename = "rawTAF", default)]
raw_taf: Option<String>,
#[serde(rename = "issueTime", default)]
issue_time: Option<String>,
#[serde(rename = "validTimeFrom", default)]
valid_from: Option<i64>,
#[serde(rename = "validTimeTo", default)]
valid_to: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct PirepJson {
#[serde(rename = "icaoId", default)]
icao_id: Option<String>,
#[serde(rename = "rawOb", default)]
raw_ob: Option<String>,
#[serde(rename = "obsTime", default)]
obs_time: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct AirSigmetJson {
#[serde(rename = "icaoId", default)]
icao_id: Option<String>,
#[serde(rename = "rawAirSigmet", default)]
raw_air_sigmet: Option<String>,
#[serde(rename = "creationTime", default)]
creation_time: Option<String>,
#[serde(default)]
coords: Vec<CoordJson>,
}
#[derive(Debug, Deserialize)]
struct CoordJson {
#[serde(default)]
lat: Option<f64>,
#[serde(default)]
lon: Option<f64>,
}
pub(crate) fn metars(body: &str) -> Result<Vec<Product>, serde_json::Error> {
let entries: Vec<MetarJson> = serde_json::from_str(body)?;
Ok(collect(entries, "metar", |entry| {
let observation = entry.observation();
Some(
Product::new(ProductKind::Metar, entry.raw_ob?)
.with_location(entry.icao_id)
.with_issued_at(entry.obs_time.and_then(|t| DateTime::from_timestamp(t, 0)))
.with_observation(Some(observation)),
)
}))
}
pub(crate) fn tafs(body: &str) -> Result<Vec<Product>, serde_json::Error> {
let entries: Vec<TafJson> = serde_json::from_str(body)?;
Ok(collect(entries, "taf", |entry| {
let valid = taf_validity(entry.valid_from, entry.valid_to);
Some(
Product::new(ProductKind::Taf, entry.raw_taf?)
.with_location(entry.icao_id)
.with_issued_at(entry.issue_time.as_deref().and_then(parse_awc_time))
.with_valid(valid),
)
}))
}
pub(crate) fn pireps(body: &str) -> Result<Vec<Product>, serde_json::Error> {
let entries: Vec<PirepJson> = serde_json::from_str(body)?;
Ok(collect(entries, "pirep", |entry| {
Some(
Product::new(ProductKind::Pirep, entry.raw_ob?)
.with_location(entry.icao_id)
.with_issued_at(
entry
.obs_time
.and_then(|t| DateTime::from_timestamp(t.trunc() as i64, 0)),
),
)
}))
}
pub(crate) fn air_sigmets(body: &str) -> Result<Vec<(Product, Vec<GeoPoint>)>, serde_json::Error> {
let entries: Vec<AirSigmetJson> = serde_json::from_str(body)?;
Ok(collect(entries, "airsigmet", |entry| {
let decoded: Vec<GeoPoint> = entry
.coords
.iter()
.filter_map(|c| {
Some(GeoPoint {
lat: c.lat?,
lon: c.lon?,
})
})
.collect();
let polygon = if decoded.len() == entry.coords.len() {
decoded
} else {
warn!(
dropped = entry.coords.len() - decoded.len(),
"SIGMET ring vertices failed to decode; keeping the product without geometry"
);
Vec::new()
};
Some((
Product::new(ProductKind::Sigmet, entry.raw_air_sigmet?)
.with_location(entry.icao_id)
.with_issued_at(entry.creation_time.as_deref().and_then(parse_awc_time)),
polygon,
))
}))
}
pub(crate) fn clip_to_bbox(
entries: Vec<(Product, Vec<GeoPoint>)>,
(south_west, north_east): (GeoPoint, GeoPoint),
) -> Vec<Product> {
entries
.into_iter()
.filter_map(|(product, polygon)| {
let keep = match polygon_bbox(&polygon) {
None => true,
Some((min, max)) => {
min.lat <= north_east.lat
&& max.lat >= south_west.lat
&& min.lon <= north_east.lon
&& max.lon >= south_west.lon
}
};
keep.then_some(product)
})
.collect()
}
fn polygon_bbox(polygon: &[GeoPoint]) -> Option<(GeoPoint, GeoPoint)> {
let first = polygon.first()?;
let mut min = *first;
let mut max = *first;
for point in polygon {
min.lat = min.lat.min(point.lat);
min.lon = min.lon.min(point.lon);
max.lat = max.lat.max(point.lat);
max.lon = max.lon.max(point.lon);
}
Some((min, max))
}
fn collect<E, T>(entries: Vec<E>, what: &str, convert: impl Fn(E) -> Option<T>) -> Vec<T> {
let total = entries.len();
let kept: Vec<T> = entries.into_iter().filter_map(convert).collect();
if kept.len() < total {
warn!(
what,
dropped = total - kept.len(),
"dropped entries without raw product text"
);
}
kept
}
fn taf_validity(from: Option<i64>, to: Option<i64>) -> Option<ValidPeriod> {
Some(ValidPeriod::new(
DateTime::from_timestamp(from?, 0)?,
DateTime::from_timestamp(to?, 0)?,
))
}
fn parse_awc_time(text: &str) -> Option<DateTime<Utc>> {
if let Ok(parsed) = DateTime::parse_from_rfc3339(text) {
return Some(parsed.to_utc());
}
for format in [
"%Y-%m-%d %H:%M:%S%.fZ",
"%Y-%m-%d %H:%M:%S%.f",
"%Y-%m-%d %H:%M:%S",
] {
if let Ok(naive) = NaiveDateTime::parse_from_str(text, format) {
return Some(naive.and_utc());
}
}
None
}
#[cfg(test)]
mod tests;