Adds mix hrdps.probe — throwaway wedge that retires URL/projection/decode
risks against MSC Datamart. Verified against five Canadian cities with
plausible 2m temperatures via wgrib2's native rotated-lat/lon support.
Findings update what we knew:
- ECCC reorganized to date-prefixed paths; the old /model_hrdps/ root
404s now. Real URL is dd.weather.gc.ca/{YYYYMMDD}/WXO-DD/model_hrdps/...
- HRDPS publishes one variable per GRIB2 file (~3.5 MB each), not
HRRR's bundled wrfsfcf format. ~14 files per cycle/forecast hour.
- wgrib2 -lon LON LAT handles the rotated grid natively — no manual
reprojection math needed.
New plan at 2026-04-29-hrdps-canadian-prop-grid.md supersedes the
2026-04-13 one (which assumed HRRR-style idx + byte-range fetches that
don't apply). Probe deletion is a Stage 10 step in the new plan.
14 KiB
HRDPS Canadian Propagation Grid Implementation Plan (SUPERSEDED)
SUPERSEDED 2026-04-29 by
docs/plans/2026-04-29-hrdps-canadian-prop-grid.md. A live probe of MSC Datamart on 2026-04-29 found that ECCC reorganized the URL structure (the/model_hrdps/flat root in the pre-flight steps below 404s now) and that HRDPS publishes one variable per GRIB2 file rather than HRRR's bundled wrfsfcf/wrfprsf format — so the Stage 1 idx + byte-range fetch shape this plan inherits from HrrrClient does not apply. Use the 2026-04-29 plan instead.
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Extend the propagation scoring grid to cover all of Canada at HRDPS's 2.5 km resolution — the Canadian analog to HRRR. This lights up the /map for VE stations, gives terrain-diffraction analysis for VE QSOs, and unlocks cross-border path analysis for KP/W7-to-VE7 tropo paths.
Architecture: Build a parallel NWP ingestion pipeline (HrdpsClient + HrdpsGridWorker + hrdps_profiles table) that matches the HRRR structure one-for-one, with three major differences: rotated lat/lon projection, CMC file naming (CMC_hrdps_continental_*_ps2.5km_*_{FFF}.grib2), and a different native-level grid spec for duct analysis. A new propagation_scores region tag distinguishes HRRR-sourced vs HRDPS-sourced cells so the front-end can render them uniformly.
Tech Stack: Elixir 1.15, Req, Nx (optional for duct analysis), Postgres, Oban, wgrib2, proj (for projection conversions if needed)
Prerequisite: Task 6 of the RDPS plan (2026-04-13-rdps-vertical-profiles.md) — or at minimum a working pattern for "score grid_data from a non-HRRR source". If you're starting HRDPS fresh, Task 6 below stands alone but will duplicate some work from the RDPS plan.
Pre-flight research (1–2 hours)
Step R.1: Confirm HRDPS datamart URL pattern
curl -s https://dd.weather.gc.ca/model_hrdps/continental/2.5km/ 2>&1 | head -30
curl -s https://dd.weather.gc.ca/model_hrdps/continental/2.5km/00/000/ 2>&1 | head -30
Expected: file list with entries like CMC_hrdps_continental_TMP_TGL_2_ps2.5km_{YYYYMMDDHH}_P{FFF}-00.grib2.
Step R.2: Download one sample + inspect with wgrib2
wgrib2 CMC_hrdps_continental_TMP_TGL_2_ps2.5km_2026041300_P000-00.grib2 -grid -v
Expected: confirms grid is rotated lat/lon, gives dimensions (typically ~2500×1800), and lists all variables.
Step R.3: Confirm pressure-level files exist
HRDPS publishes surface + pressure-level files separately (like HRRR wrfsfc vs wrfprs). Find the pressure-level variant:
curl -s https://dd.weather.gc.ca/model_hrdps/continental/2.5km/00/000/ | grep -i 'ISBL'
If separate files per pressure level, the ingestion must fan out like HrrrClient.pressure_messages/0. If a single combined file, one download gets everything.
Step R.4: Decide projection strategy
wgrib2 can extract points from rotated lat/lon directly with -lon LON LAT. For extracting the full grid as a flat array, you need proj or a wgrib2 -new_grid reprojection. Recommended: never reproject — always extract point-by-point for the grid points we care about. Slower than bulk extraction but avoids projection bugs.
Step R.5: Confirm run frequency and forecast range
HRDPS: 4× daily (00/06/12/18Z), forecasts to 48h. Verify via datamart. This is half HRRR's run frequency (hourly) and 2.7× longer (48h vs 18h).
Decision gate: After R.5, confirm with your human partner which forecast range to ingest. Recommendation: start with f00–f24 to match the "current + 1 day" use case. Extending to f48 doubles storage and compute and probably isn't worth it for amateur radio planning.
Task 1: HrdpsClient — surface + pressure fetch
Files:
- Create:
lib/microwaveprop/weather/hrdps_client.ex - Test:
test/microwaveprop/weather/hrdps_client_test.exs - Fixture:
test/support/fixtures/hrdps_sample_sfc.grib2+test/support/fixtures/hrdps_sample_prs.grib2
Step 1.1: Write failing URL builder test
test "builds correct CMC datamart URL for surface temp" do
url = HrdpsClient.grib_url(~D[2026-04-13], 12, 6, :surface, :tmp)
assert url =~ "/model_hrdps/continental/2.5km/12/006/CMC_hrdps_continental_TMP_TGL_2_ps2.5km_2026041312_P006-00.grib2"
end
Adjust the format string after pre-flight research confirms the exact naming.
Step 1.2–1.4: Write → fail → implement → pass
Reuse HrrrClient.parse_idx/1, HrrrClient.byte_ranges_for_messages/2, and HrrrClient.download_grib_ranges/2 — they're format-agnostic. Only the URL pattern and the message set differ.
Step 1.5: Implement fetch_grid/3
@spec fetch_grid([{float(), float()}], DateTime.t(), keyword()) ::
{:ok, %{{float(), float()} => map()}} | {:error, term()}
def fetch_grid(points, run_time, opts \\ [])
Same signature as HrrrClient.fetch_grid/3 so PropagationGridWorker.process_forecast_hour/4 can call either one polymorphically in Task 6.
Step 1.6: Nail the projection
Write a focused test: pick a known Canadian airport (CYYR = Goose Bay, 53.32°N, 60.42°W) and verify the HRDPS profile at that point matches the UWYO radiosonde for the same valid_time within a reasonable tolerance (surface temp ±3°C, dewpoint ±4°C). If they don't match, the projection is wrong and you need to debug wgrib2's -lon extraction vs the CMC rotated grid.
Step 1.7: Commit
Task 2: Native-level duct metrics via HrdpsNativeClient
Files:
- Create:
lib/microwaveprop/weather/hrdps_native_client.ex - Test:
test/microwaveprop/weather/hrdps_native_client_test.exs
Step 2.1: Confirm HRDPS publishes native hybrid levels
HRDPS uses CMC's GEM dynamical core with hybrid sigma-pressure levels (62 of them). The datamart file naming for native levels is *_HY_* or within model_hrdps/continental/2.5km/native/ — confirm in pre-flight.
If native levels are NOT published: this task is impossible; skip it and fall back to pressure-level duct analysis (coarser — 31 levels vs 62 — but still works). The map's ducting detection quality will be slightly worse over VE airspace than over CONUS, which is acceptable for a first pass.
If native levels ARE published: mirror HrrrNativeClient.fetch_native_duct_grid/4. Use Microwaveprop.Propagation.Duct.analyze/1 which is projection-agnostic — it just needs a list of levels with temperature, pressure, and specific humidity.
Step 2.2–2.5: TDD cycle as above
Step 2.6: Commit
Task 3: hrdps_profiles table + schema
Files:
- Create:
priv/repo/migrations/{timestamp}_create_hrdps_profiles.exs - Create:
lib/microwaveprop/weather/hrdps_profile.ex - Test:
test/microwaveprop/weather/hrdps_profile_test.exs
Step 3.1–3.4: Mirror hrrr_profiles exactly. Same columns, same indexes. The only reason to have a separate table is operational isolation — don't contaminate the 10M-row HRRR partition with 8M-row HRDPS rows, and let you drop/reprocess HRDPS without touching HRRR.
Use daily partitioning on valid_time just like hrrr_profiles.
Step 3.5: Partition-pruning migration
Add an Oban.Plugins.Cron entry for HrdpsPartitionCleanup similar to HrrrPartitionCleanup if one exists.
Step 3.6: Commit
Task 4: HrdpsGridWorker — hourly compute over Canadian grid
Files:
- Create:
lib/microwaveprop/workers/hrdps_grid_worker.ex - Test:
test/microwaveprop/workers/hrdps_grid_worker_test.exs - Modify:
lib/microwaveprop/propagation/grid.ex(addcanadian_points/0)
Step 4.1: Define the Canadian grid
At HRDPS 2.5 km resolution, Canada (42N-83N, -141 to -52W) has ~1.5M cells. Too many to score every hour. Instead:
- Sample at 0.125° (14 km) to match HRRR's output grid → ~100K points
- For the subset of points that are INSIDE HRRR's CONUS footprint, prefer HRRR; OUTSIDE, use HRDPS
- Store the "outside HRRR" mask as
Grid.hrdps_only_points/0
Step 4.2: Worker perform/1
Mirror PropagationGridWorker.process_forecast_hour/4 but with HrdpsClient.fetch_grid/3 and HrdpsNativeClient.fetch_native_duct_grid/4.
Step 4.3: Scoring
Call Propagation.score_grid_point/4 — it's already projection-agnostic. It takes a profile map and valid_time and produces per-band scores. No changes needed to the scorer.
Step 4.4: Upsert into propagation_scores
grid_scores is already indexed by (lat, lon, valid_time, band_mhz). HRDPS and HRRR both write into the same table — the unique key prevents collisions because HRDPS cells have different (lat, lon) than HRRR cells at the shared 0.125° resolution IF you don't re-score HRRR's cells from HRDPS. Use the hrdps_only_points/0 mask to guarantee no overlap.
Step 4.5: TDD cycle, commit
Task 5: Oban queue + cron
Files:
- Modify:
config/config.exs,config/runtime.exs
Step 5.1: Add hrdps queue
queues: [
# ... existing
hrdps: 1 # HRDPS fetch is sequential like HRRR (memory pressure)
]
Step 5.2: Add cron
# HRDPS runs 00/06/12/18Z. Aligned with RDPS cadence.
{"35 */6 * * *", Microwaveprop.Workers.HrdpsGridWorker}
Note: 35 past the hour, not 5, because PropagationGridWorker (HRRR) fires at 5 past. Stagger by 30 minutes so they don't compete for the DB and the wgrib2 subprocess slot.
Step 5.3: mix compile sanity-check
Step 5.4: Commit
Task 6: Map overlay — extend prop map to cover Canada
Files:
- Modify:
lib/microwaveprop_web/live/map_live.ex—@initial_bounds, timeline - Modify:
assets/js/propagation_map_hook.ts— heatmap rendering bounds - Modify:
lib/microwaveprop/propagation/grid.ex— exportnorth_america_points/0that returns HRRR ∪ HRDPS
Step 6.1: Write failing LiveView test
test "/map renders scores for a Canadian grid cell (53N, -113W)" do
{:ok, view, html} = live(conn, ~p"/map")
# after PropagationGridWorker (HRRR) and HrdpsGridWorker (HRDPS) have both written scores
assert html =~ "53.0" # or whatever assertion proves the HRDPS cell made it to the client
end
Step 6.2: Widen @initial_bounds in map_live.ex
From the CONUS-only (23.5, 49.5, -124.5, -66.9) to (23.5, 83.0, -141.0, -52.0). Zoom out default to show all of North America.
Step 6.3: Verify front-end renders Canadian points
mix phx.server
# Open http://localhost:4000/map
# Confirm colored cells north of 49N after the first HRDPS run completes
Step 6.4: Commit
Task 7: Terrain + QSO path analysis north of the border
Files:
- Modify:
lib/microwaveprop/terrain/terrain_analysis.ex(if needed) - Modify:
lib/microwaveprop/workers/terrain_profile_worker.ex - Possibly: mount SRTM data covering Canada onto the production pods
Step 7.1: Check SRTM coverage
SRTM1 covers 60°S–60°N. Canadian radiosondes and stations above 60°N (Yellowknife, Whitehorse, Inuvik, Resolute) have NO SRTM coverage. The terrain analysis pipeline needs a fallback — likely CDEM (Canadian Digital Elevation Model) or ASTER GDEM.
Decision gate with your human partner: Does the feature need to work above 60°N? If yes, this is a multi-day sub-project (Task 7 becomes a whole new plan). If no — scope to 42–60°N and explicitly document that Inuvik paths won't get terrain analysis.
Step 7.2 onward: Out of scope for this plan. Write a follow-up plan for Arctic terrain if your human partner wants coverage.
Step 7.3: Commit the scope decision to docs/plans/ or a memory file
Task 8: Freshness monitoring + observability
Files:
- Modify:
lib/microwaveprop/propagation/freshness_monitor.ex - Modify:
lib/microwaveprop_web/live/status_live.ex
Step 8.1: Extend FreshnessMonitor to track HRDPS run time
Add a hrdps_last_run_at field to the monitor state, surface it on /status.
Step 8.2: Alert when HRDPS run_time is > 4 hours stale
HRDPS runs every 6 hours, publishes ~90 min after, so a healthy pipeline never exceeds ~2.5 hours stale. 4 hours = something broken.
Step 8.3: Commit
Task 9: Precommit, docs, PR
Step 9.1: mix precommit
Must show 0 failures.
Step 9.2: Update CLAUDE.md
- Add HRDPS to
### Key Data Sources - Add
HrdpsGridWorker+HrdpsFetchWorkerto the background queue table - Add a note on the CONUS → North America scope change
Step 9.3: Update memory/reference_hrdps_canada.md
Mark the plan as executed — include the final ingestion rate, queue config, and any projection gotchas that surfaced.
Step 9.4: Commit, push to github and origin, let Flux deploy
Do NOT push to dokku.
Known risks + gotchas
- Projection: HRDPS rotated lat/lon is the #1 source of bugs. Burn a day in Task 1.6 validating a dozen points against UWYO or ASOS surface obs before moving on. A 10 km projection offset is invisible on the propagation map but destroys terrain-path analysis.
- Memory pressure: The existing HRRR worker hits
:system_memory_high_watermarkalarms at ~4 GB. HRDPS grid is 3× larger in cell count. Plan for 8 GB per pod for the prop namespace, or offload HRDPS fetch to a dedicated node via Kubernetes node affinity. - Publish time: CMC's publish delay can be erratic — 90 min on good days, 3+ hours under load. Build retry-with-backoff into
HrdpsGridWorker.perform/1so the cron doesn't fire, find no data, and dead-letter immediately. - Arctic terrain: SRTM stops at 60°N. Accept or fix — don't silently serve wrong diffraction losses to stations in Yellowknife.
- HRRR/HRDPS overlap: The two models disagree on the US/Canadian border. Pick a rule (prefer HRRR south of 49°N, prefer HRDPS north) and encode it in
Grid.point_source/2. - Scope creep: This plan is already ~5 days of work. Resist adding "while we're at it, let's also fix the native-level ducting for rotated grids" side-quests. Ship the minimum viable Canadian prop grid first.
Rough effort estimate
| Task | Estimate |
|---|---|
| Pre-flight research | 2 hours |
| Task 1: HrdpsClient | 1 day (projection debug dominates) |
| Task 2: HrdpsNativeClient | 0.5 day (or skipped if no native) |
| Task 3: hrdps_profiles schema | 2 hours |
| Task 4: HrdpsGridWorker | 1 day |
| Task 5: Cron wiring | 1 hour |
| Task 6: Map overlay | 0.5 day |
| Task 7: Terrain coverage decision | 2 hours (punt if Arctic gets out-of-scope) |
| Task 8: Freshness monitoring | 2 hours |
| Task 9: Precommit + docs | 2 hours |
| Total | ~5 days |
Compare to RDPS plan (~2 days) and the UWYO Canadian soundings (~4 hours, already done). Do HRDPS only when the RDPS 10 km coverage feels limiting, not before.