//! Encapsulates the DWML implementation details use chrono::FixedOffset; use serde::Deserialize; /// Top-level structure #[derive(Debug, Deserialize, PartialEq)] pub struct Dwml { pub head: Head, pub data: (Forecast, CurrentObservations), } impl std::str::FromStr for Dwml { type Err = quick_xml::DeError; fn from_str(s: &str) -> Result { quick_xml::de::from_str(s) } } #[derive(Debug, Deserialize, PartialEq)] pub struct Head { pub product: Product, pub source: Source, } #[derive(Debug, Deserialize, PartialEq)] pub struct Forecast { pub location: Location, #[serde(rename = "time-layout")] pub time_layout: (TimeLayout, TimeLayout, TimeLayout), pub parameters: Parameters, } #[derive(Debug, Deserialize, PartialEq)] pub struct Location { #[serde(rename = "area-description")] pub area_description: Option, pub description: Option, } #[derive(Debug, Deserialize, PartialEq)] pub struct TimeLayout { #[serde(rename = "@time-coordinate")] pub time_coordinate: TimeCoordinate, #[serde(rename = "layout-key")] pub layout_key: TimeLayoutKey, #[serde(rename = "start-valid-time")] pub start_valid_time: Vec, } impl TimeLayout { /// For a given time, returns the index of the interval that the time falls in. fn lookup(&self, t: &chrono::DateTime) -> Option { for i in 0..self.start_valid_time.len() - 1 { let Some(start) = self.start_valid_time.get(i) else { break; }; let Some(stop) = self.start_valid_time.get(i + 1) else { break; }; if start.value.0 <= *t && *t < stop.value.0 { return Some(i); } } None } } #[derive(Debug, Deserialize, PartialEq)] pub enum TimeCoordinate { #[serde(rename = "local")] Local, } #[derive(Debug, Deserialize, PartialEq)] pub struct StartValidTime { #[serde(rename = "@period-name")] pub period_name: String, #[serde(rename = "$value")] pub value: DateTime, } #[derive(Debug, Deserialize, PartialEq)] #[serde(try_from = "String")] pub struct DateTime (pub chrono::DateTime); impl TryFrom for DateTime { type Error = chrono::ParseError; fn try_from(s: String) -> Result { chrono::DateTime::parse_from_rfc3339(&s).map(Self) } } /// Observations at time of request /// /// Note that `time-layout` isn't parsed because in San Franscisco it returns a time with no timezone, which makes the schema too complicated. It's in local California time, so we could graft on a timezone from another part of the XML, but why bother. #[derive(Debug, Deserialize, PartialEq)] pub struct CurrentObservations { pub location: Location, } #[derive(Debug, Deserialize, PartialEq)] pub struct Product { #[serde(rename = "creation-date")] pub creation_date: CreationDate, } #[derive(Debug, Deserialize, PartialEq)] pub struct CreationDate { #[serde(rename = "@refresh-frequency")] pub refresh_frequency: String, #[serde(rename = "$value")] pub value: String, } #[derive(Debug, Deserialize, PartialEq)] pub struct Source { #[serde(rename = "production-center")] pub production_center: String, } #[derive(Debug, Deserialize, PartialEq)] pub struct Parameters { pub temperature: (Temperature, Temperature), #[serde(rename = "probability-of-precipitation")] pub probability_of_precipitation: ProbabilityOfPrecipitation, pub weather: Weather, } #[derive(Debug, Deserialize, PartialEq)] pub struct Temperature { #[serde(rename = "@type")] pub temp_type: TempType, #[serde(rename = "@units")] pub units: TempUnits, #[serde(rename = "@time-layout")] pub time_layout: TimeLayoutKey, pub value: Vec, } #[derive(Debug, Deserialize, PartialEq)] pub struct ProbabilityOfPrecipitation { #[serde(rename = "@time-layout")] pub time_layout: TimeLayoutKey, pub value: Vec, } #[derive(Debug, Deserialize, PartialEq)] pub struct Weather { #[serde(rename = "@time-layout")] pub time_layout: TimeLayoutKey, #[serde(rename = "weather-conditions")] pub weather_conditions: Vec, } #[derive(Debug, Deserialize, PartialEq)] pub struct WeatherCondition { /// e.g. "Showers Likely" or "Partly Sunny" #[serde(rename = "@weather-summary")] pub weather_summary: String, } // If only there was a percentage that could convey the concept of // no precipitation. #[derive(Debug, Deserialize, PartialEq)] pub struct PrecipitationValue { #[serde(rename = "$text")] pub value: Option, } #[derive(Debug, Deserialize, PartialEq)] pub enum TempType { #[serde(rename = "apparent")] Apparent, #[serde(rename = "dew point")] DewPoint, #[serde(rename = "minimum")] Minimum, #[serde(rename = "maximum")] Maximum, } #[derive(Debug, Deserialize, PartialEq)] pub enum TempUnits { Celsius, Fahrenheit, } #[derive(Debug, Deserialize, PartialEq)] pub struct TimeLayoutKey(String); #[cfg(test)] mod tests { use anyhow::{Context as _, Result}; use super::*; #[test] fn empire_city() { let s = include_str!("../data/empire_city.xml"); let dwml: Dwml = quick_xml::de::from_str(s).unwrap(); for (input, expected) in [ ("2025-01-14T18:59:59-05:00", None), ("2025-01-14T19:00:00-05:00", Some(0)), ("2025-01-21T05:59:59-05:00", Some(12)), ("2025-01-21T06:00:00-05:00", None), ] { let dt = chrono::DateTime::parse_from_rfc3339(input).unwrap(); let actual = dwml.data.0.time_layout.0.lookup(&dt); assert_eq!(actual, expected); } } #[test] fn sunset_city() { let s = include_str!("../data/sunset_city.xml"); let _: Dwml = quick_xml::de::from_str(s).unwrap(); } #[test] fn windy_city() { let s = include_str!("../data/windy_city.xml"); let _: Dwml = quick_xml::de::from_str(s).unwrap(); } #[test] fn date() -> Result<()> { for s in [ "2025-01-01T23:00:00-00:00" ] { let _ = chrono::DateTime::parse_from_rfc3339(&s).context(s).unwrap(); } Ok(()) } }