# HRDPS Canadian Propagation Grid Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Supersedes:** `docs/plans/2026-04-13-hrdps-canadian-prop-grid.md` — that plan was written before the probe wedge surfaced two architectural realities that change Stage 1's design: ECCC moved the datamart URL structure (the old `/model_hrdps/` root 404s now), and HRDPS publishes one variable per GRIB2 file rather than HRRR's bundled wrfsfcf/wrfprsf format. Major filenames, fetch shape, and idx assumptions are different. **Goal:** Extend the propagation scoring grid into Canada at 0.125° (matching the existing CONUS sample density) sourced from HRDPS, so `/map` shows scores across all of North America. Hourly Canadian scores via the cycle's f00..f18 forecast hours; HRRR wins where it has coverage and HRDPS fills the rest. **Architecture:** Build a parallel NWP ingestion pipeline (`HrdpsClient` → `HrdpsGridWorker` → `hrdps_profiles` table) that mirrors the HRRR shape. The single-variable-per-file difference is hidden inside `HrdpsClient`: it fetches the ~14 needed variable files concurrently, then runs `wgrib2 -merge` to produce a single multi-variable GRIB2 that downstream code can consume identically to a `wrfsfcf`/`wrfprsf` blob. HRDPS runs at 00/06/12/18Z and seeds f00..f18 (hourly) into the score chain, so Canadian scores refresh once per cycle but provide hourly forecast steps until the next cycle lands. Border stitching: a static `Grid.hrdps_only_points/0` mask carves Canada-only cells so HRRR and HRDPS never both write the same `(lat, lon)`. **Tech Stack:** Elixir 1.15, Req, Postgres + Ecto, Oban, wgrib2 (already in image), Rust (for `prop-grid-rs` and `hrrr-point-rs` HRDPS branches) **Prerequisite:** Stage 0 already done — the probe at `lib/mix/tasks/hrdps_probe.ex` retired the URL/projection/decode risks against the live datamart on 2026-04-29. --- ## Status snapshot (2026-04-29) **Shipped — Elixir foundation complete:** - ✅ Stage 0: Probe wedge (`mix hrdps.probe`) - ✅ Stage 1: `HrdpsClient` with URL/variable catalog, concurrent fetch + byte-concat, `fetch_grid/3`, `cycle_available?/1` — `lib/microwaveprop/weather/hrdps_client.ex` - ✅ Stage 2: `hrdps_profiles` partitioned table + `HrdpsProfile` schema - ✅ Stage 3 (Elixir-side): `Grid.hrdps_only_points/0`, `Grid.point_source/1`, `grid_tasks.source` column, `GridTaskEnqueuer` source-aware seeding, `HrdpsGridWorker` seeder - ✅ Stage 8: SRTM scope decision recorded — Option 1 (cap Canadian coverage at 60°N for v1; defer Arctic CDEM to a follow-up plan) **Deferred — needs the Rust counterpart before it can ship:** - ⏸ Stage 4: `prop-grid-rs` Rust HRDPS branch (~4 days). Until this ships, `HrdpsGridWorker` stays out of the cron — its module doc explicitly notes it is dormant scaffolding. - ⏸ Stage 5: `hrrr-point-rs` Rust HRDPS branch (~1.5 days) - ⏸ Stage 6: cycle scheduling cron entry — depends on Stage 4 - ⏸ Stage 7: map overlay bounds widening — depends on real data in `/data/scores` - ⏸ Stage 9: FreshnessMonitor HRDPS staleness tracking — depends on real cycle data flowing **Activation path once Rust ships:** 1. Land the Rust HRDPS branch + redeploy `prop-grid-rs` and `hrrr-point-rs`. 2. Add to `config/runtime.exs` cron: `{"35 5,11,17,23 * * *", Microwaveprop.Workers.HrdpsGridWorker}`. 3. Widen `MapLive.@initial_bounds` to `(23.5, 60.0, -141.0, -52.0)`. 4. Extend `FreshnessMonitor` with `hrdps_last_run_at`. The hardest decisions (cycle cadence, border stitching, variable mapping, projection) are settled and encoded in code. The remaining work is mechanical port-and-wire-up. --- ## Stage 0: Probe wedge (DONE) `mix hrdps.probe` proves the GRIB2 fetch + wgrib2 reprojection works end-to-end against the real ECCC datamart. Output verifies plausible Canadian temperatures at 5 cities. Throwaway code; will be deleted in Stage 10 once `HrdpsClient` covers the same surface area. Findings that drive the rest of the plan: - **URL:** `https://dd.weather.gc.ca/{YYYYMMDD}/WXO-DD/model_hrdps/continental/2.5km/{HH}/{FFF}/{YYYYMMDD}T{HH}Z_MSC_HRDPS_{VAR}_{LEVEL}_RLatLon0.0225_PT{FFF}H.grib2` - **Variable naming:** MSC's `{VAR}_{LEVEL_TYPE}-{LEVEL_VALUE}` (e.g. `TMP_AGL-2m`, `TMP_ISBL_0850`) — different from NCEP's HRRR short-name format - **One variable per file** (~3.5 MB each); no idx file / no byte-range partials - **wgrib2 reprojects rotated lat/lon natively** via `-lon LON LAT` — no manual rotation math - **Publish latency:** ~3-4h after each cycle (verified by cycle availability at probe time) --- ## Stage 1: `HrdpsClient` — per-variable fetch + wgrib2 -merge **Decision recorded:** Fetch each variable's GRIB2 file concurrently, then run `wgrib2 -merge` to combine into a single multi-variable file before downstream decode. Keeps the existing `hrrr_profiles`-shaped derive pipeline intact downstream of the client. The disk-staging cost (~14 files × 3.5 MB into a tmp dir per cycle/forecast hour) is small compared to the fetch time. **Files:** - Create: `lib/microwaveprop/weather/hrdps_client.ex` - Create: `test/microwaveprop/weather/hrdps_client_test.exs` - Create: `test/support/fixtures/hrdps_sample_*.grib2` (small fixtures captured from a real cycle) ### Task 1.1: URL builder (TDD) Write a failing test for `HrdpsClient.grib_url(:tmp_2m, run_time, forecast_hour)` returning the date-prefixed datamart URL with the MSC filename format. Verify against a known-good URL from the probe (`20260429T12Z_MSC_HRDPS_TMP_AGL-2m_RLatLon0.0225_PT000H.grib2`). Implement; commit. ### Task 1.2: Variable catalog Define a `@variables` map mapping internal atoms (`:tmp_2m`, `:dpt_2m`, `:pres_sfc`, `:wind_u_10m`, `:wind_v_10m`, `:apcp_1h`, `:tcdc`, `:pwat`, `:tmp_850mb`, `:tmp_700mb`, `:tmp_500mb`, `:dpt_850mb`, `:dpt_700mb`, `:hgt_850mb`) to MSC variable + level slugs (`{VAR_AGL-2m}`, `{VAR_ISBL_0850}`, etc.). This is the variable-mapping table the old plan called out as a Stage-1 risk; build it explicitly. TDD: a `variable_slug/1` test for each atom. Commit. ### Task 1.3: Concurrent fetch + tmp staging (TDD) Write a failing test for `fetch_cycle_files(run_time, forecast_hour, opts)` that returns `{:ok, [paths]}` of the per-variable GRIB2 files staged in a tmp dir. Use `Task.async_stream` with `max_concurrency: 4` (datamart isn't aggressively rate-limited but we don't need >4 in flight). Mock with `Req.Test.stub` per project conventions. Critical: every `{:exit, _}` and `{:error, _}` clause from the async_stream must `Logger.error` with variable name + URL — per CLAUDE.md "always log anything that would normally be swallowed" and the path-calculator outage memory. A silently-dropped fetch in production is a Canadian dead-zone we won't notice for hours. Implement; commit. ### Task 1.4: wgrib2 -merge into multi-variable file (TDD) Write a failing test for `merge_to_multivar(paths, output_path)`. Implement via `System.cmd("wgrib2", ["-merge", path1, path2, ..., "-grib", output_path])`. Verify the merged output's `wgrib2 file -V` lists all expected variables. Commit. ### Task 1.5: `fetch_grid/3` — public API matching `HrrrClient.fetch_grid/3` Same signature as HRRR so Stage 3's worker can call either polymorphically: ```elixir @spec fetch_grid([{float(), float()}], DateTime.t(), keyword()) :: {:ok, %{{float(), float()} => map()}} | {:error, term()} def fetch_grid(points, run_time, opts \\ []) ``` Internally: `fetch_cycle_files` → `merge_to_multivar` → reuse `Microwaveprop.Weather.GribDecoder` (the existing HRRR decoder, projection-agnostic since wgrib2 handles rotation). TDD; commit. ### Task 1.6: Projection sanity check (TDD) Pick CYYR (Goose Bay, 53.32°N, -60.42°W) and a recent cycle for which we have a UWYO Canadian sounding (those exist via `CanadianSoundingFetchWorker`). Test that the HRDPS surface temp at CYYR is within ±3°C of the radiosonde surface temp for the same `valid_time`. If they don't match, the projection is wrong and `wgrib2 -lon` is being called incorrectly. This is the most important test in the stage — burn whatever time is needed to make it pass. A 10 km projection offset is invisible on the map but destroys terrain analysis at Stage 5+. Commit. --- ## Stage 2: `hrdps_profiles` schema **Files:** - Create: `priv/repo/migrations/{timestamp}_create_hrdps_profiles.exs` - Create: `lib/microwaveprop/weather/hrdps_profile.ex` - Create: `test/microwaveprop/weather/hrdps_profile_test.exs` ### Task 2.1: Migration mirrors `hrrr_profiles` Same columns, same indexes, daily partitioning on `valid_time`. Separate table (not a unified `nwp_profiles`) so HRDPS can be dropped/reprocessed without disturbing the 4500+ HRRR profiles already in prod. `@primary_key {:id, :binary_id, autogenerate: true}` per project convention. Run migration in dev; verify table + indexes; commit. ### Task 2.2: `HrdpsProfile` schema + changeset (TDD) Identical to `HrrrProfile`. Same derived fields (`surface_refractivity`, `min_refractivity_gradient`, `ducting_detected`, `duct_characteristics`, `pwat_mm`, `hpbl_m`). Commit. ### Task 2.3: Partition pruning cron Add an `Oban.Plugins.Cron` entry mirroring whatever HRRR uses (or add one if HRRR doesn't have one yet — the prod DB will fill up either way). Commit. --- ## Stage 3: `HrdpsGridWorker` — score Canadian grid per forecast hour **Files:** - Create: `lib/microwaveprop/workers/hrdps_grid_worker.ex` - Create: `test/microwaveprop/workers/hrdps_grid_worker_test.exs` - Modify: `lib/microwaveprop/propagation/grid.ex` — add `hrdps_only_points/0` ### Task 3.1: Define the Canadian-only grid (TDD) `hrdps_only_points/0` returns the subset of a 0.125°-spaced grid that is INSIDE the Canadian land mask AND OUTSIDE the existing CONUS HRRR footprint. Implement as a precomputed list (compile-time `@external_resource`) — the cells don't change. Approximate bounds: 42°N to 60°N (north-of-60°N deferred to Stage 8), -141°W to -52°W. Filter to land using a coarse polygon — Natural Earth's `ne_50m_admin_0_countries` clipped to Canada is fine; even a rectangular bounding-box pass-through is acceptable for a first pass since over-water cells just get scored and ignored. The mask must NEVER overlap HRRR cells. Test: `MapSet.disjoint?(MapSet.new(Grid.conus_points()), MapSet.new(Grid.hrdps_only_points()))`. Commit. ### Task 3.2: Worker `perform/1` (TDD) Mirror `PropagationGridWorker.process_forecast_hour/4`. Args: `{run_time, forecast_hour}`. Calls `HrdpsClient.fetch_grid(hrdps_only_points(), run_time, opts)`, derives profiles via the existing `Microwaveprop.Weather.SoundingParams`-style derivation, persists to `hrdps_profiles`, then scores via `Microwaveprop.Propagation.Scorer.score/3`. `start_async`/`Task.async_stream` failure paths must `Logger.error` with point + reason (per CLAUDE.md and the swallowed-async-errors memory). Commit. ### Task 3.3: Score upserts into existing `propagation_scores` Single shared scores table — HRDPS rows look identical to HRRR rows, distinguished only by `(lat, lon)` falling in the HRDPS-only mask. Use the same `on_conflict: {:replace_all_except, [...]}` upsert pattern. Confirm via SQL: a sample run produces no `(lat, lon, valid_time, band_mhz)` collisions with HRRR rows. Commit. ### Task 3.4: Score-file `.prop` writer The `.prop` file at `/data/scores/...` is what `MicrowavepropWeb.ScoresController.cells/2` reads (and what `ScoreCache` mirrors in ETS). Either: (a) extend the `.prop` writer to handle the larger union grid (CONUS + Canada), or (b) emit a separate `_canada.prop` file that the controller stitches at read time. Decision: **(a)** — single file, simpler downstream. The Rust scorer (Stage 4) writes the same file; both must agree on the cell layout. Update `Microwaveprop.Propagation.ScoresFile` to support a wider grid extent. The cell-pack format already handles arbitrary lat/lon arrays per the `PSCR` magic spec. TDD: write a fixture `.prop` file at the expanded extent, read it back, verify all cells round-trip. Commit. --- ## Stage 4: `prop-grid-rs` Rust worker — HRDPS branch The Rust worker at `rust/prop_grid_rs/` claims tasks from `grid_tasks` via `FOR UPDATE SKIP LOCKED` and writes `.prop` files to `/data/scores`. It needs an HRDPS branch since it owns the hot path for f01..f18. **Files:** - Modify: `rust/prop_grid_rs/src/sources/` (likely add `hrdps.rs` next to `hrrr.rs`) - Modify: `rust/prop_grid_rs/src/main.rs` — task source dispatch - Migration: `priv/repo/migrations/{timestamp}_add_source_to_grid_tasks.exs` ### Task 4.1: Add `source` column to `grid_tasks` `source TEXT NOT NULL DEFAULT 'hrrr'`. Backfill is a no-op since existing rows are all HRRR. Index on `(source, run_time)` for the worker's claim query. Commit. ### Task 4.2: Rust HRDPS fetcher Mirror the existing `hrrr.rs` shape. Differences: - URL builder using the date-prefixed pattern + per-variable filenames - Concurrent fetch of ~14 variable files (use `tokio::join_all` with a semaphore at 4) - Shell out to `wgrib2 -merge` (same binary the Elixir client uses; no Rust GRIB2 reimplementation) - Reuse the existing point-extraction code that wraps `wgrib2 -lon` Keep the Rust HRDPS code as close to the Rust HRRR code as possible — copy-then-modify is fine; resist refactoring for shared abstraction until both branches are working. Commit. ### Task 4.3: Task dispatch on `source` column Worker's main loop branches on `task.source`: `"hrrr"` → existing path, `"hrdps"` → new path. The task seeder (`Microwaveprop.Workers.GridTaskEnqueuer`) populates `source` based on which worker enqueued the task. Commit. ### Task 4.4: Build + push image Existing CI pipeline (`.forgejo/workflows/build-grid-rs.yaml`) produces the `prop-grid-rs:main-*` image consumed by both `prop-grid-rs` and `hrrr-point-rs` deployments. New code rolls out automatically once merged. Verify in Argo / pod logs. --- ## Stage 5: `hrrr-point-rs` — HRDPS branch for per-QSO Canadian QSOs **Files:** - Modify: `rust/prop_grid_rs/src/bin/hrrr_point_worker.rs` - Migration: `priv/repo/migrations/{timestamp}_add_source_to_hrrr_fetch_tasks.exs` - Modify: `lib/microwaveprop/workers/hrrr_fetch_enqueue_worker.ex` (or wherever per-QSO fetches are enqueued) ### Task 5.1: Routing decision When a QSO's lat/lon falls outside the HRRR CONUS footprint, enqueue an HRDPS-source task instead of an HRRR task. `Grid.point_source({lat, lon})` returns `:hrrr | :hrdps | :neither` (e.g. for a southern-hemisphere QSO). For `:neither`, fall back to the existing IEMRE/RAOB enrichment path. TDD: cells at (45.0, -90.0) → `:hrrr`, (53.0, -113.0) → `:hrdps`, (-30.0, 150.0) → `:neither`. Commit. ### Task 5.2: `hrrr_fetch_tasks.source` column + Rust branch Same shape as Stage 4.1/4.3: add `source` column, branch the Rust worker, reuse Stage 1's URL/variable patterns ported to Rust. Commit. ### Task 5.3: Per-QSO upserts into `hrdps_profiles` QSO enrichment status column: add `hrdps_status` to `contacts` (similar to existing `hrrr_status`). LiveView/admin UI reads either, depending on which model covered the QSO. Commit. --- ## Stage 6: Cycle scheduling + border stitching **Files:** - Modify: `config/runtime.exs` — Oban cron + queue - Modify: `lib/microwaveprop/workers/grid_task_enqueuer.ex` (or equivalent) ### Task 6.1: Add `hrdps` queue Production runtime config adds `hrdps: 1` (single concurrent fetch — wgrib2 working set during merge can hit ~500 MB and we don't want it competing with `propagation`). ### Task 6.2: Cycle cron at HH:35 of cycle hours ```elixir {"35 5,11,17,23 * * *", Microwaveprop.Workers.HrdpsCycleSeedWorker} ``` Why these times: HRDPS runs at 00/06/12/18Z and publishes ~3-4h later, so 5h after cycle (at 05:35Z, 11:35Z, 17:35Z, 23:35Z) the f00 file is reliably available. Offset by 30 minutes from HRRR's `*/15` schedule to avoid wgrib2 contention. `HrdpsCycleSeedWorker` enqueues f00..f18 grid tasks with `source: "hrdps"`. The existing chain worker (Rust + Elixir) drains them. ### Task 6.3: Border stitching rule Already enforced at Stage 3.1: HRDPS-only mask cells are disjoint from HRRR's CONUS points. No runtime conflict. The seam at 49°N is implicit: HRRR cells exist down to ~25°N, HRDPS cells exist 49°N+. The boundary is a one-cell band of HRRR coverage abutting HRDPS — no overlap, no double-write. Document the rule in `Grid.point_source/1`'s `@moduledoc` so future readers understand why the masks are exclusive. Commit. --- ## Stage 7: Map overlay extension **Files:** - Modify: `lib/microwaveprop_web/live/map_live.ex` — `@initial_bounds` - Modify: `assets/js/propagation_map_hook.ts` — heatmap rendering bounds ### Task 7.1: Widen `@initial_bounds` CONUS-only `(23.5, 49.5, -124.5, -66.9)` → `(23.5, 60.0, -141.0, -52.0)` (excluding Arctic until Stage 8 decides). Default zoom adjusted so all of North America is visible. ### Task 7.2: Smoke test (manual + LiveView test) `mix phx.server`, open `/map`, confirm colored cells render north of 49°N after `HrdpsCycleSeedWorker` fires. The cell-pack endpoint at `/scores/cells` already serves whatever lat/lon points exist in the `.prop` file — no controller changes needed since Stage 3.4 expanded the file. LiveView test: assert that an `/scores/cells?band=10000&south=50&north=55&west=-115&east=-105` request returns >0 cells after HRDPS data exists in fixtures. ### Task 7.3: ScoreCache chunk layout still correct The ScoreCache layout (just shipped in commit 6a90d153 follow-up) uses 5°×5° chunks. Extending the grid into Canada adds ~30 new chunks but the same lookup logic applies. No code change; one regression test that fetches a Canadian-bounds viewport and asserts hit/miss telemetry works correctly. Commit. --- ## Stage 8: SRTM gap above 60°N — scope decision **Decision gate.** SRTM1 stops at 60°N. Yellowknife (62.45°N), Whitehorse (60.72°N), Inuvik (68.36°N), and Resolute (74.70°N) have NO SRTM coverage — terrain diffraction analysis would silently return wrong losses for paths involving those stations. Three options: 1. **Cap Canadian coverage at 60°N for v1.** Document explicitly in `algo.md`. Most VE QSOs are below 60°N anyway. Defer Arctic to a follow-up plan. 2. **Use CDEM (Canadian Digital Elevation Model) above 60°N.** ~1 week of additional work — different format (TIFF tiles), different mounting strategy on the production NFS share. Touches `terrain_analysis.ex` and `elevation_client.ex`. 3. **Use ASTER GDEM v3.** Global, free, 30 m resolution, but registration-walled. Not great. **Recommendation: Option 1 for v1.** Ship the 42-60°N coverage. If users report Arctic gaps, do CDEM as a separate plan. Decision recorded in this stage; file a follow-up issue if Option 2 is pursued. --- ## Stage 9: Freshness monitoring + observability **Files:** - Modify: `lib/microwaveprop/propagation/freshness_monitor.ex` - Modify: wherever `/status` exposes data freshness (LiveView or controller) ### Task 9.1: Track HRDPS run-time staleness Add `hrdps_last_run_at` to the monitor state. Surface on the status page next to HRRR's freshness. ### Task 9.2: Alert at >5h stale HRDPS publishes ~4h after each cycle, so a healthy pipeline never exceeds ~5h between runs. >5h = something broken (datamart outage, our worker failed silently, k8s scheduling glitch). `Logger.warning` (not `error` — datamart outages are common and recoverable). Telemetry event for the existing dashboard scrape. Commit. --- ## Stage 10: Precommit, docs, ship ### Task 10.1: `mix precommit` Must show 0 failures, 0 credo issues, all dialyzer warnings resolved. Full project gate. ### Task 10.2: Update `CLAUDE.md` - Add HRDPS to **Key Data Sources** - Add `HrdpsCycleSeedWorker` + `HrdpsGridWorker` + `HrdpsFetchWorker` to the **Background Job Queues** table - Note the 60°N coverage cap and the cycle cadence (4×/day vs HRRR's hourly) ### Task 10.3: Update `memory/reference_hrdps_canada.md` Mark plan as executed. Capture any projection gotchas, the final cycle latency observed in prod, and the actual border-stitching seam location. ### Task 10.4: Delete the probe wedge `lib/mix/tasks/hrdps_probe.ex` is throwaway scaffolding — its risks were retired in Stage 0; the production code in `HrdpsClient` covers the same surface. Delete in this stage's commit, not earlier (keeping it around during stages 1-3 is useful for cross-checking the real client's output). ### Task 10.5: Push to `github` AND `origin` Per the push-both-remotes feedback memory. Do NOT push to `dokku`. Argo/Flux handles the rollout from the kustomize manifest pulled from `git.mcintire.me`. --- ## Known risks + gotchas - **Projection** — HRDPS rotated lat/lon is the single biggest bug source. Stage 1.6's CYYR validation against UWYO is the safety net; do not skip. - **Per-variable file fan-out** — ~14 fetches per cycle/forecast hour × 4 cycles × 19 forecast hours = 1064 files/day. Each is small (~3.5 MB) but datamart's `200 OK` latency is variable. Concurrency-cap at 4 to avoid hammering ECCC; build retry-with-backoff into `HrdpsClient`. - **Memory pressure** — wgrib2 `-merge` working set during the multi-variable combine peaks at ~500 MB per cycle. The HRRR worker already runs at `:system_memory_high_watermark` headroom; running HRDPS on the same pod risks OOM. Either pin HRDPS to a dedicated `prop-backfill`-style pod (probably overkill) OR keep it on the hot pods but cap `hrdps: 1` so it never overlaps with `propagation: 1`. - **Cycle publish latency** — datamart can lag 90 min on good days, 4+h under load. The `HrdpsCycleSeedWorker` cron at HH:35 has a 1.5h margin which is comfortable but not infinite. If a cycle hasn't published by HH:35, the worker should fall back to seeding the previous cycle (still gives current+forecast scores, just one cycle stale). - **Border seam visibility** — at the 49°N seam, HRRR's 0.125° cells abut HRDPS's 0.125° cells with no overlap. If the two models disagree by 5-10% on a given factor at the seam, users will see a faint horizontal stripe. Probably acceptable; document in `algo.md`. - **Scope creep** — keep RDPS pressure-level coverage out of this plan even though they're related (RDPS plan is `2026-04-13-rdps-vertical-profiles.md`). Ship CONUS+Canada-via-HRDPS first; revisit RDPS for wider/coarser coverage afterward. ## Rough effort estimate | Stage | Estimate | |---|---| | 0: Probe wedge | DONE (~3h, 2026-04-29) | | 1: HrdpsClient | 4 days (Task 1.6 projection debug dominates) | | 2: hrdps_profiles schema | 0.5 day | | 3: HrdpsGridWorker + .prop writer | 3 days | | 4: Rust prop-grid-rs HRDPS branch | 4 days | | 5: Rust hrrr-point-rs HRDPS branch | 1.5 days | | 6: Cycle scheduling + border stitching | 1 day | | 7: Map overlay extension | 0.5 day | | 8: SRTM scope decision | 0.5 day (decision + documentation, no code if Option 1) | | 9: Freshness monitoring | 0.5 day | | 10: Precommit + docs + ship | 0.5 day | | **Total** | **~16 days (3 weeks calendar)** | The Rust worker stages (4 + 5) are the majority of the schedule risk. If schedule is tight, an alternative is to ship Elixir-only first (Stages 1-3 + 6-10) and follow up with the Rust port — at the cost of slower Canadian forecast-chain processing during the gap. --- ## Open questions for confirmation before Stage 1 1. **Variable set:** Does the catalog at Task 1.2 cover everything the scorer needs for Canadian cells? Worth a 30-min look at `Microwaveprop.Propagation.Scorer.factors/2` to confirm the surface + pressure-level set is complete (especially native-level duct analysis — HRDPS's hybrid sigma-pressure levels may be available under a `*_HY_*` or `native/` directory that the probe didn't explore). 2. **60°N cap:** Confirm Option 1 from Stage 8 (defer Arctic CDEM) before kickoff so we don't end up with the Stage 1.1 grid mask covering territory we won't actually serve. 3. **Forecast horizon:** Default plan uses HRDPS f00..f18 to match HRRR's window. HRDPS goes out to f48 — would f00..f24 or even f00..f48 (full Day-2 forecast, only on the Canadian side) be useful? Storage/compute roughly doubles per extra 12h of horizon.