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.
105 lines
3.5 KiB
Rust
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);
|
|
}
|
|
}
|