prop/rust/prop_grid_rs/src/native_duct.rs
Graham McIntire 9e776a07d5
fix(skewt): write profile files for f01..f18 forecast hours
The /skewt page was empty in prod because Phase 2 of the Rust cutover
moved f01..f18 grid scoring to Rust but left profile-file writing only
on the analysis (f00) path. Result: /data/scores/profiles/ accumulated
one file per chain run instead of nineteen, so SkewtLive only ever saw
the analysis hour — and that hour falls outside the 1 h past cutoff
within an hour of the chain finishing, leaving the page empty most of
the time.

Two surgical fixes:

  * `pipeline::run_chain_step` now builds `profile_entries` alongside
    `prepared` from the merged grid in a single iter pass, then spawns
    `profiles_file::write_atomic` on the blocking pool to overlap the
    NFS write with the score-band scoring/write fan-out — same pattern
    `run_analysis_step` already uses. `ChainStepStats` gains
    `profile_cells_written` so the worker log line is symmetric with
    the analysis step's existing field and a future failure mode that
    drops the profile would show up as a divergence.

  * `SkewtLive.available_valid_times/0` widens the past cutoff from
    1 h to 3 h. The chain's analysis hour can be ~70 min old by the
    time the next chain finishes (and ~2 h on a missed cycle), so the
    1 h cutoff was leaving the page blank during normal transients.
    18 h forward window unchanged.

Cosmetic: cargo fmt picked up trivial rewrites in decoder/fetcher/
native_duct/hrrr_point_worker that had drifted; rolled in.

Verification: cargo build --release, cargo clippy --lib --bins -D
warnings, cargo test --lib (134/134), mix test
test/microwaveprop_web/live/skewt_live_test.exs (3/3) all green.
2026-04-25 12:58:10 -05:00

308 lines
12 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.

