//! End-to-end f01..f18 chain step: fetch surface + pressure GRIBs, decode, //! build per-cell `Conditions`, score every band, write score-grid files //! (`/.prop`) to the shared scores directory. //! //! This is the Rust equivalent of `PropagationGridWorker.process_forecast_hour/4` //! minus the f00-only enrichment (native duct, NEXRAD, commercial link //! boost, ProfilesFile write) — Elixir retains f00. use std::path::Path; use std::sync::Arc; use chrono::{DateTime, Datelike, Timelike, Utc}; use crate::band_config; use crate::commercial::{self, LinkLookupEntry}; use crate::decoder::{self, CellValues, PointGrid}; use crate::fetcher::{self, HrrrClient, Product}; use crate::grid::wgrib2_grid_spec; use crate::native_duct; use crate::nexrad::{self, NexradObservation}; use crate::profiles_file::{self, CellEntry}; use crate::scorer::{self, Conditions}; use crate::scores_file::{self, ScorePoint}; use crate::sounding_params::{self, Level}; #[derive(Debug, thiserror::Error)] pub enum PipelineError { #[error("fetch: {0}")] Fetch(#[from] fetcher::FetchError), #[error("decode: {0}")] Decode(#[from] decoder::DecodeError), #[error("write: {0}")] Write(#[from] scores_file::WriteError), #[error("profile write: {0}")] ProfileWrite(#[from] profiles_file::WriteError), #[error("native duct: {0}")] NativeDuct(#[from] native_duct::NativeDuctError), #[error("nexrad: {0}")] Nexrad(#[from] nexrad::NexradError), #[error("commercial: {0}")] Commercial(#[from] commercial::CommercialError), #[error("forecast hour 0 is reserved for Elixir")] F00Reserved, #[error("surface grib was empty")] EmptyGrib, } #[derive(Debug, Clone)] pub struct ChainStepInput { pub run_time: DateTime, pub forecast_hour: u8, } impl ChainStepInput { pub fn valid_time(&self) -> DateTime { self.run_time + chrono::Duration::hours(self.forecast_hour as i64) } } #[derive(Debug, Clone)] pub struct ChainStepStats { pub score_files_written: u32, pub point_count: u32, pub band_count: u32, } #[tracing::instrument( name = "pipeline.run_chain_step", skip(client, scores_dir), fields( run_time = %step.run_time, forecast_hour = step.forecast_hour, ) )] pub async fn run_chain_step( client: &HrrrClient, scores_dir: &Path, step: &ChainStepInput, ) -> Result { if step.forecast_hour == 0 { return Err(PipelineError::F00Reserved); } let valid_time = step.valid_time(); let date = step.run_time.date_naive(); let hour = step.run_time.hour() as u8; let sfc_wanted = fetcher::surface_messages_owned(); let prs_wanted = fetcher::pressure_messages_grid(); let sfc_fut = client.fetch_product_blob( date, hour, Product::Surface, step.forecast_hour, &sfc_wanted, ); let prs_fut = client.fetch_product_blob( date, hour, Product::Pressure, step.forecast_hour, &prs_wanted, ); let (sfc_blob, prs_blob) = tokio::try_join!(sfc_fut, prs_fut)?; if sfc_blob.is_empty() { return Err(PipelineError::EmptyGrib); } let grid_spec = wgrib2_grid_spec(); let sfc_pattern = match_pattern(&sfc_wanted); let prs_pattern = match_pattern(&prs_wanted); // Decoder runs wgrib2 subprocess — block so we don't hog the tokio // reactor. `spawn_blocking` lifts it onto the dedicated pool. let grid_spec_sfc = grid_spec; let sfc_grid = tokio::task::spawn_blocking(move || { decoder::extract_grid(&sfc_blob, &sfc_pattern, grid_spec_sfc) }) .await .expect("blocking join")?; let grid_spec_prs = grid_spec; let prs_grid = tokio::task::spawn_blocking(move || { decoder::extract_grid(&prs_blob, &prs_pattern, grid_spec_prs) }) .await .expect("blocking join")?; let merged = merge_grids(sfc_grid, prs_grid); let point_count = merged.len() as u32; // Step 1: consume `merged` into a compact `Vec<(lat, lon, Conditions, // invariants)>`. This is the biggest win — `merged`'s inner HashMaps // of `String -> f32` account for ~200 MB of heap (92k × ~60 string // keys) and are dropped as soon as this loop finishes. let prepared: Vec<(f64, f64, Conditions, scorer::BandInvariants)> = merged .into_iter() .filter_map(|(key, cell)| { let (lat, lon) = decoder::key_to_latlon(key); let conditions = cell_to_conditions(&cell, lat, lon, &valid_time)?; let invariants = scorer::precompute_band_invariants(&conditions); Some((lat, lon, conditions, invariants)) }) .collect(); // Step 2: score and write every band in parallel. // // Scoring: rayon's thread pool over the 23 bands. Each band is // independent pure math across the ~92k-cell `prepared` slice; // sharing is read-only so no locking. This cuts per-step scoring // wall from ~20s serial to ~5-8s on a 4-core box. // // Writing: each band file is an independent NFS fsync. Launching // them as parallel `spawn_blocking` tasks lets them overlap on the // blocking pool instead of serializing one-at-a-time on the hot // path. Memory stays bounded — each Vec is ~1.8 MB so // 23 concurrent ≈ ~40 MB peak. let bands = band_config::all_bands(); let scores_dir = scores_dir.to_path_buf(); let prepared = Arc::new(prepared); let scored: Vec<(u32, Vec)> = { use rayon::prelude::*; bands .par_iter() .map(|band| { let mut scores: Vec = Vec::with_capacity(prepared.len()); for (lat, lon, conditions, invariants) in prepared.iter() { let r = scorer::composite_score_with(conditions, band, Some(*invariants)); scores.push(ScorePoint { lat: *lat, lon: *lon, score: r.score, }); } (band.freq_mhz, scores) }) .collect() }; let write_futs = scored.into_iter().map(|(band_mhz, scores)| { let dir = scores_dir.clone(); tokio::task::spawn_blocking(move || { scores_file::write_atomic(&dir, band_mhz, valid_time, &scores) }) }); let results = futures::future::try_join_all(write_futs) .await .expect("blocking join"); for r in results { r?; } let files_written = bands.len() as u32; Ok(ChainStepStats { score_files_written: files_written, point_count, band_count: bands.len() as u32, }) } /// wgrib2 `-match` regex: `":(A|B|C):"`. Var names only — levels are /// post-filtered when we read the binary back. fn match_pattern(wanted: &[(String, String)]) -> String { let mut vars: Vec<&str> = wanted.iter().map(|(v, _)| v.as_str()).collect(); vars.sort(); vars.dedup(); format!(":({}):", vars.join("|")) } fn merge_grids(mut sfc: PointGrid, prs: PointGrid) -> PointGrid { for (k, v) in prs { sfc.entry(k).or_default().extend(v); } sfc } fn cell_to_conditions( cell: &CellValues, lat: f64, lon: f64, valid_time: &DateTime, ) -> Option { let tmp_k = cell.get("TMP:2 m above ground").copied()?; let dpt_k = cell.get("DPT:2 m above ground").copied()?; let temp_c = (tmp_k - 273.15) as f64; let dewpoint_c = (dpt_k - 273.15) as f64; let temp_f = scorer::c_to_f(temp_c); let dewpoint_f = scorer::c_to_f(dewpoint_c); let abs_humidity = scorer::absolute_humidity(temp_c, dewpoint_c); let wind_speed_kts = match ( cell.get("UGRD:10 m above ground").copied(), cell.get("VGRD:10 m above ground").copied(), ) { (Some(u), Some(v)) => Some(scorer::wind_speed_kts(u as f64, v as f64)), _ => None, }; let sky_cover_pct = cell .get("TCDC:entire atmosphere") .copied() .map(|v| v as f64); let pwat_mm = cell .get("PWAT:entire atmosphere (considered as a single layer)") .copied() .map(|v| v as f64); let pressure_mb = cell .get("PRES:surface") .copied() .map(|pa| pa as f64 / 100.0); let rain_rate_mmhr = cell .get("APCP:surface") .copied() .map(|mm| mm as f64) .filter(|v| *v > 0.0); let bl_depth_m = cell.get("HPBL:surface").copied().map(|v| v as f64); let levels: Vec = fetcher::grid_level_keys() .iter() .filter_map(|k| { let t = cell.get(k.tmp.as_str()).copied()?; let d = cell.get(k.dpt.as_str()).copied(); let h = cell.get(k.hgt.as_str()).copied()?; Some(Level { pres_mb: k.pres_mb, hght_m: h as f64, tmpc: (t - 273.15) as f64, dwpc: d.map(|v| (v - 273.15) as f64), }) }) .collect(); let min_refractivity_gradient = sounding_params::min_refractivity_gradient(levels); Some(Conditions { abs_humidity, temp_f, dewpoint_f, wind_speed_kts, sky_cover_pct, utc_hour: valid_time.hour() as u8, utc_minute: valid_time.minute() as u8, month: valid_time.month() as u8, longitude: lon, latitude: Some(lat), pressure_mb, prev_pressure_mb: None, rain_rate_mmhr, min_refractivity_gradient, bl_depth_m, pwat_mm, best_duct_band_ghz: None, bulk_richardson: None, }) } #[cfg(test)] mod tests { use super::*; #[test] fn match_pattern_dedups_vars() { let wanted = vec![ ("TMP".into(), "2 m above ground".into()), ("DPT".into(), "2 m above ground".into()), ("TMP".into(), "1000 mb".into()), ]; assert_eq!(match_pattern(&wanted), ":(DPT|TMP):"); } #[test] fn cell_to_conditions_maps_fields() { use chrono::TimeZone; let mut cell = CellValues::new(); cell.insert("TMP:2 m above ground".into(), 298.15); // 25 °C cell.insert("DPT:2 m above ground".into(), 293.15); // 20 °C cell.insert("UGRD:10 m above ground".into(), 3.0); cell.insert("VGRD:10 m above ground".into(), 4.0); cell.insert("TCDC:entire atmosphere".into(), 30.0); cell.insert("PRES:surface".into(), 101_000.0); cell.insert("HPBL:surface".into(), 400.0); cell.insert( "PWAT:entire atmosphere (considered as a single layer)".into(), 25.0, ); let vt = Utc.with_ymd_and_hms(2026, 6, 15, 18, 0, 0).unwrap(); let c = cell_to_conditions(&cell, 32.0, -97.0, &vt).unwrap(); assert!((c.temp_f - 77.0).abs() < 0.1); assert!((c.dewpoint_f - 68.0).abs() < 0.1); assert!((c.wind_speed_kts.unwrap() - 9.72).abs() < 0.1); assert!((c.pressure_mb.unwrap() - 1010.0).abs() < 0.01); assert_eq!(c.sky_cover_pct, Some(30.0)); assert_eq!(c.bl_depth_m, Some(400.0)); assert_eq!(c.pwat_mm, Some(25.0)); assert_eq!(c.month, 6); assert_eq!(c.longitude, -97.0); } #[test] fn cell_missing_surface_returns_none() { use chrono::TimeZone; let cell = CellValues::new(); let vt = Utc.with_ymd_and_hms(2026, 6, 15, 18, 0, 0).unwrap(); assert!(cell_to_conditions(&cell, 32.0, -97.0, &vt).is_none()); } #[test] fn rejects_forecast_hour_zero() { use chrono::TimeZone; let client = HrrrClient::default_base().unwrap(); let dir = tempfile::tempdir().unwrap(); let step = ChainStepInput { run_time: Utc.with_ymd_and_hms(2026, 4, 19, 15, 0, 0).unwrap(), forecast_hour: 0, }; let rt = tokio::runtime::Runtime::new().unwrap(); let err = rt .block_on(run_chain_step(&client, dir.path(), &step)) .unwrap_err(); assert!(matches!(err, PipelineError::F00Reserved)); } /// Integration-ish: hand-build a 2-cell surface + pressure grid, /// merge → cell_to_conditions → scorer → scores_file::write_atomic /// → scores_file::read, asserting the full chain agrees with /// itself. Covers the post-fetch hot path (the one that scales /// with grid size) without standing up wgrib2. #[test] fn merge_score_write_read_roundtrip() { use chrono::TimeZone; // decoder encodes lat/lon at millidegree precision in the key. fn latlon_to_key(lat: f64, lon: f64) -> (i32, i32) { ((lat * 1000.0).round() as i32, (lon * 1000.0).round() as i32) } let mut sfc = PointGrid::new(); let mut prs = PointGrid::new(); let cells = [(32.0_f64, -97.0_f64), (32.03_f64, -97.03_f64)]; for &(lat, lon) in &cells { let mut s = CellValues::new(); s.insert("TMP:2 m above ground".into(), 295.0); s.insert("DPT:2 m above ground".into(), 285.0); s.insert("UGRD:10 m above ground".into(), 2.0); s.insert("VGRD:10 m above ground".into(), 1.0); s.insert("TCDC:entire atmosphere".into(), 20.0); s.insert("PRES:surface".into(), 101_000.0); s.insert("HPBL:surface".into(), 500.0); s.insert( "PWAT:entire atmosphere (considered as a single layer)".into(), 20.0, ); sfc.insert(latlon_to_key(lat, lon), s); let mut p = CellValues::new(); p.insert("TMP:1000 mb".into(), 295.0); p.insert("DPT:1000 mb".into(), 285.0); p.insert("HGT:1000 mb".into(), 100.0); p.insert("TMP:975 mb".into(), 293.0); p.insert("DPT:975 mb".into(), 280.0); p.insert("HGT:975 mb".into(), 300.0); prs.insert(latlon_to_key(lat, lon), p); } let merged = merge_grids(sfc, prs); assert_eq!(merged.len(), 2); let valid_time = Utc.with_ymd_and_hms(2026, 6, 15, 18, 0, 0).unwrap(); let prepared: Vec<_> = merged .into_iter() .filter_map(|(key, cell)| { let (lat, lon) = crate::decoder::key_to_latlon(key); let c = cell_to_conditions(&cell, lat, lon, &valid_time)?; let inv = scorer::precompute_band_invariants(&c); Some((lat, lon, c, inv)) }) .collect(); assert_eq!(prepared.len(), 2); let band = &band_config::all_bands()[0]; let scores: Vec = prepared .iter() .map(|(lat, lon, c, inv)| { let r = scorer::composite_score_with(c, band, Some(*inv)); ScorePoint { lat: *lat, lon: *lon, score: r.score, } }) .collect(); assert_eq!(scores.len(), 2); for s in &scores { assert!(s.score <= 100); } // Write to an atomic .prop file and read it back. let dir = tempfile::tempdir().unwrap(); scores_file::write_atomic(dir.path(), band.freq_mhz, valid_time, &scores).unwrap(); let path = scores_file::path_for(dir.path(), band.freq_mhz, valid_time); let bytes = std::fs::read(&path).expect("read back score file"); let decoded = scores_file::decode(&bytes).expect("decode"); // Decoded grid covers the full CONUS bounding box as a dense // byte array; our 2 score points land at their respective // cells and the rest are sentinel no-data. Spot-check: header // fields survived the round trip and the body is the expected // size for a row_major n_rows × n_cols grid. assert_eq!(decoded.band_mhz, band.freq_mhz); assert_eq!( decoded.body.len(), decoded.n_rows as usize * decoded.n_cols as usize ); } } // ===================================================================== // Analysis pipeline (f00): fetch surface+pressure + native duct, merge // with NEXRAD + commercial-link degradation, score all bands, write the // ProfilesFile (MessagePack) and the band score files. // ===================================================================== #[derive(Debug, Clone)] pub struct AnalysisStepInput { pub run_time: DateTime, } impl AnalysisStepInput { pub fn valid_time(&self) -> DateTime { // f00 analysis valid_time == run_time. self.run_time } } #[derive(Debug, Clone)] pub struct AnalysisStepStats { pub score_files_written: u32, pub point_count: u32, pub band_count: u32, pub profile_cells_written: u32, pub duct_cells: u32, pub nexrad_cells_with_echo: u32, pub commercial_cells_boosted: u32, } #[tracing::instrument( name = "pipeline.run_analysis_step", skip(client, pool, scores_dir), fields(run_time = %step.run_time) )] pub async fn run_analysis_step( client: &HrrrClient, pool: &sqlx::PgPool, scores_dir: &Path, step: &AnalysisStepInput, ) -> Result { let valid_time = step.valid_time(); let date = step.run_time.date_naive(); let hour = step.run_time.hour() as u8; let grid_spec = wgrib2_grid_spec(); // Parallel fetch + decode of surface + pressure + native duct. All // three are independent HRRR products; overlap the HTTP legs and // the subsequent wgrib2 decodes so the long pole is a single // decode, not three serial decodes. let sfc_wanted = fetcher::surface_messages_owned(); let prs_wanted = fetcher::pressure_messages_grid(); let sfc_pattern = match_pattern(&sfc_wanted); let prs_pattern = match_pattern(&prs_wanted); // All three futures return different error types; lift into // PipelineError inside each branch so try_join! stays happy. let sfc_fut = async { client .fetch_product_blob(date, hour, Product::Surface, 0, &sfc_wanted) .await .map_err(PipelineError::Fetch) }; let prs_fut = async { client .fetch_product_blob(date, hour, Product::Pressure, 0, &prs_wanted) .await .map_err(PipelineError::Fetch) }; let duct_fut = async { native_duct::fetch_native_duct_grid(client, date, hour, 0, grid_spec) .await .map_err(PipelineError::NativeDuct) }; let (sfc_blob, prs_blob, duct_map) = tokio::try_join!(sfc_fut, prs_fut, duct_fut)?; let duct_count = duct_map.len() as u32; let sfc_grid = tokio::task::spawn_blocking(move || { decoder::extract_grid(&sfc_blob, &sfc_pattern, grid_spec) }) .await .expect("blocking join")?; let prs_grid = tokio::task::spawn_blocking(move || { decoder::extract_grid(&prs_blob, &prs_pattern, grid_spec) }) .await .expect("blocking join")?; // Merge pressure into surface, then fold duct metrics into every // cell that has both. The duct values live alongside the raw // CellValues strings so build_grid_cache_rows can pick them up // under their atom keys after the Elixir reader normalises. let mut merged = merge_grids(sfc_grid, prs_grid); merged = native_duct::merge_duct_grid(merged, &duct_map); // NEXRAD composite reflectivity overlay — only valid for f00 // because forecast hours can't see the future radar image. let nexrad_http = reqwest::Client::builder() .user_agent("prop-grid-rs/0.1") .build() .expect("reqwest client"); let nexrad_points: Vec<(f64, f64)> = merged.keys().map(|&k| decoder::key_to_latlon(k)).collect(); let nexrad_obs: Vec = match nexrad::fetch_frame( &nexrad_http, valid_time, &nexrad_points, ) .await { Ok(obs) => obs, Err(e) => { tracing::warn!(error = %e, "NEXRAD fetch failed — continuing without radar overlay"); Vec::new() } }; let nexrad_cells_with_echo = apply_nexrad(&mut merged, &nexrad_obs); // Commercial-link degradation — precompute once, then fold into // every in-range cell. Only ~7 links cluster around DFW so most // cells stay untouched. let commercial_lookup: Vec = commercial::build_link_lookup(pool, valid_time) .await .map_err(PipelineError::Commercial)?; let commercial_cells_boosted = apply_commercial(&mut merged, &commercial_lookup); let point_count = merged.len() as u32; // Build the ProfilesFile cell list (the raw enriched grid) and // the Conditions vector (for scoring) in one pass. Profile keeps // atmospheric + duct + NEXRAD + commercial fields under // string-keyed msgpack; Rust doesn't need to mutate those further. let mut profile_entries: Vec = Vec::with_capacity(merged.len()); let mut prepared: Vec<(f64, f64, Conditions, scorer::BandInvariants)> = Vec::with_capacity(merged.len()); for (key, cell) in merged.iter() { let (lat, lon) = decoder::key_to_latlon(*key); if let Some(conditions) = cell_to_conditions(cell, lat, lon, &valid_time) { let invariants = scorer::precompute_band_invariants(&conditions); prepared.push((lat, lon, conditions, invariants)); profile_entries.push(cell_to_profile_entry(lat, lon, cell)); } } // Write the profile file on the blocking pool — msgpack encode + // gzip + atomic rename. One file per run, ~2 MB compressed, so the // write runs well inside the scoring loop's wall time. let scores_dir_owned = scores_dir.to_path_buf(); let scores_dir_for_profiles = scores_dir_owned.clone(); let profile_future = { let profiles = profile_entries; tokio::task::spawn_blocking(move || { profiles_file::write_atomic(&scores_dir_for_profiles, valid_time, &profiles) .map(|_| 0u32) }) }; // Bands and score writes run in parallel, same shape as // run_chain_step. Rayon across bands, try_join_all across writes. let bands = band_config::all_bands(); let prepared_arc = Arc::new(prepared); let scored: Vec<(u32, Vec)> = { use rayon::prelude::*; bands .par_iter() .map(|band| { let mut scores: Vec = Vec::with_capacity(prepared_arc.len()); for (lat, lon, conditions, invariants) in prepared_arc.iter() { let r = scorer::composite_score_with(conditions, band, Some(*invariants)); scores.push(ScorePoint { lat: *lat, lon: *lon, score: r.score, }); } (band.freq_mhz, scores) }) .collect() }; let write_futs = scored.into_iter().map(|(band_mhz, scores)| { let dir = scores_dir_owned.clone(); tokio::task::spawn_blocking(move || { scores_file::write_atomic(&dir, band_mhz, valid_time, &scores) }) }); let results = futures::future::try_join_all(write_futs) .await .expect("blocking join"); for r in results { r?; } profile_future .await .expect("blocking join") .map_err(PipelineError::ProfileWrite)?; let files_written = bands.len() as u32; Ok(AnalysisStepStats { score_files_written: files_written, point_count, band_count: bands.len() as u32, profile_cells_written: prepared_arc.len() as u32, duct_cells: duct_count, nexrad_cells_with_echo, commercial_cells_boosted, }) } fn apply_nexrad(grid: &mut PointGrid, obs: &[NexradObservation]) -> u32 { let mut with_echo = 0u32; for o in obs { let key = ( (o.lat * 1000.0).round() as i32, (o.lon * 1000.0).round() as i32, ); // merged's keys are whatever extract_grid produced; use direct // lat/lon match by scanning. This is O(N*M) worst-case but N is // ~92k and obs mirrors the same point list, so in practice each // observation matches exactly one cell by int-key. if let Some(cell) = grid.get_mut(&key) { if o.max_reflectivity_dbz > 0.0 { with_echo += 1; } cell.insert( "nexrad_max_reflectivity_dbz".into(), o.max_reflectivity_dbz as f32, ); } } with_echo } fn apply_commercial(grid: &mut PointGrid, lookup: &[LinkLookupEntry]) -> u32 { let mut boosted = 0u32; for (key, cell) in grid.iter_mut() { let (lat, lon) = decoder::key_to_latlon(*key); if let Some(d) = commercial::degradation_at((lat, lon), lookup) { cell.insert("commercial_degradation_db".into(), d.degradation_db as f32); cell.insert("commercial_baseline_dbm".into(), d.baseline_dbm as f32); cell.insert("commercial_current_dbm".into(), d.current_dbm as f32); cell.insert("commercial_n_links".into(), d.n_links as f32); boosted += 1; } } boosted } /// Turn one enriched cell into a ProfilesFile entry. The msgpack /// `profile` map keeps string keys; the Elixir reader atomizes the /// whitelisted subset (surface_*, hpbl_m, pwat_mm, duct metrics, …). fn cell_to_profile_entry(lat: f64, lon: f64, cell: &CellValues) -> CellEntry { use profiles_file::value_map; use rmpv::Value as V; let mut kv: Vec<(&'static str, V)> = Vec::with_capacity(16); if let Some(&v) = cell.get("TMP:2 m above ground") { kv.push(("surface_temp_c", V::F64((v as f64) - 273.15))); } if let Some(&v) = cell.get("DPT:2 m above ground") { kv.push(("surface_dewpoint_c", V::F64((v as f64) - 273.15))); } if let Some(&v) = cell.get("PRES:surface") { kv.push(("surface_pressure_mb", V::F64((v as f64) / 100.0))); } if let Some(&v) = cell.get("HPBL:surface") { kv.push(("hpbl_m", V::F64(v as f64))); } if let Some(&v) = cell.get("PWAT:entire atmosphere (considered as a single layer)") { kv.push(("pwat_mm", V::F64(v as f64))); } if let Some(&v) = cell.get("native_min_gradient") { kv.push(("native_min_gradient", V::F64(v as f64))); } if let Some(&v) = cell.get("best_duct_freq_ghz") { kv.push(("best_duct_freq_ghz", V::F64(v as f64))); } if let Some(&v) = cell.get("max_duct_thickness_m") { kv.push(("max_duct_thickness_m", V::F64(v as f64))); } if let Some(&v) = cell.get("duct_count") { kv.push(("duct_count", V::Integer((v as i64).into()))); } if let Some(&v) = cell.get("nexrad_max_reflectivity_dbz") { kv.push(("nexrad_max_reflectivity_dbz", V::F64(v as f64))); } if let Some(&d) = cell.get("commercial_degradation_db") { let links = cell.get("commercial_n_links").copied().unwrap_or(0.0); let baseline = cell.get("commercial_baseline_dbm").copied().unwrap_or(0.0); let current = cell.get("commercial_current_dbm").copied().unwrap_or(0.0); kv.push(( "commercial_link_degradation", value_map([ ("degradation_db", V::F64(d as f64)), ("baseline_dbm", V::F64(baseline as f64)), ("current_dbm", V::F64(current as f64)), ("n_links", V::Integer((links as i64).into())), ]), )); } // Pressure-level profile list for SoundingParams.derive on the // Elixir side. Mirrors the fields derive expects. let levels: Vec = fetcher::grid_level_keys() .iter() .filter_map(|k| { let t = cell.get(k.tmp.as_str()).copied()?; let d = cell.get(k.dpt.as_str()).copied(); let h = cell.get(k.hgt.as_str()).copied()?; let mut pairs: Vec<(V, V)> = Vec::with_capacity(4); pairs.push((V::String("pres_mb".into()), V::F64(k.pres_mb))); pairs.push((V::String("hght_m".into()), V::F64(h as f64))); pairs.push((V::String("tmpc".into()), V::F64((t as f64) - 273.15))); if let Some(dv) = d { pairs.push((V::String("dwpc".into()), V::F64((dv as f64) - 273.15))); } Some(V::Map(pairs)) }) .collect(); if !levels.is_empty() { kv.push(("profile", V::Array(levels))); } let profile = value_map(kv); CellEntry { lat, lon, profile } }