prop/rust/prop_grid_rs/src/pipeline.rs
Graham McInitre b3ad3bdbe6 fix: sweep bugs across Python MQTT listener, Elixir PSKR/propagation, and Rust
Python pskr_mqtt_listen.py:
- Guard against malformed CONNACK/SUBACK/PUBLISH packets
- Fix _s() truthiness: is not None instead of if v (zero SNR was lost)
- EINTR-safe select loop, OOB data handling, VBInt overflow check
- Proper socket cleanup with try/finally + DISCONNECT on shutdown

Elixir:
- Log dropped spot errors in aggregator (was silently discarded)
- Wire retain_scores_window into NotifyListener chain completion
- Recurse sweep_tmp_dir into band/weather_scalars subdirectories
- Rescue recalibrator.run/0 to write failed status row on crash
- Handle nil in fmt_snr/fmt_callsigns (no more nil dB crashes)
- Replace Stream.run with Enum.reduce logging exits in poll_worker
- Handle File.stat race in ms_footprints prune, grid_center nil log
- Fix unused variables, stale comments, missing get_path!/1

Rust:
- Graceful JoinError handling in fetcher/hrdps_fetcher (no more panics)
- round_to_5min returns Option (leap-second safe)
- Acquire/Release ordering on shutdown flags (was Relaxed)
- Parameterized NOTIFY in db.rs, defensive scalar sweep, NaN guard
- OsString::push for tmp naming, clippy fixes
2026-07-20 19:48:45 -05:00

