prop/rust/prop_grid_rs/src/native_duct.rs
Graham McIntire 63f25a9612
Some checks failed
Build prop-grid-rs / Test, build, push (push) Successful in 5m54s
Build and Push / Build and Push Docker Image (push) Failing after 4m44s
perf(grid-rs): dense grid, fused scoring pass, and columnar .pgrid profiles
Reworks the post-fetch half of the propagation pipeline. Fetch and GRIB2
decode were already cheap — measured against a live HRRR cycle, all 39
pressure messages decode via `wgrib2 -lola` in 0.29 s and the 31 MB
byte-range fetch takes ~3 s — so nothing here touches the decoder. All the
cost was downstream.

Also fixes a broken NOTIFY that made every chain step run up to 5 times.

pg_notify
  `NOTIFY propagation_ready, $1` is a Postgres syntax error: NOTIFY is a
  utility statement whose payload must be a literal, so a bind raises
  42601. It shared a transaction with the `status='done'` UPDATE, so every
  successful step rolled back, stayed 'running', and was requeued by
  reclaim_stale_running up to @max_reclaim_attempts times. Elixir's
  NotifyListener never fired either, so ScoreCache warm and the
  "propagation:updated" fan-out were dead.

FieldGrid
  A decoded grid was HashMap<(i32,i32), HashMap<Arc<str>, f32>> — a dense
  rectangular grid stored as ~95k nested hash maps, costing ~4.6M inserts
  on decode, ~3.7M on merge and ~14M lookups across three derivation
  passes. wgrib2 -lola already emits one dense row-major f32 block per
  message, so keep it: dense per-message planes, names hashed once per
  grid into plane ids, NaN as the missing sentinel. This is what forced
  PROP_GRID_RS_PARALLELISM=1 under a 3Gi limit.

Fused pass
  Three 95k-cell derivation passes plus 23 band-major scoring passes over
  a staged Vec<(f64,f64,Conditions,BandInvariants)> (~19MB re-streamed 23
  times) collapse into one pass: levels extracted once per cell, all 23
  bands scored while the cell is hot, scores accumulated cell-major so
  rayon chunks own disjoint slices. Scores land straight in the dense
  score-file body — no ScorePoint scatter.

.pgrid
  The profile artifact was an rmpv tree plus gzip -9, written 30x an hour,
  and ProfilesFile.read_point/3 gunzipped and unpacked the entire 95k-cell
  file to return one cell on every map click and Skew-T load. Replaced
  with a dense cell-major f32 record array carrying a self-describing
  field table. Elixir reads it via :file.pread; .mp.gz and .etf.gz remain
  readable so files written before this drain out of the 48h window.

  Measured on a full CONUS grid (95,073 cells x 48 planes x 23 bands):
    derive + score + build artifacts   0.022 s
    profile write   3.957 s -> 0.006 s (22.0 MB -> 22.4 MB on disk)
    single-cell read   whole-file decode -> 0.5 us
    23 score files     0.003 s

Also
  - hrrr_points: batched UNNEST upsert replacing one awaited INSERT per
    point. Keeps ON CONFLICT DO UPDATE — the PSKR sampler's two-pass loop
    depends on it.
  - fetcher: real semaphore capping in-flight ranges at
    MAX_PARALLEL_RANGES, which the comment claimed but the code did not do
    (it spawned all 27 while the connection pool was sized for 8).
  - metrics: per-stage histogram. Only chain-step and decode durations
    were instrumented, which is why the write cost stayed invisible.
  - profiles_file: parse_valid_time anchors on the known extension set, so
    sibling-suffixed names like <iso>.hrdps.prop no longer parse as
    <iso>.hrdps and vanish from prune and list operations.
  - PROP_GRID_RS_PARALLELISM 1 -> 3. Memory limit held at 3Gi until RSS is
    observed at the new parallelism.
  - cargo fmt over the crate; worker.rs, hrdps_fetcher.rs and nexrad.rs
    were already unformatted at HEAD and the pre-commit hook gates on it.

HRDPS still runs at 0.5 degrees. wgrib2 -lola scales linearly in output
points on rotated lat/lon (12.5 s wall, 202 s CPU for one message at
0.125 degrees) because it has no inverse projection for those grids; a raw
native dump is 0.32 s. The fix is decode-once plus a closed-form
rotated-pole index, left for a follow-up.
2026-08-01 08:23:36 -05:00

