use std::collections::HashMap;
use std::sync::RwLock;
use serde::Deserialize;
use crate::client::get_json;
use crate::codes::CompassDirection;
use crate::units::{MeasurementSystem, TemperatureUnit};
use crate::weather::CurrentWeather;
const POINTS_URL_PREFIX: &str = "https://api.weather.gov/points/";
const STATIONS_URL_PREFIX: &str = "https://api.weather.gov/stations/";
const MAX_HOPS: usize = 3;
#[derive(Debug, Deserialize)]
struct PointsResponse {
properties: PointsProperties,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PointsProperties {
observation_stations: String,
}
#[derive(Debug, Deserialize)]
struct StationsResponse {
features: Vec<StationFeature>,
}
#[derive(Debug, Deserialize)]
struct StationFeature {
properties: StationProperties,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StationProperties {
station_identifier: String,
}
static STATION_CACHE: RwLock<Option<HashMap<String, Vec<String>>>> = RwLock::new(None);
#[derive(Debug, Default, Deserialize)]
struct Measurement {
value: Option<f64>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
struct ObservationProperties {
temperature: Measurement,
dewpoint: Measurement,
relative_humidity: Measurement,
wind_direction: Measurement,
wind_speed: Measurement,
wind_gust: Measurement,
barometric_pressure: Measurement,
sea_level_pressure: Measurement,
visibility: Measurement,
heat_index: Measurement,
wind_chill: Measurement,
}
#[derive(Debug, Deserialize)]
struct ObservationResponse {
properties: ObservationProperties,
}
#[derive(Debug)]
pub(crate) struct NwsObservation {
temperature: Option<f32>,
windspeed: Option<f32>,
humidity: Option<i32>,
feels_like: Option<f32>,
wind_direction: Option<i32>,
wind_gusts: Option<f32>,
visibility: Option<f32>,
pressure: Option<f32>,
dew_point: Option<f32>,
}
pub(crate) async fn override_current_observations(
latitude: f64,
longitude: f64,
temperature_unit: TemperatureUnit,
measurement_system: MeasurementSystem,
) -> Option<NwsObservation> {
let point = format!("{latitude:.4},{longitude:.4}");
let station_ids = cached_station_ids(&point).await?;
for id in station_ids.iter().take(MAX_HOPS) {
let url = format!("{STATIONS_URL_PREFIX}{id}/observations/latest");
let Some(obs) = get_json::<ObservationResponse>(&url, "NWS observation").await else {
continue;
};
if let Some(parsed) =
parse_observation(obs.properties, temperature_unit, measurement_system)
{
tracing::debug!("NWS override from station {id}");
return Some(parsed);
}
}
tracing::debug!("no usable NWS observation within {MAX_HOPS}");
None
}
async fn fetch_station_ids(point: &str) -> Option<Vec<String>> {
let points_url = format!("{POINTS_URL_PREFIX}{point}");
let points: PointsResponse = get_json(&points_url, "NWS points").await?;
let stations: StationsResponse =
get_json(&points.properties.observation_stations, "NWS stations").await?;
let ids: Vec<String> = stations
.features
.into_iter()
.map(|f| f.properties.station_identifier)
.collect();
tracing::debug!("NWS grid resolved {} station(s)", ids.len());
Some(ids)
}
async fn cached_station_ids(point: &str) -> Option<Vec<String>> {
if let Ok(guard) = STATION_CACHE.read() {
if let Some(ids) = guard.as_ref().and_then(|m| m.get(point)) {
return Some(ids.clone());
}
}
let ids = fetch_station_ids(point).await?;
if !ids.is_empty() {
if let Ok(mut guard) = STATION_CACHE.write() {
guard
.get_or_insert_with(HashMap::new)
.insert(point.to_string(), ids.clone());
}
}
Some(ids)
}
fn c_to_unit(celsius: f64, unit: TemperatureUnit) -> f32 {
match unit {
TemperatureUnit::Celsius => celsius as f32,
TemperatureUnit::Fahrenheit => (celsius * 9.0 / 5.0 + 32.0) as f32,
}
}
fn kmh_to_system(kmh: f64, system: MeasurementSystem) -> f32 {
match system {
MeasurementSystem::Metric => kmh as f32,
MeasurementSystem::Imperial => (kmh / 1.609_344) as f32,
}
}
fn parse_observation(
props: ObservationProperties,
unit: TemperatureUnit,
system: MeasurementSystem,
) -> Option<NwsObservation> {
let temperature = props.temperature.value?;
let feels_like = props
.heat_index
.value
.or(props.wind_chill.value)
.or(Some(temperature))
.map(|c| c_to_unit(c, unit));
Some(NwsObservation {
temperature: Some(c_to_unit(temperature, unit)),
windspeed: props.wind_speed.value.map(|k| kmh_to_system(k, system)),
humidity: props.relative_humidity.value.map(|v| v.round() as i32),
feels_like,
wind_direction: props.wind_direction.value.map(|v| v.round() as i32),
wind_gusts: props.wind_gust.value.map(|k| kmh_to_system(k, system)),
visibility: props.visibility.value.map(|m| m as f32), pressure: props
.barometric_pressure
.value
.or(props.sea_level_pressure.value)
.map(|pa| (pa / 100.0) as f32),
dew_point: props.dewpoint.value.map(|c| c_to_unit(c, unit)),
})
}
impl NwsObservation {
pub(crate) fn apply_to(&self, cw: &mut CurrentWeather) {
if let Some(v) = self.temperature {
cw.temperature = v;
}
if let Some(v) = self.windspeed {
cw.windspeed = v;
}
if let Some(v) = self.humidity {
cw.humidity = v;
}
if let Some(v) = self.feels_like {
cw.feels_like = v;
}
if let Some(v) = self.wind_gusts {
cw.wind_gusts = v;
}
if let Some(v) = self.visibility {
cw.visibility = v;
}
if let Some(v) = self.pressure {
cw.pressure = v;
}
if let Some(v) = self.dew_point {
cw.dew_point = v;
}
if let Some(v) = self.wind_direction {
cw.wind_direction = v;
cw.compass_direction = CompassDirection::from_degrees(v);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codes::WeatherCondition;
fn meas(v: f64) -> Measurement {
Measurement { value: Some(v) }
}
fn empty() -> Measurement {
Measurement { value: None }
}
fn sample() -> ObservationProperties {
ObservationProperties {
temperature: meas(20.0), dewpoint: meas(10.0), relative_humidity: meas(55.4), wind_direction: meas(180.0),
wind_speed: meas(16.093_44), wind_gust: meas(32.186_88), barometric_pressure: meas(101_325.0), sea_level_pressure: empty(),
visibility: meas(16_093.4), heat_index: empty(),
wind_chill: empty(),
}
}
fn om_current() -> CurrentWeather {
CurrentWeather {
temperature: 60.0,
weathercode: 3,
condition: WeatherCondition::from_code(3),
windspeed: 5.0,
humidity: 40,
feels_like: 58.0,
wind_direction: 90,
compass_direction: CompassDirection::from_degrees(90),
wind_gusts: 8.0,
uv_index: 4.0,
visibility: 10_000.0,
pressure: 1_000.0,
cloud_cover: 75,
dew_point: 45.0,
}
}
#[test]
fn converters_round_trip() {
assert!((c_to_unit(0.0, TemperatureUnit::Fahrenheit) - 32.0).abs() < 0.001);
assert!((c_to_unit(100.0, TemperatureUnit::Fahrenheit) - 212.0).abs() < 0.001);
assert_eq!(c_to_unit(18.5, TemperatureUnit::Celsius), 18.5);
assert!((kmh_to_system(1.609_344, MeasurementSystem::Imperial) - 1.0).abs() < 0.001);
assert_eq!(kmh_to_system(50.0, MeasurementSystem::Metric), 50.0);
}
#[test]
fn parse_maps_and_converts_every_field() {
let o = parse_observation(
sample(),
TemperatureUnit::Fahrenheit,
MeasurementSystem::Imperial,
)
.unwrap();
assert!((o.temperature.unwrap() - 68.0).abs() < 0.01);
assert!((o.dew_point.unwrap() - 50.0).abs() < 0.01);
assert_eq!(o.humidity.unwrap(), 55);
assert_eq!(o.wind_direction.unwrap(), 180);
assert!((o.windspeed.unwrap() - 10.0).abs() < 0.01);
assert!((o.wind_gusts.unwrap() - 20.0).abs() < 0.01);
assert!((o.pressure.unwrap() - 1013.25).abs() < 0.01);
assert!((o.visibility.unwrap() - 16_093.4).abs() < 0.5);
}
#[test]
fn none_without_temperature() {
let mut p = sample();
p.temperature = empty();
assert!(
parse_observation(p, TemperatureUnit::Celsius, MeasurementSystem::Metric).is_none()
);
}
#[test]
fn feels_like_precedence() {
let mut p = sample(); p.heat_index = meas(30.0);
p.wind_chill = meas(5.0);
assert_eq!(parse(p).feels_like.unwrap(), 30.0);
let mut p = sample(); p.wind_chill = meas(5.0);
assert_eq!(parse(p).feels_like.unwrap(), 5.0);
assert_eq!(parse(sample()).feels_like.unwrap(), 20.0); }
#[test]
fn absent_optionals_stay_none() {
let mut p = sample();
p.visibility = empty();
p.barometric_pressure = empty(); let o = parse(p);
assert!(o.visibility.is_none());
assert!(o.pressure.is_none());
}
fn parse(p: ObservationProperties) -> NwsObservation {
parse_observation(p, TemperatureUnit::Celsius, MeasurementSystem::Metric).unwrap()
}
#[test]
fn apply_to_overlays_present_preserves_absent_and_pairs_compass() {
let nws = NwsObservation {
temperature: Some(68.0),
windspeed: Some(10.0),
humidity: None, feels_like: None,
wind_direction: Some(180), wind_gusts: None,
visibility: None,
pressure: Some(1013.25),
dew_point: Some(50.0),
};
let mut cw = om_current();
nws.apply_to(&mut cw);
assert!((cw.temperature - 68.0).abs() < 0.01);
assert!((cw.windspeed - 10.0).abs() < 0.01);
assert!((cw.pressure - 1013.25).abs() < 0.01);
assert!((cw.dew_point - 50.0).abs() < 0.01);
assert_eq!(cw.wind_direction, 180);
assert_eq!(cw.compass_direction, CompassDirection::from_degrees(180));
assert_eq!(cw.humidity, 40);
assert!((cw.wind_gusts - 8.0).abs() < 0.01);
assert!((cw.feels_like - 58.0).abs() < 0.01);
assert_eq!(cw.weathercode, 3);
assert_eq!(cw.condition, WeatherCondition::from_code(3));
assert_eq!(cw.cloud_cover, 75);
assert!((cw.uv_index - 4.0).abs() < 0.01);
}
#[test]
fn apply_to_all_absent_is_a_noop() {
let nws = NwsObservation {
temperature: None,
windspeed: None,
humidity: None,
feels_like: None,
wind_direction: None,
wind_gusts: None,
visibility: None,
pressure: None,
dew_point: None,
};
let before = om_current();
let mut cw = before.clone(); nws.apply_to(&mut cw);
assert!((cw.temperature - before.temperature).abs() < f32::EPSILON);
assert_eq!(cw.wind_direction, before.wind_direction);
assert_eq!(cw.compass_direction, before.compass_direction); assert_eq!(cw.humidity, before.humidity);
assert!((cw.pressure - before.pressure).abs() < f32::EPSILON);
}
}