IEM's RAOB endpoint stopped keeping Canadian stations current — most stalled around Sep 2024 — which leaves a gap in refractivity/ducting calibration over all the CW*/CY* stations already in weather_stations. MSC's own tephi CSV is fixed-width and rendering-focused; the WMO TEMP bulletins would need a full decoder. University of Wyoming serves the same stations in a clean space-delimited format, is current, and accepts the 3-letter station code (drop the leading C from Canadian ICAO). Its output shape matches IemClient.parse_raob_json/1 exactly, so it drops straight into Weather.upsert_sounding/2. - New UwyoSoundingClient with URL builder, Req-based fetch_sounding, and a fixed-width parse_sounding_html that extracts PRES/HGHT/TEMP/ DWPT/DRCT/SKNT from the <PRE> block and DateTime from the <H2> header. - New CanadianSoundingFetchWorker: Oban batch worker that finds all sounding stations whose code starts with C, picks the most recently publishable 00Z or 12Z slot (90 min publish delay), calls UWYO per station, and upserts with SoundingParams-derived refractivity/ ducting fields. - Oban cron at 01:30Z and 13:30Z (dev + prod) to drive the worker. - Req.Test plug wiring in config/test.exs. - Captured Goose Bay fixture as test/support/fixtures/uwyo_sounding_yyr.html. Also drops two implementation plans under docs/plans/: - 2026-04-13-rdps-vertical-profiles.md: ~2 day plan for RDPS 10 km Canadian ducting outside HRRR's Lambert footprint. - 2026-04-13-hrdps-canadian-prop-grid.md: ~5 day plan for the full HRDPS 2.5 km Canadian prop grid. Explicitly recommends doing RDPS first. TDD throughout, 19 new tests, 1318/1318 passing, credo strict clean.
12 KiB
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:
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:
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
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
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
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(usemix 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
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
mix ecto.gen.migration create_rdps_profiles
Columns (mirror hrrr_profiles minus the native-duct fields RDPS doesn't provide):
idbinary_idvalid_timeutc_datetimerun_timeutc_datetimelatfloatlonfloatprofilejsonb (array of pressure-level maps)surface_pressure_mb,surface_temp_c,surface_dewpoint_cfloatssurface_refractivity,min_refractivity_gradientfloatsducting_detectedboolean,duct_characteristicsjsonbpwat_mm,hpbl_mfloats (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
mix ecto.migrate
psql -h localhost -d microwaveprop_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/1callsRdpsClient.fetch_cell_profile, runsSoundingParams.derive/1on the returned profile, upserts viaWeather.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
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
# 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
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/0or 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/3should gracefully degrade to HRRR-only ifrdps_profilesis empty for the requestedvalid_time.