372 lines
14 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, DecodeError};
use crate::duct::{self, NativeProfile};
use crate::fetcher::{self, HrrrClient};
use crate::field_grid::{FieldGrid, PlaneId};
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<usize, 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: FieldGrid =
tokio::task::spawn_blocking(move || decoder::extract_grid(&blob, &pattern, grid_spec))
.await
.expect("blocking join")?;
Ok(reduce_grid_to_ducts(&grid))
}
/// Plane ids for the 4 duct variables across all 50 hybrid levels,
/// resolved once per grid instead of formatting 200 lookup keys per
/// cell (the old code built four `String`s per level per cell — 19 M
/// allocations across a CONUS grid).
struct DuctPlanes {
levels: Vec<(PlaneId, PlaneId, Option<PlaneId>, Option<PlaneId>)>,
}
impl DuctPlanes {
fn resolve(grid: &FieldGrid) -> Self {
let mut levels = Vec::with_capacity(NATIVE_LEVEL_COUNT as usize);
for level in 1..=NATIVE_LEVEL_COUNT {
let lvl = format!("{level} hybrid level");
// HGT and TMP are required; a level missing either is junk
// and was skipped by the old per-cell filter.
let (Some(hgt), Some(tmp)) = (
grid.plane_id(&format!("HGT:{lvl}")),
grid.plane_id(&format!("TMP:{lvl}")),
) else {
continue;
};
levels.push((
hgt,
tmp,
grid.plane_id(&format!("SPFH:{lvl}")),
grid.plane_id(&format!("PRES:{lvl}")),
));
}
Self { levels }
}
}
/// Per-cell reducer: build a `NativeProfile` from the dense planes and
/// run the duct analyser. Keyed by cell index rather than millidegree
/// lat/lon so the merge into the surface grid is a direct index write.
pub fn reduce_grid_to_ducts(grid: &FieldGrid) -> HashMap<usize, DuctMetrics> {
let planes = DuctPlanes::resolve(grid);
let mut out = HashMap::new();
let mut scratch: Vec<(f64, f64, f64, f64)> = Vec::with_capacity(planes.levels.len());
for cell in 0..grid.n_cells() {
scratch.clear();
for &(hgt_p, tmp_p, spfh_p, pres_p) in &planes.levels {
let (Some(hgt), Some(tmp)) = (grid.at(hgt_p, cell), grid.at(tmp_p, cell)) 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 = grid.at_opt(spfh_p, cell).unwrap_or(0.0);
let pres = grid.at_opt(pres_p, cell).unwrap_or(0.0);
scratch.push((hgt as f64, tmp as f64, spfh as f64, pres as f64));
}
if let Some(profile) = profile_from_levels(&mut scratch) {
let analysis = duct::analyze(&profile);
out.insert(
cell,
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
}
/// Sort one cell's collected `(height, tmp, spfh, pres)` rows ascending
/// by height and turn them into a `NativeProfile`. Mirrors the Elixir
/// `build_native_profile/1`: returns `None` if fewer than 3 levels
/// survive (too sparse to analyse).
///
/// Takes `&mut` so the caller can reuse one scratch buffer across all
/// ~95 k cells rather than allocating per cell.
fn profile_from_levels(levels: &mut [(f64, f64, f64, f64)]) -> Option<NativeProfile> {
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 NaN (i.e. "absent", exactly as the old code left the key
/// out); cells present get the four duct planes filled in.
pub fn merge_duct_grid(base: &mut FieldGrid, ducts: &HashMap<usize, DuctMetrics>) {
// Collect into dense planes, then push once each. Writing four
// planes in one pass beats four passes over the duct map.
let n = base.n_cells();
let mut min_grad = vec![f32::NAN; n];
let mut best_freq = vec![f32::NAN; n];
let mut max_thick = vec![f32::NAN; n];
let mut count = vec![f32::NAN; n];
for (&cell, metrics) in ducts {
if cell >= n {
continue;
}
min_grad[cell] = metrics.native_min_gradient as f32;
if let Some(f) = metrics.best_duct_freq_ghz {
best_freq[cell] = f as f32;
}
if let Some(t) = metrics.max_duct_thickness_m {
max_thick[cell] = t as f32;
}
count[cell] = metrics.duct_count as f32;
}
base.push_plane("native_min_gradient", min_grad);
base.push_plane("best_duct_freq_ghz", best_freq);
base.push_plane("max_duct_thickness_m", max_thick);
base.push_plane("duct_count", count);
}
/// 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<usize, 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 two_cell_spec() -> GridSpec {
GridSpec {
lon_start: -100.0,
lon_count: 2,
lon_step: 0.5,
lat_start: 30.0,
lat_count: 1,
lat_step: 0.5,
}
}
/// Grid where cell 0 carries `cell0_levels` hybrid levels and cell 1
/// carries `cell1_levels`. Levels beyond a cell's count are NaN, which
/// is how the dense representation says "absent".
fn grid_with_levels(cell0_levels: usize, cell1_levels: usize) -> FieldGrid {
let mut g = FieldGrid::new(two_cell_spec());
let max = cell0_levels.max(cell1_levels);
for i in 1..=max {
let present = |c: usize| if i <= c { 1.0 } else { f32::NAN };
g.push_plane(
&format!("HGT:{i} hybrid level"),
vec![
present(cell0_levels) * (i as f32) * 50.0,
present(cell1_levels) * (i as f32) * 50.0,
],
);
g.push_plane(
&format!("TMP:{i} hybrid level"),
vec![
present(cell0_levels) * (290.0 - (i as f32) * 0.5),
present(cell1_levels) * (290.0 - (i as f32) * 0.5),
],
);
g.push_plane(
&format!("SPFH:{i} hybrid level"),
vec![
present(cell0_levels) * (0.008 - (i as f32) * 0.0001),
present(cell1_levels) * (0.008 - (i as f32) * 0.0001),
],
);
g.push_plane(
&format!("PRES:{i} hybrid level"),
vec![
present(cell0_levels) * (101_000.0 - (i as f32) * 600.0),
present(cell1_levels) * (101_000.0 - (i as f32) * 600.0),
],
);
}
g
}
#[test]
fn profile_from_levels_drops_cells_with_fewer_than_3_levels() {
assert!(profile_from_levels(&mut []).is_none());
assert!(profile_from_levels(&mut [(1.0, 2.0, 3.0, 4.0); 2]).is_none());
assert!(profile_from_levels(&mut [(1.0, 2.0, 3.0, 4.0); 3]).is_some());
}
#[test]
fn profile_from_levels_sorts_by_height() {
// Interleaved: level 3 first in collection order, level 1 last.
let mut levels = [
(150.0, 287.0, 0.0, 0.0),
(10.0, 291.0, 0.0, 0.0),
(50.0, 289.0, 0.0, 0.0),
];
let p = profile_from_levels(&mut levels).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 grid = grid_with_levels(50, 2); // cell 1 too sparse
let out = reduce_grid_to_ducts(&grid);
assert_eq!(out.len(), 1);
let m = &out[&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_fills_only_cells_present_in_the_duct_map() {
let mut base = FieldGrid::new(two_cell_spec());
base.push_plane("PRES:surface", vec![101_000.0, 101_000.0]);
let ducts = HashMap::from([(
0usize,
DuctMetrics {
native_min_gradient: -150.0,
best_duct_freq_ghz: Some(24.0),
max_duct_thickness_m: Some(80.0),
duct_count: 1,
},
)]);
merge_duct_grid(&mut base, &ducts);
let pres = base.plane_id("PRES:surface").unwrap();
let grad = base.plane_id("native_min_gradient").unwrap();
let freq = base.plane_id("best_duct_freq_ghz").unwrap();
let count = base.plane_id("duct_count").unwrap();
assert_eq!(base.at(pres, 0), Some(101_000.0));
assert_eq!(base.at(grad, 0), Some(-150.0));
assert_eq!(base.at(freq, 0), Some(24.0));
assert_eq!(base.at(count, 0), Some(1.0));
// Cell 1 had no duct entry — every duct plane stays absent, which
// is what the old code achieved by never inserting the key.
assert_eq!(base.at(grad, 1), None);
assert_eq!(base.at(count, 1), None);
assert_eq!(base.at(pres, 1), Some(101_000.0));
}
}