//! IEM n0q CONUS composite reflectivity fetch + per-POI extraction. //! //! Port of `Microwaveprop.Weather.NexradClient.fetch_frame/2` — the //! subset used by f00's chain step. n0q is a palettized 8-bit PNG, //! 12200 × 5400 pixels, 0.005°/pixel, covering (-126W..-65W, 23N..50N) //! every 5 min. For each point of interest the extractor returns the //! max dBZ in a ~25 km box, matching Elixir's //! `extract_box/5` output field `max_reflectivity_dbz`. use bytes::Bytes; use chrono::{DateTime, Datelike, Timelike, Utc}; pub const LON_MIN: f64 = -126.0; pub const LAT_MAX: f64 = 50.0; pub const DEG_PER_PIXEL: f64 = 0.005; pub const DEFAULT_BOX_HALF: i32 = 25; pub const BASE_URL: &str = "https://mesonet.agron.iastate.edu/archive/data"; #[derive(Debug, thiserror::Error)] pub enum NexradError { #[error("http: {0}")] Http(#[from] reqwest::Error), #[error("decode: {0}")] Decode(String), #[error("unexpected status {0}")] Status(u16), } #[derive(Debug, Clone, Copy)] pub struct NexradObservation { pub lat: f64, pub lon: f64, pub max_reflectivity_dbz: f64, } /// Round a timestamp down to the nearest 5-min boundary (IEM archives /// 5-min cadence). pub fn round_to_5min(ts: DateTime) -> DateTime { let minute = ts.minute(); let rounded = (minute / 5) * 5; ts.with_minute(rounded) .and_then(|t| t.with_second(0)) .and_then(|t| t.with_nanosecond(0)) .expect("valid rounded timestamp") } pub fn frame_url(rounded: DateTime) -> String { format!( "{}/{:04}/{:02}/{:02}/GIS/uscomp/n0q_{:04}{:02}{:02}{:02}{:02}.png", BASE_URL, rounded.year(), rounded.month(), rounded.day(), rounded.year(), rounded.month(), rounded.day(), rounded.hour(), rounded.minute() ) } pub fn latlon_to_pixel(lat: f64, lon: f64) -> (i32, i32) { let x = ((lon - LON_MIN) / DEG_PER_PIXEL).round() as i32; let y = ((LAT_MAX - lat) / DEG_PER_PIXEL).round() as i32; (x, y) } /// Value 0 = no echo. Values 1..=255 map linearly to -30..=+95 dBZ. pub fn pixel_to_dbz(v: u8) -> f64 { if v == 0 { 0.0 } else { -30.0 + ((v - 1) as f64) / 254.0 * 125.0 } } /// Maximum dBZ within a 2·half+1 × 2·half+1 box around (cx, cy). /// Pixels outside the image or zero (no echo) are skipped. pub fn max_dbz_in_box(pixels: &[u8], width: i32, cx: i32, cy: i32, half: i32) -> f64 { let height = (pixels.len() as i32) / width; let x_min = (cx - half).max(0); let x_max = (cx + half).min(width - 1); let y_min = (cy - half).max(0); let y_max = (cy + half).min(height - 1); let mut max_val = 0u8; for y in y_min..=y_max { for x in x_min..=x_max { let off = (y * width + x) as usize; if off < pixels.len() { let v = pixels[off]; if v > max_val { max_val = v; } } } } pixel_to_dbz(max_val) } /// Decode an IEM n0q PNG to an indexed-pixel buffer. pub fn decode_n0q_png(bytes: &[u8]) -> Result<(Vec, i32), NexradError> { let decoder = png::Decoder::new(std::io::Cursor::new(bytes)); let mut reader = decoder .read_info() .map_err(|e| NexradError::Decode(format!("read_info: {e}")))?; let info = reader.info(); let width = info.width as i32; // IEM n0q is indexed-color 8-bit. `next_frame` fills the buffer // with raw palette indices, which is exactly what pixel_to_dbz // expects. let mut buf = vec![0u8; reader.output_buffer_size()]; reader .next_frame(&mut buf) .map_err(|e| NexradError::Decode(format!("next_frame: {e}")))?; Ok((buf, width)) } /// Fetch + decode the n0q frame nearest `timestamp`, then return one /// `max_reflectivity_dbz` per point of interest. pub async fn fetch_frame( client: &reqwest::Client, timestamp: DateTime, points: &[(f64, f64)], ) -> Result, NexradError> { let rounded = round_to_5min(timestamp); let url = frame_url(rounded); let resp = client .get(&url) .timeout(std::time::Duration::from_secs(60)) .send() .await?; if !resp.status().is_success() { return Err(NexradError::Status(resp.status().as_u16())); } let body: Bytes = resp.bytes().await?; let (pixels, width) = decode_n0q_png(&body)?; let obs = points .iter() .map(|&(lat, lon)| { let (cx, cy) = latlon_to_pixel(lat, lon); let dbz = max_dbz_in_box(&pixels, width, cx, cy, DEFAULT_BOX_HALF); NexradObservation { lat, lon, max_reflectivity_dbz: dbz, } }) .collect(); Ok(obs) } #[cfg(test)] mod tests { use super::*; use chrono::TimeZone; #[test] fn rounds_to_five_minute_boundary() { let t = Utc.with_ymd_and_hms(2026, 4, 19, 14, 37, 45).unwrap(); let r = round_to_5min(t); assert_eq!(r.minute(), 35); assert_eq!(r.second(), 0); assert_eq!(r.hour(), 14); } #[test] fn frame_url_format() { let t = Utc.with_ymd_and_hms(2026, 4, 19, 14, 35, 0).unwrap(); let u = frame_url(t); assert_eq!( u, "https://mesonet.agron.iastate.edu/archive/data/2026/04/19/GIS/uscomp/n0q_202604191435.png" ); } #[test] fn pixel_to_dbz_endpoints() { assert_eq!(pixel_to_dbz(0), 0.0); assert!((pixel_to_dbz(1) - -30.0).abs() < 1e-9); assert!((pixel_to_dbz(255) - 95.0).abs() < 1e-9); } #[test] fn latlon_to_pixel_reasonable() { // Corner sanity: (50N, -126W) → (0, 0) assert_eq!(latlon_to_pixel(50.0, -126.0), (0, 0)); // Mid-CONUS: (32.9, -97.0) let (x, y) = latlon_to_pixel(32.9, -97.0); assert!(x > 5000 && x < 7000, "x={x}"); assert!(y > 2000 && y < 4000, "y={y}"); } #[test] fn max_dbz_picks_hottest_pixel_in_box() { // 10x10 image. Non-zero pixel at (5, 5) = 200 (→ ~68 dBZ). let width = 10; let mut pixels = vec![0u8; 100]; pixels[5 * 10 + 5] = 200; let dbz = max_dbz_in_box(&pixels, width, 5, 5, 2); assert!((dbz - pixel_to_dbz(200)).abs() < 1e-9); } #[test] fn box_clipped_at_edges() { let width = 10; let pixels = vec![100u8; 100]; // Center outside image — clipped to 0. let dbz = max_dbz_in_box(&pixels, width, 50, 50, 5); assert_eq!(dbz, 0.0); } }