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

105 lines
3.5 KiB
Rust

//! Climatological region lookup. 1:1 port of
//! `lib/microwaveprop/propagation/region.ex`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region {
GulfCoast,
Southeast,
SouthernPlains,
CornBelt,
Northeast,
DesertSouthwest,
PacificNorthwest,
MountainWest,
Other,
}
/// Lat min (inclusive), lat max (exclusive), lon min (inclusive), lon max (exclusive).
struct BBox(Region, f64, f64, f64, f64);
// Ordered to match Elixir's `Enum.find_value` short-circuit.
const REGIONS: &[BBox] = &[
BBox(Region::GulfCoast, 25.0, 32.0, -100.0, -80.0),
BBox(Region::Southeast, 30.0, 37.0, -90.0, -75.0),
BBox(Region::SouthernPlains, 32.0, 38.0, -105.0, -93.0),
BBox(Region::CornBelt, 38.0, 48.0, -100.0, -82.0),
BBox(Region::Northeast, 38.0, 48.0, -82.0, -67.0),
BBox(Region::DesertSouthwest, 30.0, 38.0, -120.0, -105.0),
BBox(Region::PacificNorthwest, 42.0, 50.0, -125.0, -115.0),
BBox(Region::MountainWest, 38.0, 48.0, -115.0, -100.0),
];
pub fn for_point(lat: f64, lon: f64) -> Region {
for bbox in REGIONS {
let BBox(name, lat_min, lat_max, lon_min, lon_max) = *bbox;
if lat >= lat_min && lat < lat_max && lon >= lon_min && lon < lon_max {
return name;
}
}
Region::Other
}
/// Multiplier applied to the band's `seasonal_base` score. Unknown
/// region/month combinations return 1.0. Range [0.7, 1.3].
pub fn seasonal_adjustment(region: Region, month: u8) -> f64 {
match (region, month) {
(Region::GulfCoast, 6) => 0.95,
(Region::GulfCoast, 7) => 0.95,
(Region::GulfCoast, 8) => 1.15,
(Region::GulfCoast, 9) => 1.10,
(Region::CornBelt, 6) => 1.05,
(Region::CornBelt, 7) => 0.85,
(Region::CornBelt, 8) => 0.80,
(Region::CornBelt, 9) => 0.95,
(Region::Southeast, 7) => 0.97,
(Region::Southeast, 8) => 1.08,
(Region::SouthernPlains, 8) => 1.05,
(Region::DesertSouthwest, 7) => 1.10,
(Region::DesertSouthwest, 8) => 1.10,
(Region::DesertSouthwest, 9) => 1.05,
(Region::PacificNorthwest, 6) => 1.15,
(Region::PacificNorthwest, 7) => 1.20,
(Region::PacificNorthwest, 8) => 1.20,
(Region::PacificNorthwest, 9) => 1.10,
_ => 1.0,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gulf_coast_points() {
// Houston ≈ (29.8, -95.4) — Gulf Coast bbox.
assert_eq!(for_point(29.8, -95.4), Region::GulfCoast);
}
#[test]
fn corn_belt_points() {
// Des Moines ≈ (41.6, -93.6) — Corn Belt bbox.
assert_eq!(for_point(41.6, -93.6), Region::CornBelt);
}
#[test]
fn non_conus_returns_other() {
// Alaska (61.2, -149.9) — outside every bbox.
assert_eq!(for_point(61.2, -149.9), Region::Other);
}
#[test]
fn adjustments_match_elixir_table() {
assert_eq!(seasonal_adjustment(Region::GulfCoast, 8), 1.15);
assert_eq!(seasonal_adjustment(Region::CornBelt, 7), 0.85);
assert_eq!(seasonal_adjustment(Region::PacificNorthwest, 7), 1.20);
assert_eq!(seasonal_adjustment(Region::Other, 8), 1.0);
assert_eq!(seasonal_adjustment(Region::CornBelt, 1), 1.0);
}
#[test]
fn precedence_matches_elixir_ordering() {
// (30.5, -90.0) is inside BOTH gulf_coast (25..32, -100..-80) and
// southeast (30..37, -90..-75). Elixir hits gulf_coast first.
assert_eq!(for_point(30.5, -90.0), Region::GulfCoast);
}
}