prop/docs/plans/2026-04-13-hrdps-canadian-prop-grid.md
Graham McIntire 14e81f9251
docs(hrdps): plan + probe wedge for Canadian propagation grid
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.
2026-04-29 16:47:15 -05:00

313 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (12 hours)
**Step R.1: Confirm HRDPS datamart URL pattern**
```bash
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**
```bash
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:
```bash
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 f00f24 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**
```elixir
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.21.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`**
```elixir
@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.22.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.13.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` (add `canadian_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**
```elixir
queues: [
# ... existing
hrdps: 1 # HRDPS fetch is sequential like HRRR (memory pressure)
]
```
**Step 5.2: Add cron**
```elixir
# 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` — export `north_america_points/0` that returns HRRR HRDPS
**Step 6.1: Write failing LiveView test**
```elixir
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**
```bash
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°S60°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 4260°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` + `HrdpsFetchWorker` to 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_watermark` alarms 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/1` so 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.