use aerocontext_core::{GeoPoint, Product, ProductKind, ValidPeriod};
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::Deserialize;
use tracing::warn;
#[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>,
}
#[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| {
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))),
)
}))
}
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;