prop/rust/prop_grid_rs/src/sounding_params.rs
Graham McIntire b80878056d
feat(grid-rs): Rust worker for HRRR f01..f18 propagation chain
Extracts the memory-hostile HRRR fetch → decode → score pipeline for
forecast hours 1–18 into a separate Rust service (`prop-grid-rs`).
Elixir retains f00 with its native-duct + NEXRAD + commercial-link
enrichment; Rust handles the other 18 steps per hourly chain.

Hand-off via a new `grid_tasks` table (`FOR UPDATE SKIP LOCKED` claim
from Rust, Postgrex `NOTIFY propagation_ready` back to Elixir). Rust
writes the same on-disk score-grid format Elixir already uses, to the
same `/data/scores` NFS tree. Phase 1 ships in shadow mode with
PROP_SCORES_DIR=/data/scores_shadow.

Rust crate layout at `rust/prop_grid_rs/` (1:1 module parity with the
Elixir source it ports):
  - grid, region, band_config, scores_file, sounding_params
  - scorer: all 10 factors + composite. Matches the Elixir scorer
    byte-for-byte across 115 golden-fixture samples (5 scenarios
    × 23 bands).
  - decoder: wgrib2 subprocess + Fortran-record lola-binary parser
  - fetcher: HRRR URL derivation + idx cache (1h TTL) + 8-way
    parallel byte-range downloads with 429/5xx retry
  - pipeline: end-to-end chain step, f00 rejected at the boundary
  - db: sqlx grid_tasks claim/complete + propagation_ready NOTIFY
  - bin/worker: tokio main loop, JSON logs, SIGTERM-safe

91 Rust tests + clippy -D warnings clean. 2,158 Elixir tests green.

Elixir additions:
  - `GridTaskEnqueuer` seeds fh=1..18 rows from
    `PropagationGridWorker.seed_chain/0`
  - `NotifyListener` LISTEN → warm `ScoreCache` → PubSub fan-out
  - `ShadowComparator` diffs prod vs shadow `.ntms` bodies
  - `mix rust.golden` task writes the Rust-side golden fixture

k8s: new `deployment-grid-rs.yaml` pinned to talos5 (32 GB NUC) via
`prop-grid-rs=primary` nodeSelector + `workload=grid-rs:NoSchedule`
toleration, 512 Mi limit, sharing the existing NFS `/data` mount.

The plan document is at `plans/vivid-hatching-quail.md` (local to my
workstation); phases 2 (cutover) and 3 (talos5 concurrency tuning)
follow after 72h of shadow-mode parity.
2026-04-19 15:42:49 -05:00

125 lines
4.4 KiB
Rust

//! Minimum-refractivity-gradient derivation from pressure-level
//! profiles. 1:1 port of the subset of
//! `lib/microwaveprop/weather/sounding_params.ex` needed by the
//! propagation grid scorer: `min_refractivity_gradient` per cell.
//!
//! The full Elixir module derives CAPE/LI/K-index/ducts — those are
//! used for contact enrichment and f00's native-duct merge, neither of
//! which is in the Rust f01..f18 scope.
#[derive(Debug, Clone)]
pub struct Level {
pub pres_mb: f64,
pub hght_m: f64,
pub tmpc: f64,
pub dwpc: Option<f64>,
}
/// Buck equation for saturation vapor pressure (hPa) at temperature in °C.
pub fn sat_vap_pres(t_c: f64) -> f64 {
6.1121 * ((18.678 - t_c / 234.5) * (t_c / (257.14 + t_c))).exp()
}
/// Minimum refractivity gradient (dN/dh, N-units per km) from a pressure
/// profile. Returns `None` if fewer than 3 usable levels or adjacent
/// pairs too close in height. Levels are internally sorted descending by
/// pressure (surface first) to match the Elixir orientation.
pub fn min_refractivity_gradient(mut levels: Vec<Level>) -> Option<f64> {
levels.retain(|l| l.dwpc.is_some());
if levels.len() < 3 {
return None;
}
levels.sort_by(|a, b| {
b.pres_mb
.partial_cmp(&a.pres_mb)
.unwrap_or(std::cmp::Ordering::Equal)
});
let sfc_hght = levels[0].hght_m;
let refract: Vec<(f64, f64)> = levels
.iter()
.map(|l| {
let t_k = l.tmpc + 273.15;
let e = sat_vap_pres(l.dwpc.expect("filtered above"));
let n = 77.6 * l.pres_mb / t_k + 3.73e5 * e / (t_k * t_k);
let h_agl = l.hght_m - sfc_hght;
(h_agl, n)
})
.collect();
let mut min_grad: Option<f64> = None;
for pair in refract.windows(2) {
let (h0, n0) = pair[0];
let (h1, n1) = pair[1];
let dh_km = (h1 - h0) / 1000.0;
if dh_km.abs() < 0.01 {
continue;
}
let g = (n1 - n0) / dh_km;
min_grad = Some(match min_grad {
Some(prev) if prev <= g => prev,
_ => g,
});
}
min_grad
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn buck_sat_vap_at_freezing() {
// At 0 °C, Buck gives ~6.112 hPa.
let e = sat_vap_pres(0.0);
assert!((e - 6.112).abs() < 0.02, "{e}");
}
#[test]
fn nil_for_too_few_levels() {
let levels = vec![Level {
pres_mb: 1000.0,
hght_m: 0.0,
tmpc: 25.0,
dwpc: Some(20.0),
}];
assert!(min_refractivity_gradient(levels).is_none());
}
#[test]
fn typical_tropospheric_gradient_is_negative() {
// Synthetic profile: refractivity decreases with altitude → negative
// dN/dh. Realistic numbers from a standard atmosphere.
let levels = vec![
Level { pres_mb: 1000.0, hght_m: 100.0, tmpc: 25.0, dwpc: Some(20.0) },
Level { pres_mb: 925.0, hght_m: 800.0, tmpc: 20.0, dwpc: Some(14.0) },
Level { pres_mb: 850.0, hght_m: 1500.0, tmpc: 15.0, dwpc: Some(8.0) },
Level { pres_mb: 700.0, hght_m: 3000.0, tmpc: 5.0, dwpc: Some(-5.0) },
];
let g = min_refractivity_gradient(levels).unwrap();
assert!(g < 0.0, "gradient should be negative: {g}");
assert!(g > -500.0, "no duct expected: {g}");
}
#[test]
fn surface_temperature_inversion_yields_stronger_gradient() {
// Surface inversion: cold moist near the ground, warm dry above —
// strong negative refractivity gradient.
let inversion = vec![
Level { pres_mb: 1000.0, hght_m: 100.0, tmpc: 18.0, dwpc: Some(17.0) },
Level { pres_mb: 990.0, hght_m: 300.0, tmpc: 22.0, dwpc: Some(5.0) },
Level { pres_mb: 950.0, hght_m: 700.0, tmpc: 20.0, dwpc: Some(3.0) },
Level { pres_mb: 850.0, hght_m: 1500.0, tmpc: 15.0, dwpc: Some(0.0) },
];
let strong = min_refractivity_gradient(inversion).unwrap();
let neutral = vec![
Level { pres_mb: 1000.0, hght_m: 100.0, tmpc: 25.0, dwpc: Some(18.0) },
Level { pres_mb: 925.0, hght_m: 800.0, tmpc: 20.0, dwpc: Some(14.0) },
Level { pres_mb: 850.0, hght_m: 1500.0, tmpc: 15.0, dwpc: Some(8.0) },
];
let baseline = min_refractivity_gradient(neutral).unwrap();
assert!(strong < baseline, "inversion {strong} vs baseline {baseline}");
}
}