The HRDPS pipeline as written was unusable in production: each chain
step took ~5 hours (not 30-90s as the dev-bench claim) because every
`wgrib2 -lon` batch redundantly JPEG2000-decodes ~150 records. With 57
batches × 18 forecast hours, a single cycle would take days to drain.
The /weather Canadian view only needs the current hour, so the cheapest
fix is to stop seeding the rest of the chain entirely:
* Rust grid: HRDPS_STEP=0.5 (was 0.125) drops point count 16× to ~3.5k.
Coarser cells than HRRR, but /weather gets visible Canadian coverage
inside one chain step (~20 min) instead of never.
* GridTaskEnqueuer.seed_current_hour/2 inserts exactly one forecast row
whose valid_time is closest to `now`. fh is clamped to 1..18 so the
Rust F00Reserved branch never fires.
* HrdpsGridWorker switches to seed_current_hour and drops the
6-hour-cycle cron in favor of HH:35 hourly fires. Reseeds are
idempotent on the 4-column unique key.
Also fixes a pre-existing credo "nested too deep" in
ScalarFile.read_point by extracting lookup_in_chunk + hit_or_false
helpers — happened to be on the read path that surfaces this work, so
folding it into the same commit.
CI runs cargo clippy with -D warnings. The yesterday's HRDPS commits
introduced two pedantic-but-blocked lints:
- grid.rs:97 in_conus_bbox used `>=`/`<=` chain — clippy's
manual_range_contains says use (LAT_MIN..=LAT_MAX).contains(&lat).
- pipeline.rs:426 backfill_dpt_from_depr iterated `cell.iter()` and
threw the values away — clippy's iter_kv_map says use
cell.keys().filter_map(...). Tightened the inline format strings
to `{depr_key}` instead of the deprecated `{}`-with-arg form to
avoid a follow-up useless_format complaint on the same touch.
cargo clippy --all-targets -- -D warnings passes; cargo test
--release stays at 158 passed.
Stages 4-5 of the HRDPS plan. Lands the Rust forecast-hour pipeline
for source='hrdps' grid_tasks rows.
What ships:
- `hrdps_fetcher.rs` — sibling of fetcher.rs for ECCC's MSC Datamart
shape: date-prefixed URLs, one variable per GRIB2 file, concurrent
fetch + byte-concat into a single multi-record blob. wgrib2 reads
multi-record files natively so the downstream decoder path is
unchanged. Mirrors lib/microwaveprop/weather/hrdps_client.ex.
- `grid::hrdps_only_points()` + `grid::hrdps_grid_spec()` — Canadian
cells (49-60°N, -141 to -52°W) minus the HRRR overlap. Disjoint
from conus_points by construction.
- `db::TaskSource` enum + `source` column on ClaimedTask. claim_next
surfaces source for forecast lane dispatch; claim_next_analysis
filters to source='hrrr' only (HRDPS analysis path is not yet
ported — would need native-duct + NEXRAD + commercial merges that
Rust doesn't have for HRDPS).
- `pipeline::run_chain_step_hrdps` — fetch combined blob, decode with
HRDPS grid spec, drop HRRR-overlap cells via a HashSet of
hrdps_only_points, back-fill DPT from DEPR (HRDPS publishes DEPR
as primary), score every band, write `<band>/<iso>.hrdps.prop`.
Skips per-valid_time profile + scalar artifacts since those would
clobber HRRR's at the same valid_time — surfacing HRDPS on /weather
is a separate stage.
- worker.rs dispatches forecast lane on (kind, source) — Hrrr →
run_chain_step, Hrdps → run_chain_step_hrdps. Analysis stays
HRRR-only. Worker constructs an HrdpsClient alongside HrrrClient.
- `scores_file::write_atomic_hrdps` writes to `.hrdps.prop` with the
Canadian grid spec via the new encode_with_spec. Coexists with the
HRRR `.prop` file for the same (band, valid_time).
163 unit tests pass; backfill_dpt_from_depr handles surface and
pressure-level levels with no double-fill when DPT exists.
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.