1263 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! End-to-end f01..f48 chain step: fetch surface + pressure GRIBs, decode,
//! build per-cell `Conditions`, score every band, write score-grid files
//! (`<band>/<iso>.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::{hrdps_only_points, wgrib2_grid_spec};
use crate::hrdps_fetcher::{self, HrdpsClient};
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};
use crate::weather_scalar_file::{self, ScalarRow};
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("fetch: {0}")]
Fetch(#[from] fetcher::FetchError),
#[error("hrdps fetch: {0}")]
HrdpsFetch(#[from] hrdps_fetcher::HrdpsFetchError),
#[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("scalar write: {0}")]
ScalarWrite(#[from] weather_scalar_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<Utc>,
pub forecast_hour: u8,
}
impl ChainStepInput {
pub fn valid_time(&self) -> DateTime<Utc> {
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,
/// Number of cells written to the per-valid_time profile file.
/// Always `point_count` after the f01..f48 profile-file write was
/// added on 2026-04-25 — kept as a separate field so the log line
/// is symmetric with the analysis step's `profile_cells_written`
/// in `RunStepStats`, and so a future failure mode that writes
/// scores but skips the profile file shows up as a divergence.
pub profile_cells_written: 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<ChainStepStats, PipelineError> {
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;
// Build `prepared` (for scoring) and `profile_entries` (for the
// per-valid_time profile file consumed by /skewt and the LiveView
// point-detail panels) in a single pass over `merged`. We need a
// borrowing iterator (not `into_iter`) because `cell_to_profile_entry`
// also reads from the cell — `merged` is dropped immediately after
// this loop finishes, so the ~200 MB string-keyed HashMaps don't
// outlive the scoring stage. The price is briefly retaining both
// vecs at the same time as `merged`, which is well inside the
// 512 MiB cgroup ceiling on talos5.
let mut prepared: Vec<(f64, f64, Conditions, scorer::BandInvariants)> =
Vec::with_capacity(merged.len());
let mut profile_entries: Vec<CellEntry> = Vec::with_capacity(merged.len());
let mut scalar_rows: Vec<ScalarRow> = 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));
if let Some(row) = weather_scalar_file::derive_row(lat, lon, valid_time, cell) {
scalar_rows.push(row);
}
}
}
drop(merged);
let profile_cells_written = profile_entries.len() as u32;
// Spawn the profile-file write on the blocking pool so it overlaps
// with the scoring loop instead of serialising on the hot path.
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)
})
};
// Persist the derived scalar artifact alongside the raw profile so
// Elixir's `/weather` reads land on a pre-derived chunk file
// instead of falling back to the cold ProfilesFile decode + per-cell
// SoundingParams + WeatherLayers derivation. Identical wire format
// (gzipped MessagePack chunked by 5°×5°) to what the Elixir writer
// produces — the reader doesn't care which side wrote the bytes.
let scores_dir_for_scalars = scores_dir_owned.clone();
let scalar_future = {
let rows = scalar_rows;
tokio::task::spawn_blocking(move || {
weather_scalar_file::write_atomic(&scores_dir_for_scalars, valid_time, &rows)
.map(|_| 0u32)
})
};
// 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<ScorePoint> is ~1.8 MB so
// 23 concurrent ≈ ~40 MB peak.
let bands = band_config::all_bands();
let prepared = Arc::new(prepared);
let scored: Vec<(u32, Vec<ScorePoint>)> = {
use rayon::prelude::*;
bands
.par_iter()
.map(|band| {
let mut scores: Vec<ScorePoint> = 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_handles: Vec<_> = 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)
})
})
.collect();
for h in write_handles {
h.await.expect("blocking join")?;
}
profile_future
.await
.expect("blocking join")
.map_err(PipelineError::ProfileWrite)?;
scalar_future
.await
.expect("blocking join")
.map_err(PipelineError::ScalarWrite)?;
let files_written = bands.len() as u32;
Ok(ChainStepStats {
score_files_written: files_written,
point_count,
band_count: bands.len() as u32,
profile_cells_written,
})
}
/// HRDPS forecast-hour chain step. Sibling of `run_chain_step` for
/// `source = 'hrdps'` rows. Differences from the HRRR path:
///
/// * Single concatenated multi-record blob from
/// `hrdps_fetcher::HrdpsClient::fetch_combined_blob` (per-variable
/// fetch + byte-concat) instead of separate sfc/prs blobs.
/// * `hrdps_grid_spec()` for the wgrib2 grid extraction (Canadian
/// bbox, rotated lat/lon handled internally by wgrib2).
/// * Cells inside HRRR's CONUS bbox are dropped from the output —
/// HRRR owns those — using a HashSet of `hrdps_only_points()` as
/// the membership test.
/// * DEPR (T-Td depression) is back-filled to DPT keys before
/// `cell_to_conditions` runs, since HRDPS publishes DEPR as a
/// primary surface variable rather than DPT.
/// * Writes to `<base>/<band>/<iso>.hrdps.prop` via
/// `scores_file::write_atomic_hrdps`. Sibling file to HRRR's
/// `.prop`, never overwrites.
/// * Skips the per-valid_time profile + scalar artifacts — those
/// would clobber the HRRR-written equivalents at the same
/// `valid_time`. Re-introducing them when HRDPS surfaces on
/// `/weather` is a separate stage.
#[tracing::instrument(
name = "pipeline.run_chain_step_hrdps",
skip(client, scores_dir),
fields(
run_time = %step.run_time,
forecast_hour = step.forecast_hour,
)
)]
pub async fn run_chain_step_hrdps(
client: &HrdpsClient,
scores_dir: &Path,
step: &ChainStepInput,
) -> Result<ChainStepStats, PipelineError> {
if step.forecast_hour == 0 {
return Err(PipelineError::F00Reserved);
}
let valid_time = step.valid_time();
let cycle = step.run_time;
let blob = client
.fetch_combined_blob(cycle, step.forecast_hour)
.await?;
if blob.is_empty() {
return Err(PipelineError::EmptyGrib);
}
// wgrib2 inventory keys for HRDPS use the same NCEP-style level
// strings as HRRR (`TMP:2 m above ground`, `DEPR:850 mb`, etc.) —
// the MSC filename convention is independent of what wgrib2 prints
// when scanning the file. Match every variable HRDPS publishes;
// post-filter happens in cell_to_conditions.
let pattern = ":(TMP|DPT|DEPR|PRES|HPBL|UGRD|VGRD|TCDC|HGT):";
// Use point-extract (`wgrib2 -lon`) instead of full-grid `-lola`
// interpolation. The full-grid path takes >10 min per chain step on
// HRDPS's rotated lat/lon source (production observation 2026-04-29);
// -lon only computes the rotation math at the points we actually
// need, dropping wall time to ~30-90 s for the ~57k Canadian cells.
let canadian_points = hrdps_only_points();
let point_count_for_log = canadian_points.len() as u32;
let pattern_owned = pattern.to_string();
let blob_for_decode = blob.clone();
let grid = tokio::task::spawn_blocking(move || {
decoder::extract_points(&blob_for_decode, &pattern_owned, &canadian_points)
})
.await
.expect("blocking join")?;
drop(blob);
let point_count = grid.len() as u32;
tracing::info!(
requested = point_count_for_log,
decoded = point_count,
"hrdps points extracted"
);
let mut prepared: Vec<(f64, f64, Conditions, scorer::BandInvariants)> =
Vec::with_capacity(grid.len());
let mut scalar_rows: Vec<ScalarRow> = Vec::with_capacity(grid.len());
for (key, mut cell) in grid.into_iter() {
let (lat, lon) = decoder::key_to_latlon(key);
// HRDPS publishes DEPR (T-Td depression in K) as a primary
// surface + pressure-level variable rather than DPT. Back-fill
// DPT entries from `TMP - DEPR` so cell_to_conditions sees the
// expected key set.
backfill_dpt_from_depr(&mut cell);
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));
// Derive the per-cell scalar row alongside scoring. The /weather
// map reads these via Weather.weather_grid_at to render
// temperature/dewpoint-depression/refractivity layers — surfacing
// HRDPS here is what gives Canadian cells weather data on the
// map north of the HRRR coverage line.
if let Some(row) = weather_scalar_file::derive_row(lat, lon, valid_time, &cell) {
scalar_rows.push(row);
}
}
}
let bands = band_config::all_bands();
let prepared = std::sync::Arc::new(prepared);
let scored: Vec<(u32, Vec<ScorePoint>)> = {
use rayon::prelude::*;
bands
.par_iter()
.map(|band| {
let mut scores: Vec<ScorePoint> = 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 scores_dir_owned = scores_dir.to_path_buf();
// Write the HRDPS scalar artifact alongside the scores. Sibling dir
// (`<vt>.hrdps/`) so HRRR's `<vt>/` write isn't clobbered. Without
// this the /weather map would only show CONUS data.
let scalar_dir = scores_dir_owned.clone();
let scalar_future = tokio::task::spawn_blocking(move || {
weather_scalar_file::write_atomic_hrdps(&scalar_dir, valid_time, &scalar_rows).map(|_| ())
});
let write_handles: Vec<_> = scored
.into_iter()
.map(|(band_mhz, scores)| {
let dir = scores_dir_owned.clone();
tokio::task::spawn_blocking(move || {
scores_file::write_atomic_hrdps(&dir, band_mhz, valid_time, &scores)
})
})
.collect();
for h in write_handles {
h.await.expect("blocking join")?;
}
scalar_future
.await
.expect("blocking join")
.map_err(PipelineError::ScalarWrite)?;
let files_written = bands.len() as u32;
Ok(ChainStepStats {
score_files_written: files_written,
point_count,
band_count: bands.len() as u32,
profile_cells_written: 0,
})
}
fn backfill_dpt_from_depr(cell: &mut CellValues) {
// Snapshot of (TMP key, DEPR key, DPT key) tuples to insert. Done in
// two passes so the iteration in pass 1 isn't invalidated by the
// mutation in pass 2.
let pending: Vec<(std::sync::Arc<str>, f32)> = cell
.keys()
.filter_map(|key| {
let depr_key = key.strip_prefix("DEPR:")?;
let tmp_key: std::sync::Arc<str> = format!("TMP:{depr_key}").into();
let dpt_key: std::sync::Arc<str> = format!("DPT:{depr_key}").into();
if cell.contains_key(&dpt_key) {
return None;
}
let tmp = cell.get(&tmp_key).copied()?;
let depr = cell.get(key).copied()?;
Some((dpt_key, tmp - depr))
})
.collect();
for (dpt_key, dpt_val) in pending {
cell.insert(dpt_key, dpt_val);
}
}
/// 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<Utc>,
) -> Option<Conditions> {
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);
// Mirror Elixir's merged_rain_rate: pick the heavier of HRRR's
// accumulation-derived rate and NEXRAD's reflectivity-derived rate
// so a fast convective cell that hasn't yet shown up in the hourly
// APCP still triggers the rain penalty.
let hrrr_rate = cell
.get("APCP:surface")
.copied()
.map(|mm| mm as f64)
.unwrap_or(0.0);
let nexrad_rate = scorer::dbz_to_rain_rate_mmhr(
cell.get("nexrad_max_reflectivity_dbz")
.copied()
.map(|v| v as f64),
);
let merged_rate = hrrr_rate.max(nexrad_rate);
let rain_rate_mmhr = if merged_rate > 0.0 {
Some(merged_rate)
} else {
None
};
let bl_depth_m = cell.get("HPBL:surface").copied().map(|v| v as f64);
let best_duct_band_ghz = cell.get("best_duct_freq_ghz").copied().map(|v| v as f64);
let levels: Vec<Level> = 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,
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 nexrad_reflectivity_lifts_rain_rate_above_hrrr_precip() {
// Mirrors the Elixir scorer's rain merge: when HRRR's hourly
// accumulation hasn't caught a fast convective cell but NEXRAD
// sees 45 dBZ overhead, take whichever rate is higher. Without
// this the Rust pipeline silently underestimates rain attenuation.
use chrono::TimeZone;
let mut cell = CellValues::new();
cell.insert("TMP:2 m above ground".into(), 295.0);
cell.insert("DPT:2 m above ground".into(), 285.0);
cell.insert("APCP:surface".into(), 0.0);
cell.insert("nexrad_max_reflectivity_dbz".into(), 45.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();
let rate = c.rain_rate_mmhr.expect("nexrad-derived rain rate present");
assert!(rate > 5.0, "expected >5 mm/hr from 45 dBZ, got {rate}");
}
#[test]
fn duct_freq_threads_into_best_duct_band_ghz() {
// After native_duct::merge_duct_grid runs, every cell with a
// detected trapping layer carries `best_duct_freq_ghz`. The
// scorer reads `best_duct_band_ghz` from Conditions for the 15%
// Native Duct Boost. cell_to_conditions must wire one to the
// other or the boost never fires from the Rust pipeline.
use chrono::TimeZone;
let mut cell = CellValues::new();
cell.insert("TMP:2 m above ground".into(), 295.0);
cell.insert("DPT:2 m above ground".into(), 285.0);
cell.insert("best_duct_freq_ghz".into(), 24.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_eq!(c.best_duct_band_ghz, Some(24.0));
}
#[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));
}
#[test]
fn hrdps_rejects_forecast_hour_zero() {
use chrono::TimeZone;
let client = HrdpsClient::default_base().unwrap();
let dir = tempfile::tempdir().unwrap();
let step = ChainStepInput {
run_time: Utc.with_ymd_and_hms(2026, 4, 29, 12, 0, 0).unwrap(),
forecast_hour: 0,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let err = rt
.block_on(run_chain_step_hrdps(&client, dir.path(), &step))
.unwrap_err();
assert!(matches!(err, PipelineError::F00Reserved));
}
#[test]
fn backfill_dpt_from_depr_derives_missing_dewpoint() {
let mut cell = CellValues::new();
cell.insert("TMP:2 m above ground".into(), 290.15); // 17°C
cell.insert("DEPR:2 m above ground".into(), 5.0);
backfill_dpt_from_depr(&mut cell);
let dpt = cell
.get("DPT:2 m above ground")
.copied()
.expect("DPT back-filled from DEPR");
assert!((dpt - 285.15).abs() < 1e-3, "got {dpt}");
}
#[test]
fn backfill_dpt_does_not_clobber_existing_dpt() {
let mut cell = CellValues::new();
cell.insert("TMP:2 m above ground".into(), 290.15);
cell.insert("DEPR:2 m above ground".into(), 5.0);
cell.insert("DPT:2 m above ground".into(), 280.0); // pre-existing
backfill_dpt_from_depr(&mut cell);
assert_eq!(cell.get("DPT:2 m above ground").copied(), Some(280.0));
}
#[test]
fn backfill_dpt_handles_pressure_levels() {
let mut cell = CellValues::new();
cell.insert("TMP:850 mb".into(), 275.0);
cell.insert("DEPR:850 mb".into(), 4.0);
cell.insert("TMP:700 mb".into(), 265.0);
cell.insert("DEPR:700 mb".into(), 3.0);
backfill_dpt_from_depr(&mut cell);
assert_eq!(cell.get("DPT:850 mb").copied(), Some(271.0));
assert_eq!(cell.get("DPT:700 mb").copied(), Some(262.0));
}
/// 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<ScorePoint> = 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
);
}
/// cell_to_profile_entry includes pre-computed surface_refractivity
/// and min_refractivity_gradient so Elixir consumers (Skew-T,
/// contact detail) have a guaranteed fallback when SoundingParams.derive
/// hits a level without dewpoint.
#[test]
fn profile_entry_includes_refractivity_scalars() {
let mut cell = CellValues::new();
cell.insert("TMP:2 m above ground".into(), 298.15);
cell.insert("DPT:2 m above ground".into(), 293.15);
cell.insert("PRES:surface".into(), 101_300.0);
cell.insert("HPBL:surface".into(), 1500.0);
cell.insert(
"PWAT:entire atmosphere (considered as a single layer)".into(),
30.0,
);
cell.insert("TMP:1000 mb".into(), 295.0);
cell.insert("DPT:1000 mb".into(), 290.0);
cell.insert("HGT:1000 mb".into(), 100.0);
cell.insert("TMP:925 mb".into(), 292.0);
cell.insert("DPT:925 mb".into(), 285.0);
cell.insert("HGT:925 mb".into(), 800.0);
cell.insert("TMP:850 mb".into(), 289.0);
cell.insert("DPT:850 mb".into(), 280.0);
cell.insert("HGT:850 mb".into(), 1500.0);
let entry = cell_to_profile_entry(32.9, -97.0, &cell);
let map = entry.profile.as_map().expect("profile is a map");
let has_sr = map.iter().any(|(k, _)| k.as_str() == Some("surface_refractivity"));
let has_mg = map.iter().any(|(k, _)| k.as_str() == Some("min_refractivity_gradient"));
assert!(has_sr, "surface_refractivity key missing");
assert!(has_mg, "min_refractivity_gradient key missing");
}
/// When every pressure level is missing dewpoint, surface_refractivity
/// and min_refractivity_gradient are omitted.
#[test]
fn profile_entry_omits_refractivity_when_no_dewpoint() {
let mut cell = CellValues::new();
cell.insert("TMP:2 m above ground".into(), 298.15);
cell.insert("PRES:surface".into(), 101_300.0);
cell.insert("TMP:1000 mb".into(), 295.0);
cell.insert("HGT:1000 mb".into(), 100.0);
cell.insert("TMP:925 mb".into(), 292.0);
cell.insert("HGT:925 mb".into(), 800.0);
let entry = cell_to_profile_entry(32.9, -97.0, &cell);
let map = entry.profile.as_map().expect("profile is a map");
let has_sr = map.iter().any(|(k, _)| k.as_str() == Some("surface_refractivity"));
let has_mg = map.iter().any(|(k, _)| k.as_str() == Some("min_refractivity_gradient"));
assert!(!has_sr, "surface_refractivity should be absent without dewpoint");
assert!(!has_mg, "min_refractivity_gradient should be absent without dewpoint");
}
}
// =====================================================================
// 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<Utc>,
}
impl AnalysisStepInput {
pub fn valid_time(&self) -> DateTime<Utc> {
// 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<AnalysisStepStats, PipelineError> {
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()
.unwrap_or_else(|e| {
tracing::warn!(
"failed to build Nexrad HTTP client (missing root CA certs?): {} — \
f00 overlay will be skipped for this cycle",
e
);
// Return a client that will fail every request rather than
// panicking the worker. The f00 overlay is non-critical.
reqwest::Client::new()
});
let nexrad_points: Vec<(f64, f64)> =
merged.keys().map(|&k| decoder::key_to_latlon(k)).collect();
let nexrad_obs: Vec<NexradObservation> = 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<LinkLookupEntry> = 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<CellEntry> = Vec::with_capacity(merged.len());
let mut prepared: Vec<(f64, f64, Conditions, scorer::BandInvariants)> =
Vec::with_capacity(merged.len());
let mut scalar_rows: Vec<ScalarRow> = 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));
if let Some(row) = weather_scalar_file::derive_row(lat, lon, valid_time, cell) {
scalar_rows.push(row);
}
}
}
// 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)
})
};
// Same wire format as Elixir's `ScalarFile`. See run_chain_step
// (forecast hours) for the full rationale — both code paths go
// through the same writer so f00 and f01..f48 land identical
// chunked artifacts on NFS.
let scores_dir_for_scalars = scores_dir_owned.clone();
let scalar_future = {
let rows = scalar_rows;
tokio::task::spawn_blocking(move || {
weather_scalar_file::write_atomic(&scores_dir_for_scalars, valid_time, &rows)
.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<ScorePoint>)> = {
use rayon::prelude::*;
bands
.par_iter()
.map(|band| {
let mut scores: Vec<ScorePoint> = 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_handles: Vec<_> = 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)
})
})
.collect();
for h in write_handles {
h.await.expect("blocking join")?;
}
profile_future
.await
.expect("blocking join")
.map_err(PipelineError::ProfileWrite)?;
scalar_future
.await
.expect("blocking join")
.map_err(PipelineError::ScalarWrite)?;
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)));
}
// Wind / cloud / precip are read by Elixir's per-QSO scorer
// (`Propagation.factors_for` in propagation.ex) when a user clicks a
// grid cell on /map. Without these, wind/sky/rain factors silently
// score 0 for Rust-produced ProfilesFile cells.
if let Some(&u) = cell.get("UGRD:10 m above ground") {
kv.push(("wind_u", V::F64(u as f64)));
}
if let Some(&vv) = cell.get("VGRD:10 m above ground") {
kv.push(("wind_v", V::F64(vv as f64)));
}
if let Some(&c) = cell.get("TCDC:entire atmosphere") {
kv.push(("cloud_cover_pct", V::F64(c as f64)));
}
if let Some(&p) = cell.get("APCP:surface") {
kv.push(("precip_mm", V::F64(p 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.
// Also collect typed `Level` structs so we can pre-compute
// surface_refractivity and min_refractivity_gradient — the Elixir
// Skew-T page needs these but SoundingParams.derive will produce nil
// when any level is missing dewpoint. Pre-computing in Rust (which
// filters the same way) gives the Elixir side a guaranteed fallback.
let mut typed_levels: Vec<Level> = Vec::with_capacity(fetcher::grid_level_keys().len());
let levels: Vec<V> = 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 tmpc = (t as f64) - 273.15;
let dwpc = d.map(|dv| (dv as f64) - 273.15);
typed_levels.push(Level {
pres_mb: k.pres_mb,
hght_m: h as f64,
tmpc,
dwpc,
});
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(tmpc)));
if let Some(dv) = dwpc {
pairs.push((V::String("dwpc".into()), V::F64(dv)));
}
Some(V::Map(pairs))
})
.collect();
if !levels.is_empty() {
kv.push(("profile", V::Array(levels)));
}
// Pre-compute refractivity scalars so Elixir consumers (Skew-T,
// contact detail) have a guaranteed fallback when the pressure-level
// profile is missing dewpoint on some levels.
if let Some(sr) = sounding_params::surface_refractivity(&typed_levels) {
kv.push(("surface_refractivity", V::F64(sr)));
}
if let Some(mg) = sounding_params::min_refractivity_gradient(typed_levels) {
kv.push(("min_refractivity_gradient", V::F64(mg)));
}
let profile = value_map(kv);
CellEntry { lat, lon, profile }
}