//! HRRR native hybrid-sigma duct detection for the CONUS grid.
//!
//! Port of `Microwaveprop.Weather.HrrrNativeClient.fetch_native_duct_grid`.
//! The native-level HRRR file (`wrfnatf{fh}.grib2`) is ~566 MB with TMP /
//! SPFH / HGT / PRES on all 50 hybrid-sigma levels — the ~10-50 m vertical
//! resolution needed to resolve surface ducts and boundary-layer
//! inversions. `HrrrClient` only covers surface + pressure levels, which
//! is ~250 m resolution near the surface and loses the ducts entirely.
//!
//! Flow: fetch idx → select duct byte ranges (4 vars × 50 levels = 200
//! messages, ~300 MB subset) → download via merged byte ranges → run
//! wgrib2 `-lola` to grid → per-cell build a `NativeProfile` and run
//! `duct::analyze`. Returns a HashMap keyed by (lat, lon) to per-cell
//! duct metrics.
use std::collections::HashMap;
use std::path::Path;
use chrono::NaiveDate;
use crate::decoder::{self, CellValues, DecodeError, PointGrid};
use crate::duct::{self, NativeProfile};
use crate::fetcher::{self, HrrrClient};
use crate::grid::GridSpec;
/// The 4 variables needed for duct detection. UGRD/VGRD/TKE were
/// features we never used; skipping them saves ~230 MB per file.
pub const DUCT_VARIABLES: [&str; 4] = ["TMP", "SPFH", "HGT", "PRES"];
/// HRRR's 50 native hybrid-sigma levels. Level 1 is surface.
pub const NATIVE_LEVEL_COUNT: u8 = 50;
#[derive(Debug, Clone, Copy)]
pub struct DuctMetrics {
pub native_min_gradient: f64,
pub best_duct_freq_ghz: Option<f64>,
pub max_duct_thickness_m: Option<f64>,
pub duct_count: u32,
}
#[derive(Debug, thiserror::Error)]
pub enum NativeDuctError {
#[error("fetch: {0}")]
Fetch(#[from] fetcher::FetchError),
#[error("decode: {0}")]
Decode(#[from] DecodeError),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
/// Build the (var, level) tuples for the duct-variable messages. 4
/// vars × 50 levels = 200 messages, matching the Elixir
/// `HrrrNativeClient.duct_messages/0`.
pub fn duct_messages() -> Vec<(String, String)> {
let mut out = Vec::with_capacity((NATIVE_LEVEL_COUNT as usize) * DUCT_VARIABLES.len());
for level in 1..=NATIVE_LEVEL_COUNT {
for var in DUCT_VARIABLES {
out.push((var.to_string(), format!("{level} hybrid level")));
}
}
out
}
/// wgrib2 `-match` regex: "`:(TMP|SPFH|HGT|PRES):.*hybrid level:`".
pub fn match_pattern() -> String {
format!(":({}):.*hybrid level:", DUCT_VARIABLES.join("|"))
}
/// URL builder for native-level HRRR files. Re-export of
/// `fetcher::hrrr_native_url` kept for external callers.
pub use crate::fetcher::hrrr_native_url;
/// Fetch the native-level duct grid for a (date, hour, forecast_hour)
/// triple. Downloads the byte-range subset, decodes via wgrib2, and
/// computes duct metrics per cell.
pub async fn fetch_native_duct_grid(
client: &HrrrClient,
date: NaiveDate,
hour: u8,
forecast_hour: u8,
grid_spec: GridSpec,
) -> Result<HashMap<(i32, i32), DuctMetrics>, NativeDuctError> {
let messages = duct_messages();
let blob = client
.fetch_native_blob(date, hour, forecast_hour, &messages)
.await?;
// wgrib2 decode must run on the blocking pool — it forks a
// subprocess and the stdout read is synchronous IO.
let pattern = match_pattern();
let grid: PointGrid =
tokio::task::spawn_blocking(move || decoder::extract_grid(&blob, &pattern, grid_spec))
.await
.expect("blocking join")?;
Ok(reduce_grid_to_ducts(grid))
}
/// Per-cell reducer: turn the `CellValues` map into a
/// `NativeProfile` and run the duct analyser.
pub fn reduce_grid_to_ducts(grid: PointGrid) -> HashMap<(i32, i32), DuctMetrics> {
let mut out = HashMap::with_capacity(grid.len());
for (key, cell) in grid {
if let Some(profile) = build_native_profile(&cell) {
let analysis = duct::analyze(&profile);
out.insert(
key,
DuctMetrics {
native_min_gradient: duct::min_m_gradient(&profile),
best_duct_freq_ghz: analysis.best_duct_band_ghz,
max_duct_thickness_m: duct::max_duct_thickness_m(&analysis.ducts),
duct_count: analysis.ducts.len() as u32,
},
);
}
}
out
}
/// Build a `NativeProfile` from one cell's raw values. Mirrors the
/// Elixir `build_native_profile/1`: walk all 50 hybrid levels, keep
/// only those with both HGT and TMP present, then sort ascending by
/// height. Returns `None` if fewer than 3 levels survive (too sparse
/// to analyse).
pub fn build_native_profile(cell: &CellValues) -> Option<NativeProfile> {
// (height, tmp, spfh, pres) per level. Filter out rows where HGT
// or TMP is absent — those levels are junk.
let mut levels: Vec<(f64, f64, f64, f64)> = Vec::with_capacity(NATIVE_LEVEL_COUNT as usize);
for level in 1..=NATIVE_LEVEL_COUNT {
// `cell.get` dispatches via Arc<str>'s Borrow<str>, so pass
// &str refs built by `format!(…).as_str()` (or inline &str
// concat) — passing `&String` triggers the wrong Borrow impl.
let lvl_str = format!("{level} hybrid level");
let hgt_key = format!("HGT:{lvl_str}");
let tmp_key = format!("TMP:{lvl_str}");
let spfh_key = format!("SPFH:{lvl_str}");
let pres_key = format!("PRES:{lvl_str}");
let Some(&hgt) = cell.get(hgt_key.as_str()) else {
continue;
};
let Some(&tmp) = cell.get(tmp_key.as_str()) else {
continue;
};
// SPFH / PRES default to 0.0 if absent — the duct math handles
// degenerate rows (returns 0.0 gradient / no duct) without
// raising, matching Elixir's nil-tolerance.
let spfh = cell.get(spfh_key.as_str()).copied().unwrap_or(0.0);
let pres = cell.get(pres_key.as_str()).copied().unwrap_or(0.0);
levels.push((hgt as f64, tmp as f64, spfh as f64, pres as f64));
}
if levels.len() < 3 {
return None;
}
levels.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let heights_m = levels.iter().map(|x| x.0).collect();
let temp_k = levels.iter().map(|x| x.1).collect();
let spfh = levels.iter().map(|x| x.2).collect();
let pressure_pa = levels.iter().map(|x| x.3).collect();
Some(NativeProfile {
heights_m,
temp_k,
spfh,
pressure_pa,
})
}
/// Merge native duct metrics into an existing surface+pressure grid.
/// Mirrors Elixir's `apply_duct_grid` — cells missing from the duct
/// map keep their previous contents; cells present overwrite the
/// `native_min_gradient` / `best_duct_freq_ghz` / etc. fields.
///
/// Consumers that only need the raw duct HashMap can skip this; the
/// merge helper exists for symmetry with the Elixir call site.
pub fn merge_duct_grid(
mut base: HashMap<(i32, i32), CellValues>,
ducts: &HashMap<(i32, i32), DuctMetrics>,
) -> HashMap<(i32, i32), CellValues> {
for (key, metrics) in ducts {
if let Some(cell) = base.get_mut(key) {
cell.insert(
"native_min_gradient".into(),
metrics.native_min_gradient as f32,
);
if let Some(f) = metrics.best_duct_freq_ghz {
cell.insert("best_duct_freq_ghz".into(), f as f32);
}
if let Some(t) = metrics.max_duct_thickness_m {
cell.insert("max_duct_thickness_m".into(), t as f32);
}
cell.insert("duct_count".into(), metrics.duct_count as f32);
}
}
base
}
/// Legacy entry used by goldens/tests: decode a GRIB2 blob already
/// on disk with the duct match pattern and reduce.
pub fn duct_grid_from_file(
grib_path: &Path,
grid_spec: GridSpec,
) -> Result<HashMap<(i32, i32), DuctMetrics>, NativeDuctError> {
let grid = decoder::extract_grid_from_file(grib_path, &match_pattern(), grid_spec)?;
Ok(reduce_grid_to_ducts(grid))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn duct_messages_counts_200() {
let m = duct_messages();
assert_eq!(m.len(), 200);
// First and last samples.
assert_eq!(m[0], ("TMP".into(), "1 hybrid level".into()));
assert_eq!(m[m.len() - 1], ("PRES".into(), "50 hybrid level".into()));
}
#[test]
fn match_pattern_includes_all_duct_vars() {
let p = match_pattern();
assert_eq!(p, ":(TMP|SPFH|HGT|PRES):.*hybrid level:");
}
fn cell_with_levels(count: usize) -> CellValues {
let mut c = CellValues::new();
for i in 1..=count {
c.insert(format!("HGT:{i} hybrid level").into(), (i as f32) * 50.0);
c.insert(
format!("TMP:{i} hybrid level").into(),
290.0 - (i as f32) * 0.5,
);
c.insert(
format!("SPFH:{i} hybrid level").into(),
0.008 - (i as f32) * 0.0001,
);
c.insert(
format!("PRES:{i} hybrid level").into(),
101_000.0 - (i as f32) * 600.0,
);
}
c
}
#[test]
fn build_profile_drops_cells_with_fewer_than_3_levels() {
assert!(build_native_profile(&cell_with_levels(0)).is_none());
assert!(build_native_profile(&cell_with_levels(2)).is_none());
assert!(build_native_profile(&cell_with_levels(3)).is_some());
}
#[test]
fn build_profile_sorts_by_height() {
let mut cell = CellValues::new();
// Interleaved: level 3 comes first in insertion order, level 1 last.
cell.insert("HGT:3 hybrid level".into(), 150.0);
cell.insert("TMP:3 hybrid level".into(), 287.0);
cell.insert("HGT:1 hybrid level".into(), 10.0);
cell.insert("TMP:1 hybrid level".into(), 291.0);
cell.insert("HGT:2 hybrid level".into(), 50.0);
cell.insert("TMP:2 hybrid level".into(), 289.0);
let p = build_native_profile(&cell).unwrap();
assert_eq!(p.heights_m, vec![10.0, 50.0, 150.0]);
assert_eq!(p.temp_k, vec![291.0, 289.0, 287.0]);
}
#[test]
fn reduce_grid_builds_metrics_for_cells_with_enough_levels() {
let mut grid: PointGrid = HashMap::new();
grid.insert((0, 0), cell_with_levels(50));
grid.insert((1, 0), cell_with_levels(2)); // too sparse
let out = reduce_grid_to_ducts(grid);
assert_eq!(out.len(), 1);
let m = &out[&(0, 0)];
// Temperature decreases with height and moisture decreases too
// — constructed profile has no strong inversion, so the
// count should be 0 but min_gradient is still computed.
assert!(m.native_min_gradient.is_finite());
}
#[test]
fn merge_duct_grid_only_touches_shared_keys() {
let mut base: HashMap<(i32, i32), CellValues> = HashMap::new();
base.insert((0, 0), {
let mut c = CellValues::new();
c.insert("PRES:surface".into(), 101_000.0);
c
});
let ducts = HashMap::from([(
(0, 0),
DuctMetrics {
native_min_gradient: -150.0,
best_duct_freq_ghz: Some(24.0),
max_duct_thickness_m: Some(80.0),
duct_count: 1,
},
)]);
let merged = merge_duct_grid(base, &ducts);
let cell = &merged[&(0, 0)];
assert_eq!(cell.get("PRES:surface").copied(), Some(101_000.0));
assert_eq!(cell.get("native_min_gradient").copied(), Some(-150.0));
assert_eq!(cell.get("best_duct_freq_ghz").copied(), Some(24.0));
assert_eq!(cell.get("duct_count").copied(), Some(1.0));
}
}