aerocontext-awc 0.4.2

aviationweather.gov Data API adapter for aerocontext-core
Documentation
//! Lenient decoding of AWC `format=json` payloads into [`Product`]s.
//!
//! Every field is optional at the serde layer: AWC's OpenAPI spec is
//! descriptive rather than contractual, so a missing field must never
//! fail a whole briefing. Entries without raw product text are dropped
//! (the domain model is text-centric) and counted via `tracing`.

use aerocontext_core::{
    CloudCover, CloudLayer, FlightCategory, GeoPoint, MetarObservation, Product, ProductKind,
    ValidPeriod, metar,
};
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::Deserialize;
use tracing::warn;

/// An AWC value that may arrive as a number or a string (`visib` is
/// `"10+"` or a number; `wdir` is degrees or `"VRB"`).
#[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>,
    /// Raw METAR text. AWC names this `rawOb` for METARs and PIREPs.
    #[serde(rename = "rawOb", default)]
    raw_ob: Option<String>,
    /// Observation time, UNIX seconds.
    #[serde(rename = "obsTime", default)]
    obs_time: Option<i64>,
    #[serde(default)]
    temp: Option<f64>,
    #[serde(default)]
    dewp: Option<f64>,
    /// Wind direction in degrees, or `"VRB"` for variable.
    #[serde(default)]
    wdir: Option<NumOrText>,
    #[serde(default)]
    wspd: Option<i64>,
    #[serde(default)]
    wgst: Option<i64>,
    /// Visibility in statute miles; `"10+"` means greater than 10.
    #[serde(default)]
    visib: Option<NumOrText>,
    /// Altimeter setting in hectopascals.
    #[serde(default)]
    altim: Option<f64>,
    #[serde(default)]
    clouds: Vec<CloudJson>,
    /// Vertical visibility, feet — the height of an obscured sky, which
    /// AWC carries here rather than in `clouds[].base`.
    #[serde(rename = "vertVis", default)]
    vert_vis: Option<f64>,
    /// AWC's own flight-category, cross-checked against our derivation.
    #[serde(rename = "fltCat", default)]
    flt_cat: Option<String>,
}

impl MetarJson {
    /// Decode the structured observation fields. Raw text stays on the
    /// `Product`; this only augments it.
    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 {
                    // Dropping a layer could drop a ceiling, so count it
                    // (parity with the rest of the decoder's drop
                    // discipline) rather than failing silently.
                    dropped = dropped.wrapping_add(1);
                    return None;
                };
                // An obscured sky carries its height in `vertVis`, not in
                // the layer base — without it the ceiling would vanish.
                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>,
    /// Raw TAF text. Named `rawTAF`, not `rawOb`.
    #[serde(rename = "rawTAF", default)]
    raw_taf: Option<String>,
    /// Issuance time, formatted string.
    #[serde(rename = "issueTime", default)]
    issue_time: Option<String>,
    /// Forecast validity window, UNIX seconds.
    #[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>,
    /// Observation time, UNIX seconds. The spec types this `number`
    /// (unlike METAR's `integer`), so a fractional epoch must not fail
    /// the batch.
    #[serde(rename = "obsTime", default)]
    obs_time: Option<f64>,
}

#[derive(Debug, Deserialize)]
struct AirSigmetJson {
    /// Issuing office, e.g. `KKCI`.
    #[serde(rename = "icaoId", default)]
    icao_id: Option<String>,
    /// Raw text. Named `rawAirSigmet` on this endpoint.
    #[serde(rename = "rawAirSigmet", default)]
    raw_air_sigmet: Option<String>,
    /// Issuance time, formatted string.
    #[serde(rename = "creationTime", default)]
    creation_time: Option<String>,
    /// Hazard polygon as `{lat, lon}` pairs (plain JSON, not GeoJSON).
    #[serde(default)]
    coords: Vec<CoordJson>,
}

#[derive(Debug, Deserialize)]
struct CoordJson {
    #[serde(default)]
    lat: Option<f64>,
    #[serde(default)]
    lon: Option<f64>,
}

/// Decode a `metar` JSON array body.
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)),
        )
    }))
}

/// Decode a `taf` JSON array body.
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),
        )
    }))
}

/// Decode a `pirep` JSON array body.
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)),
                ),
        )
    }))
}

/// Decode an `airsigmet` JSON array body, keeping the hazard polygon for
/// client-side clipping.
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();
        // All-or-nothing: a partially decoded ring could lose a bbox
        // extreme and let clipping exclude a hazard that intersects the
        // request area. No usable geometry means "always keep".
        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,
        ))
    }))
}

/// Keep SIGMETs whose polygon's bounding box intersects the request box.
///
/// A SIGMET without usable geometry is kept: silently dropping a hazard
/// because its shape did not decode would make the briefing unsafe.
/// Antimeridian-crossing shapes are treated naively (CONUS products do
/// not cross it).
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
}

/// Build a [`ValidPeriod`] from a TAF's `validTimeFrom`/`validTimeTo` epoch
/// seconds; `None` unless both are present and decode.
fn taf_validity(from: Option<i64>, to: Option<i64>) -> Option<ValidPeriod> {
    Some(ValidPeriod::new(
        DateTime::from_timestamp(from?, 0)?,
        DateTime::from_timestamp(to?, 0)?,
    ))
}

/// Best-effort parse of AWC's assorted timestamp strings; `None` rather
/// than an error, because a bad timestamp must not fail a briefing.
fn parse_awc_time(text: &str) -> Option<DateTime<Utc>> {
    if let Ok(parsed) = DateTime::parse_from_rfc3339(text) {
        return Some(parsed.to_utc());
    }
    // Space-separated variants seen in spec examples, with and without
    // fractional seconds / trailing Z.
    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;