Rename the local dev database from microwaveprop_dev to prop_dev so the name matches the production database. Add scripts/restore_prod_to_local.sh, which pulls the latest pg_dump off the backup server, drops the local prop_dev, recreates it, and pg_restores into it.
307 lines
12 KiB
Markdown
307 lines
12 KiB
Markdown
# RDPS Vertical Profiles (Canadian Ducting Outside HRRR Footprint) Implementation Plan
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** Extend the refractivity/ducting analysis beyond HRRR's Lambert CONUS footprint by ingesting ECCC's Regional Deterministic Prediction System (RDPS) vertical profiles. This gives us forecast ducting across all of Canada + northern-tier US gaps without the full HRDPS ingestion complexity.
|
||
|
||
**Architecture:** RDPS publishes a dedicated "vertical profiles" subset at 10 km resolution, 84h forecast, 4×/day. The files contain pressure-level T/Td/height — exactly the shape `SoundingParams.derive/1` + `Propagation.Duct.analyze/1` already consume. Fetch one forecast cycle per run, decode, store as time-indexed profile rows in a new `rdps_profiles` table (parallel to `hrrr_profiles` but on a 10 km grid), and surface it through a new `/weather-ca` map or extend the existing weather map timeline to "fall back to RDPS outside the HRRR footprint".
|
||
|
||
**Tech Stack:** Elixir 1.15, Req (HTTP), Nx optional, Postgres, Oban, wgrib2 (already installed for HRRR native duct grid)
|
||
|
||
---
|
||
|
||
## Pre-flight: URL and format research
|
||
|
||
Before writing any code, confirm the RDPS vertical-profiles format. MSC docs are thin; the cheapest way is to hit the datamart directly.
|
||
|
||
**Files to touch:** None — this is read-only research.
|
||
|
||
**Step R.1: Find today's run directory**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
curl -s https://dd.weather.gc.ca/model_gem_regional/10km/grib2/00/000/ | grep -oE 'href="[^"]+\.grib2"' | head -20
|
||
```
|
||
|
||
Expected: file list including a `*vertical*profile*.grib2` or similar naming.
|
||
|
||
**Step R.2: Confirm vertical-profiles subdirectory**
|
||
|
||
Actual RDPS directory on dd.weather.gc.ca is `/model_gem_regional/10km/grib2/{HH}/{FFF}/` — where HH is 00/06/12/18 and FFF is forecast hour. The vertical-profile subset may live under a different path; check:
|
||
|
||
```bash
|
||
curl -s https://dd.weather.gc.ca/ensemble/rdps/grib2/ 2>/dev/null
|
||
curl -s https://dd.weather.gc.ca/20260413/WXO-DD/model_gem_regional/ 2>/dev/null | grep -oE 'href="[^"]+/?'
|
||
```
|
||
|
||
If no dedicated vertical-profiles subset exists, fall back to pulling TMP/DEPR/HGT at all 31 pressure levels of the regular RDPS pressure-level files (same approach `HrrrClient.fetch_grid/3` uses for HRRR).
|
||
|
||
**Step R.3: Download one sample file and inspect with wgrib2**
|
||
|
||
```bash
|
||
wgrib2 /tmp/sample.grib2 -var -v | head -30
|
||
```
|
||
|
||
Confirm it contains TMP, DEPR (or SPFH), HGT at 1000, 925, 850, 700, 500, 400, 300, 250, 200 mb (minimum).
|
||
|
||
**Decision after R.3:** If RDPS ships a ready-made vertical-profiles product with structured per-cell levels, continue with Task 1. If it's just the full pressure-level grid (like HRRR), this plan is still viable but the first task becomes "adapt HrrrClient pressure-level extraction to RDPS rotated lat/lon projection" — still simpler than full HRDPS.
|
||
|
||
---
|
||
|
||
## Task 1: New `RdpsClient` module for GRIB2 fetch + decode
|
||
|
||
**Files:**
|
||
- Create: `lib/microwaveprop/weather/rdps_client.ex`
|
||
- Test: `test/microwaveprop/weather/rdps_client_test.exs`
|
||
- Fixture: `test/support/fixtures/rdps_sample.grib2` (capture one real ~50 MB file via curl, commit it)
|
||
|
||
**Step 1.1: Write failing test for URL builder**
|
||
|
||
```elixir
|
||
test "builds the correct datamart URL for a run/forecast hour/pressure level" do
|
||
url = RdpsClient.grib_url(~D[2026-04-13], 12, 6, :tmp, 850)
|
||
assert url =~ "/20260413/WXO-DD/model_gem_regional/10km/grib2/12/006/"
|
||
assert url =~ "TMP_ISBL_850"
|
||
end
|
||
```
|
||
|
||
**Step 1.2: Verify test fails**
|
||
|
||
```
|
||
mix test test/microwaveprop/weather/rdps_client_test.exs -v
|
||
```
|
||
|
||
Expected: FAIL with UndefinedFunctionError.
|
||
|
||
**Step 1.3: Implement `grib_url/5`**
|
||
|
||
Match the actual dd.weather.gc.ca pattern discovered in pre-flight research. Copy the index-file + byte-range download pattern from `HrrrClient.fetch_product/4` — RDPS GRIB2 files also ship `.idx` sidecars.
|
||
|
||
**Step 1.4: Run, confirm PASS**
|
||
|
||
**Step 1.5: Write failing test for `fetch_cell_profile/3`**
|
||
|
||
Given a lat/lon and run time, return `%{"pres", "hght", "tmpc", "dwpc"}` maps for all standard pressure levels, matching the shape `SoundingParams.derive/1` expects. Use the committed fixture GRIB2 via `Req.Test` stub or directly from disk.
|
||
|
||
**Step 1.6: Implement fetch_cell_profile/3 using wgrib2 subprocess**
|
||
|
||
Reuse `Microwaveprop.Weather.Grib2.Wgrib2.extract_points/3` — the module already wraps wgrib2 for HRRR extraction and supports arbitrary GRIB2 files.
|
||
|
||
**Key nuance — rotated lat/lon projection:** RDPS uses rotated_latitude_longitude, not Lambert conformal. wgrib2 handles this internally for `-lon`, `-match ":(TMP|DEPR|HGT):(1000 mb|925 mb|850 mb|700 mb|500 mb|400 mb|300 mb|250 mb|200 mb)"` extraction — verify by comparing a known point against UWYO sounding for Winnipeg/YWG.
|
||
|
||
**Step 1.7: Commit**
|
||
|
||
```bash
|
||
git add lib/microwaveprop/weather/rdps_client.ex test/microwaveprop/weather/rdps_client_test.exs test/support/fixtures/rdps_sample.grib2
|
||
git commit -m "Add RdpsClient for Canadian pressure-level weather fetch"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `rdps_profiles` schema + migration
|
||
|
||
**Files:**
|
||
- Create: `priv/repo/migrations/{timestamp}_create_rdps_profiles.exs` (use `mix ecto.gen.migration create_rdps_profiles`)
|
||
- Create: `lib/microwaveprop/weather/rdps_profile.ex`
|
||
- Test: `test/microwaveprop/weather/rdps_profile_test.exs`
|
||
|
||
**Step 2.1: Write failing changeset test**
|
||
|
||
```elixir
|
||
test "requires valid_time, lat, lon, and profile" do
|
||
changeset = RdpsProfile.changeset(%RdpsProfile{}, %{})
|
||
refute changeset.valid?
|
||
assert %{valid_time: [_], lat: [_], lon: [_], profile: [_]} = errors_on(changeset)
|
||
end
|
||
```
|
||
|
||
**Step 2.2: Generate migration**
|
||
|
||
```bash
|
||
mix ecto.gen.migration create_rdps_profiles
|
||
```
|
||
|
||
Columns (mirror `hrrr_profiles` minus the native-duct fields RDPS doesn't provide):
|
||
- `id` binary_id
|
||
- `valid_time` utc_datetime
|
||
- `run_time` utc_datetime
|
||
- `lat` float
|
||
- `lon` float
|
||
- `profile` jsonb (array of pressure-level maps)
|
||
- `surface_pressure_mb`, `surface_temp_c`, `surface_dewpoint_c` floats
|
||
- `surface_refractivity`, `min_refractivity_gradient` floats
|
||
- `ducting_detected` boolean, `duct_characteristics` jsonb
|
||
- `pwat_mm`, `hpbl_m` floats (if RDPS publishes them; otherwise null)
|
||
- unique index on `(lat, lon, valid_time)`
|
||
- timestamps
|
||
|
||
**Step 2.3: Implement schema module**
|
||
|
||
Copy shape from `lib/microwaveprop/weather/hrrr_profile.ex`, strip anything RDPS doesn't supply.
|
||
|
||
**Step 2.4: Run migration against dev DB and verify**
|
||
|
||
```bash
|
||
mix ecto.migrate
|
||
psql -h localhost -d prop_dev -c "\d rdps_profiles"
|
||
```
|
||
|
||
**Step 2.5: Run schema test, verify PASS**
|
||
|
||
**Step 2.6: Commit**
|
||
|
||
---
|
||
|
||
## Task 3: `RdpsFetchWorker` — one cell per job, queue-limited
|
||
|
||
**Files:**
|
||
- Create: `lib/microwaveprop/workers/rdps_fetch_worker.ex`
|
||
- Test: `test/microwaveprop/workers/rdps_fetch_worker_test.exs`
|
||
|
||
**Step 3.1: Write failing test**
|
||
|
||
Worker takes `%{"lat" => lat, "lon" => lon, "valid_time" => iso}`, stubs `RdpsClient.fetch_cell_profile/3`, asserts an `RdpsProfile` row is upserted with derived SoundingParams fields filled in.
|
||
|
||
**Step 3.2: Implement worker**
|
||
|
||
- `use Oban.Worker, queue: :rdps, max_attempts: 3, unique: ...`
|
||
- `perform/1` calls `RdpsClient.fetch_cell_profile`, runs `SoundingParams.derive/1` on the returned profile, upserts via `Weather.upsert_rdps_profile/1` (add this function).
|
||
|
||
**Step 3.3: Verify test passes**
|
||
|
||
**Step 3.4: Commit**
|
||
|
||
---
|
||
|
||
## Task 4: `RdpsGridWorker` — hourly-ish batch driver
|
||
|
||
**Files:**
|
||
- Create: `lib/microwaveprop/workers/rdps_grid_worker.ex`
|
||
- Test: `test/microwaveprop/workers/rdps_grid_worker_test.exs`
|
||
|
||
**Step 4.1: Write failing test**
|
||
|
||
Test that `perform/1` enqueues one `RdpsFetchWorker` per grid point in a Canadian bounding box `(south, north, west, east) = (42, 83, -141, -52)` sampled at 0.5° (≈1500 points).
|
||
|
||
**Step 4.2: Implement**
|
||
|
||
Mirror `PropagationGridWorker.perform/1` — compute run_time (2 hours ago nearest RDPS synoptic hour 00/06/12/18), loop over Canadian grid, enqueue. Don't couple to HRRR; RDPS runs on a different cadence (every 6 hours vs HRRR every hour) and this keeps the two pipelines independent.
|
||
|
||
**Step 4.3: Verify**
|
||
|
||
**Step 4.4: Commit**
|
||
|
||
---
|
||
|
||
## Task 5: Oban cron wiring + queue limits
|
||
|
||
**Files:**
|
||
- Modify: `config/config.exs` (dev + shared)
|
||
- Modify: `config/runtime.exs` (prod Oban config)
|
||
|
||
**Step 5.1: Add `rdps` queue**
|
||
|
||
```elixir
|
||
queues: [
|
||
# ... existing
|
||
rdps: 2
|
||
]
|
||
```
|
||
|
||
Dev: 2. Prod: 2 per pod × 3 pods = 6 workers, plus a global_limit if MSC starts rate-limiting.
|
||
|
||
**Step 5.2: Add cron**
|
||
|
||
```elixir
|
||
# RDPS runs 00/06/12/18Z. Data typically publishes ~90 minutes after synoptic.
|
||
{"30 */6 * * *", Microwaveprop.Workers.RdpsGridWorker}
|
||
```
|
||
|
||
**Step 5.3: `mix compile` sanity-check**
|
||
|
||
**Step 5.4: Commit**
|
||
|
||
---
|
||
|
||
## Task 6: Score integration — fall back to RDPS outside HRRR footprint
|
||
|
||
**Files:**
|
||
- Modify: `lib/microwaveprop/propagation.ex:~244` (`scores_at/3`)
|
||
- Modify: `lib/microwaveprop/workers/propagation_grid_worker.ex:~74` (`process_forecast_hour/4`)
|
||
- Test: `test/microwaveprop/propagation/rdps_fallback_test.exs`
|
||
|
||
**Step 6.1: Write failing test**
|
||
|
||
For a lat/lon inside HRRR's CONUS footprint, `Propagation.score_grid_point/4` uses the HRRR profile. For a lat/lon in northern Canada (65N, -90W), it falls back to the `RdpsProfile` for the same valid_time.
|
||
|
||
**Step 6.2: Add `rdps_profile_at/3` helper in `Weather`**
|
||
|
||
```elixir
|
||
def rdps_profile_at(lat, lon, valid_time) do
|
||
# nearest-neighbor lookup in rdps_profiles
|
||
end
|
||
```
|
||
|
||
**Step 6.3: Modify `process_forecast_hour/4` to also score RDPS points**
|
||
|
||
Two-pass: first the HRRR CONUS grid as today, then a second pass over RDPS cells that fall outside HRRR's Lambert domain. Extend `grid_data` to include both sources, mark each point with `source: :hrrr` or `source: :rdps` in factors.
|
||
|
||
**Step 6.4: Verify tests + precommit**
|
||
|
||
**Step 6.5: Commit**
|
||
|
||
---
|
||
|
||
## Task 7: Map overlay — extend CONUS prop map north
|
||
|
||
**Files:**
|
||
- Modify: `lib/microwaveprop/propagation/grid.ex` — `conus_points/0` → `north_america_points/0` or similar
|
||
- Modify: `assets/js/propagation_map_hook.ts` — extend bounds, label RDPS-sourced cells
|
||
|
||
**Step 7.1: Decide UX**
|
||
|
||
Pick one:
|
||
- **Option A**: Silent extension — the existing /map just shows scores north of 49N without visual distinction.
|
||
- **Option B**: Dashed line on the map at the HRRR/RDPS boundary, tooltip notes data source.
|
||
|
||
Default to Option A unless your human partner prefers B.
|
||
|
||
**Step 7.2: Extend grid bounds**
|
||
|
||
Change the `conus_points/0` generator to include cells in `(42, 83, -141, -52)` for RDPS. Keep CONUS cells on the current 0.125° grid, add RDPS cells at 0.5° (coarser).
|
||
|
||
**Step 7.3: Verify `/map` renders Canadian cells**
|
||
|
||
Manual: `mix phx.server`, open http://localhost:4000/map, zoom to Ontario/Quebec, confirm colored cells appear.
|
||
|
||
**Step 7.4: Commit**
|
||
|
||
---
|
||
|
||
## Task 8: Precommit + PR
|
||
|
||
**Step 8.1: `mix precommit`**
|
||
|
||
Must show 0 failures.
|
||
|
||
**Step 8.2: Update `CLAUDE.md` with the new pipeline**
|
||
|
||
Add an `RdpsFetchWorker`/`RdpsGridWorker` row to the Background Job Queues table and a bullet under `## Key Data Sources` for RDPS.
|
||
|
||
**Step 8.3: Update `memory/reference_hrdps_canada.md`**
|
||
|
||
Add a note: "RDPS vertical profiles (10 km) ingestion landed YYYY-MM-DD — gives Canadian ducting without the full HRDPS rewrite."
|
||
|
||
**Step 8.4: Commit and push to both `github` and `origin` remotes**
|
||
|
||
Do NOT push to `dokku`. Let Flux roll out the new image from the CI build.
|
||
|
||
---
|
||
|
||
## Notes / risks
|
||
|
||
- **Rotated lat/lon projection**: wgrib2 handles it correctly for point extraction but caution needed if the team ever decides to store full-grid binary rasters.
|
||
- **File size**: RDPS pressure-level files are ~50 MB each. At 4 runs/day × ~10 forecast hours × ~5 variables × ~10 pressure levels, total download ≈ 2 GB/day. Not a problem on a home lab with skippy.w5isp.com cache, but worth caching aggressively.
|
||
- **Data latency**: RDPS publishes ~90 minutes after synoptic; the cron is already aligned.
|
||
- **Fallback when RDPS is unavailable**: `scores_at/3` should gracefully degrade to HRRR-only if `rdps_profiles` is empty for the requested `valid_time`.
|