prop/rust/prop_grid_rs/src/decoder.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

355 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.

//! wgrib2 subprocess wrapper. 1:1 port of the hot paths in
//! `lib/microwaveprop/weather/grib2/wgrib2.ex` used by f01..f18:
//! * `extract_grid` (write GRIB2 to tmp → `wgrib2 -lola … bin` → parse)
//! * inventory parsing from stdout
//! * `parse_lola_binary` (Fortran-unformatted IEEE 754 little-endian f32)
//!
//! wgrib2 is called as a child process exactly as the Elixir version
//! does — same CLI flags, same temp-file lifecycle. Output binary is
//! stride-addressed: each message = 4-byte LE record length + N*4 bytes
//! of data + 4-byte duplicate record length.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use byteorder::{ByteOrder, LittleEndian};
use crate::grid::{round3, GridSpec};
const UNDEFINED_VALUE: f32 = 9.999e20;
static UNIQUE: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, thiserror::Error)]
pub enum DecodeError {
#[error("wgrib2 not available on PATH")]
Wgrib2NotAvailable,
#[error("wgrib2 failed (exit {code}): {stderr}")]
Wgrib2Failed { code: i32, stderr: String },
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone)]
pub struct Message {
pub var: String,
pub level: String,
}
/// Per-cell: `"VAR:LEVEL" -> f32`. Key format matches Elixir exactly so
/// scorer input can be wired unchanged.
pub type CellValues = HashMap<String, f32>;
/// Lat/lon rounded to 3 decimals. f64 keys can't be hashed directly, so
/// we key by `(lat_mdeg, lon_mdeg)` where `*_mdeg = round(x * 1000) as i32`.
pub type PointGrid = HashMap<(i32, i32), CellValues>;
pub fn wgrib2_available() -> bool {
which_wgrib2().is_some()
}
fn which_wgrib2() -> Option<PathBuf> {
if let Ok(val) = std::env::var("WGRIB2") {
return Some(PathBuf::from(val));
}
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join("wgrib2");
if candidate.is_file() {
return Some(candidate);
}
}
None
}
pub fn normalize_lon(lon: f64) -> f64 {
if lon < 0.0 {
lon + 360.0
} else {
lon
}
}
pub fn denormalize_lon(lon: f64) -> f64 {
if lon > 180.0 {
lon - 360.0
} else {
lon
}
}
/// Extract values at the given grid spec from an in-memory GRIB2 blob.
/// Writes the blob to a temp file, runs wgrib2, parses the output.
pub fn extract_grid(
grib: &[u8],
match_pattern: &str,
grid_spec: GridSpec,
) -> Result<PointGrid, DecodeError> {
let wgrib2 = which_wgrib2().ok_or(DecodeError::Wgrib2NotAvailable)?;
let tmp_dir = std::env::temp_dir();
let uniq = UNIQUE.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let grib_path = tmp_dir.join(format!("hrrr_{nanos}_{uniq}.grib2"));
let bin_path = tmp_dir.join(format!("hrrr_{nanos}_{uniq}.grib2.lola.bin"));
let result: Result<PointGrid, DecodeError> = (|| {
std::fs::write(&grib_path, grib)?;
run_wgrib2_lola(&wgrib2, &grib_path, match_pattern, &grid_spec, &bin_path)
})();
let _ = std::fs::remove_file(&grib_path);
let _ = std::fs::remove_file(&bin_path);
result
}
/// File-path variant — avoids the intermediate `write(tmp, binary)` copy.
pub fn extract_grid_from_file(
grib_path: &Path,
match_pattern: &str,
grid_spec: GridSpec,
) -> Result<PointGrid, DecodeError> {
let wgrib2 = which_wgrib2().ok_or(DecodeError::Wgrib2NotAvailable)?;
let bin_path = sibling_tmp_path(grib_path, "lola");
let result = run_wgrib2_lola(&wgrib2, grib_path, match_pattern, &grid_spec, &bin_path);
let _ = std::fs::remove_file(&bin_path);
result
}
fn sibling_tmp_path(path: &Path, suffix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let uniq = UNIQUE.fetch_add(1, Ordering::Relaxed);
let mut s = path.as_os_str().to_owned();
s.push(format!(".{suffix}.{nanos}.{uniq}.bin"));
PathBuf::from(s)
}
fn run_wgrib2_lola(
wgrib2: &Path,
grib_path: &Path,
match_pattern: &str,
grid_spec: &GridSpec,
bin_path: &Path,
) -> Result<PointGrid, DecodeError> {
let lon_spec = format!(
"{}:{}:{}",
normalize_lon(grid_spec.lon_start),
grid_spec.lon_count,
grid_spec.lon_step
);
let lat_spec = format!(
"{}:{}:{}",
grid_spec.lat_start, grid_spec.lat_count, grid_spec.lat_step
);
let Output { status, stdout, stderr } = Command::new(wgrib2)
.arg(grib_path)
.arg("-match")
.arg(match_pattern)
.arg("-lola")
.arg(&lon_spec)
.arg(&lat_spec)
.arg(bin_path)
.arg("bin")
.output()?;
if !status.success() {
let mut combined = stdout;
combined.extend_from_slice(&stderr);
let text = String::from_utf8_lossy(&combined).to_string();
let snippet: String = text.chars().take(200).collect();
return Err(DecodeError::Wgrib2Failed {
code: status.code().unwrap_or(-1),
stderr: snippet,
});
}
let inventory = String::from_utf8_lossy(&stdout);
let messages = parse_inventory(&inventory);
match std::fs::read(bin_path) {
Ok(bin) => Ok(parse_lola_binary(&bin, &messages, grid_spec)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()),
Err(e) => Err(e.into()),
}
}
/// Parse `wgrib2`'s stdout inventory (`"n:offset:date:var:level:..."`).
/// Malformed lines are dropped.
pub fn parse_inventory(out: &str) -> Vec<Message> {
out.lines()
.filter_map(|line| {
let mut parts = line.splitn(8, ':');
let _n = parts.next()?;
let _offset = parts.next()?;
let _date = parts.next()?;
let var = parts.next()?;
let level = parts.next()?;
Some(Message {
var: var.to_string(),
level: level.to_string(),
})
})
.collect()
}
/// Parse the Fortran-unformatted output file from `wgrib2 -lola`.
/// Layout: per message = 4-byte LE u32 record length + N·4 bytes f32 LE
/// data + 4-byte LE u32 record length. `N = nx * ny`. Cells whose value
/// exceeds `UNDEFINED_VALUE / 2` are treated as missing (the sentinel
/// wgrib2 writes for "no data").
pub fn parse_lola_binary(bin: &[u8], messages: &[Message], grid_spec: &GridSpec) -> PointGrid {
let nx = grid_spec.lon_count;
let ny = grid_spec.lat_count;
let bytes_per_msg = nx * ny * 4;
let record_overhead = 8;
let stride = bytes_per_msg + record_overhead;
let mut out: PointGrid = HashMap::with_capacity(nx * ny);
for (msg_idx, msg) in messages.iter().enumerate() {
let data_offset = msg_idx * stride + 4;
if data_offset + bytes_per_msg > bin.len() {
continue;
}
let chunk = &bin[data_offset..data_offset + bytes_per_msg];
let key = format!("{}:{}", msg.var, msg.level);
for j in 0..ny {
let lat = round3(grid_spec.lat_start + j as f64 * grid_spec.lat_step);
let lat_key = (lat * 1000.0).round() as i32;
for i in 0..nx {
let cell_offset = (j * nx + i) * 4;
let value = LittleEndian::read_f32(&chunk[cell_offset..cell_offset + 4]);
if value > UNDEFINED_VALUE / 2.0 {
continue;
}
let raw_lon = grid_spec.lon_start + i as f64 * grid_spec.lon_step;
let lon = round3(denormalize_lon(raw_lon));
let lon_key = (lon * 1000.0).round() as i32;
out.entry((lat_key, lon_key))
.or_default()
.insert(key.clone(), value);
}
}
}
out
}
pub fn key_to_latlon(key: (i32, i32)) -> (f64, f64) {
(key.0 as f64 / 1000.0, key.1 as f64 / 1000.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lon_round_trip() {
for lon in [-125.0, -97.0, -66.0, 0.0, 179.5] {
assert!((denormalize_lon(normalize_lon(lon)) - lon).abs() < 1e-9);
}
}
#[test]
fn inventory_parses_var_level() {
let text = "1:0:d=2026041915:TMP:2 m above ground:anl:\n\
2:1234:d=2026041915:DPT:2 m above ground:anl:\n";
let msgs = parse_inventory(text);
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].var, "TMP");
assert_eq!(msgs[1].level, "2 m above ground");
}
#[test]
fn inventory_drops_short_lines() {
let text = "garbage\n1:0:d:TMP:surface:anl:\n";
let msgs = parse_inventory(text);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].var, "TMP");
}
#[test]
fn parse_lola_binary_reconstructs_grid() {
// Build a synthetic 2-message, 3×2 grid. Layout matches wgrib2:
// [4 bytes len][6 f32 values][4 bytes len] per message.
let spec = GridSpec {
lon_start: -100.0,
lon_count: 3,
lon_step: 0.5,
lat_start: 30.0,
lat_count: 2,
lat_step: 0.5,
};
let msgs = vec![
Message { var: "TMP".into(), level: "surface".into() },
Message { var: "DPT".into(), level: "surface".into() },
];
let mut buf = Vec::new();
for (msg_idx, _msg) in msgs.iter().enumerate() {
let len: u32 = 3 * 2 * 4;
buf.extend_from_slice(&len.to_le_bytes());
for j in 0..2 {
for i in 0..3 {
let v = (msg_idx as f32) * 100.0 + j as f32 * 10.0 + i as f32;
buf.extend_from_slice(&v.to_le_bytes());
}
}
buf.extend_from_slice(&len.to_le_bytes());
}
let grid = parse_lola_binary(&buf, &msgs, &spec);
assert_eq!(grid.len(), 6);
// Cell (30.0, -100.0) → i=0, j=0, msg0 value = 0.0, msg1 = 100.0
let k = ((30.0 * 1000.0) as i32, (-100.0 * 1000.0) as i32);
let cell = grid.get(&k).unwrap();
assert_eq!(cell["TMP:surface"], 0.0);
assert_eq!(cell["DPT:surface"], 100.0);
// Cell (30.5, -99.0) → i=2, j=1, msg0 = 12.0
let k2 = ((30.5 * 1000.0) as i32, (-99.0 * 1000.0) as i32);
let cell2 = grid.get(&k2).unwrap();
assert_eq!(cell2["TMP:surface"], 12.0);
assert_eq!(cell2["DPT:surface"], 112.0);
}
#[test]
fn parse_lola_skips_undefined_sentinel() {
let spec = GridSpec {
lon_start: -100.0,
lon_count: 2,
lon_step: 1.0,
lat_start: 30.0,
lat_count: 1,
lat_step: 1.0,
};
let msgs = vec![Message { var: "TMP".into(), level: "surface".into() }];
let mut buf = Vec::new();
let len: u32 = 2 * 4;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&UNDEFINED_VALUE.to_le_bytes()); // i=0
buf.extend_from_slice(&42.0f32.to_le_bytes()); // i=1
buf.extend_from_slice(&len.to_le_bytes());
let grid = parse_lola_binary(&buf, &msgs, &spec);
assert_eq!(grid.len(), 1); // only i=1 survived
let k = ((30.0 * 1000.0) as i32, (-99.0 * 1000.0) as i32);
assert_eq!(grid.get(&k).unwrap()["TMP:surface"], 42.0);
}
#[test]
fn key_to_latlon_roundtrip() {
let (lat, lon) = key_to_latlon((32_500, -97_250));
assert_eq!(lat, 32.5);
assert_eq!(lon, -97.25);
}
}