prop/lib/microwaveprop/weather/hrdps_client.ex
Graham McIntire 0c3be97abb
Some checks failed
Build base image / Build and push base image (push) Successful in 3m10s
Build and Push / Build and Push Docker Image (push) Failing after 14s
Build prop-grid-rs / Test, build, push (push) Successful in 12m52s
fix: resolve 27 security, architecture, test, and performance audit findings
P0 (security-critical):
- Gate CSV/ADIF upload tabs behind authentication, add 30s cooldown to all upload handlers
- Cap CSV/ADIF imports at 2,000 rows server-side in both parsers
- Add submitter_verified boolean to contacts (client-cannot-set, anonymous=false)
- Create k8s/secret.example.yaml with placeholders, add LIVE_VIEW_SIGNING_SALT

P1 (high-priority):
- Add Mox.verify_on_exit!() to valkey_test.exs
- Replace DateTime.utc_now() truncation with static ~U literals in map_live_test.exs
- Replace Process.sleep with render_async in pskr_spots_live_test.exs (6 occurrences)
- Add MonitorLive.Show test coverage (4 tests: owner view, non-owner redirect, config success/error)
- Extract duct-detection and mechanism-classification logic from ContactLive.Show into Propagation.PathAnalysis
- Split ContactLive.Show render into 12 function components
- Update CLAUDE.md: remove stale ML model, mark HRDPS active, add backtest/pskr dirs
- Batch CSV import enrichment jobs via new enqueue_for_contacts/1

P2 (medium-priority):
- Set secure:true on session and remember-me cookies in production
- Change SMTP TLS from verify_none to verify_peer with public_key cacerts
- Make /metrics fail-closed in production when PROMETHEUS_AUTH_TOKEN unset
- Add RateLimiter (anon_limit:10, auth_limit:60) to /api/contacts/map
- Add content-security-policy-report-only header
- Add comment noting String.to_atom is compile-time safe in hrdps_client.ex
- Delegate duplicated haversine_km to canonical Microwaveprop.Geo.haversine_km/4
- Consolidate score-tier/color/verdict formatting into Microwaveprop.Format
- Update CLAUDE.md testing section to match actual raw-string-matching practice
- Batch HrrrPointEnqueuer Repo.insert_all calls to single round-trip
- Split weather.ex (1696→216 lines) and radio.ex (1285→54 lines) into purpose-based sub-facades

P3 (low-priority):
- Add LIVE_VIEW_SIGNING_SALT warning comment, extend filter_parameters
- Add host/community validation to snmp_client.ex
- Add raw/1 safety comment in algo_live.ex
- Add hex-audit and cargo-audit Makefile targets
- Add privacy_live smoke test
- Replace notify_listener busy-poll loop with Process.monitor/1 + assert_receive
- Add ContactCommonVolumeRadar changeset validation tests (5 tests)
2026-07-27 18:19:37 -05:00

395 lines
14 KiB
Elixir
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.

defmodule Microwaveprop.Weather.HrdpsClient do
@moduledoc """
Client for ECCC's HRDPS (High Resolution Deterministic Prediction System) —
the Canadian analog to HRRR. Mirrors `Microwaveprop.Weather.HrrrClient`'s
shape (`fetch_grid/3` returning `{:ok, %{{lat, lon} => profile}}`) so the
propagation chain can call either polymorphically.
## Architectural differences from HRRR
* **URL structure** — date-prefixed datamart paths
(`https://dd.weather.gc.ca/{YYYYMMDD}/WXO-DD/model_hrdps/continental/2.5km/{HH}/{FFF}/...`).
No flat `/model_hrdps/` root; the old structure 404s as of the 2026-04-29
re-verification.
* **One variable per file** — HRDPS publishes each variable as its own
GRIB2 file (~3.5 MB each) instead of HRRR's bundled `wrfsfcf`/`wrfprsf`.
No idx files, no byte-range partials. We fetch each needed variable's
file concurrently and **byte-concatenate** them into a single multi-record
binary — wgrib2 reads the result as if it were a normal multi-message
GRIB2 file. No `wgrib2 -merge` step needed.
* **Rotated lat/lon projection** — handled transparently by `wgrib2 -lon`
inside `Microwaveprop.Weather.Grib2.Wgrib2.extract_points_from_file/3`.
* **Cycle cadence** — 4×/day at 00/06/12/18Z (vs HRRR's hourly), forecasts
out to 48h. Publish latency is ~3-4h after each cycle.
"""
alias Microwaveprop.Weather.Grib2.Wgrib2
require Logger
@datamart_base_default "https://dd.weather.gc.ca"
defp datamart_base, do: Application.get_env(:microwaveprop, :hrdps_datamart_base, @datamart_base_default)
# Surface variables required by the propagation scorer. HRRR's equivalent
# set is in `HrrrClient.@surface_messages`; HRDPS doesn't publish the
# bundled APCP / PWAT inventory at f000, so the scorer's PWAT factor falls
# back to its default for HRDPS-derived points (see plan stage 1.2).
@surface_vars [
{:tmp_2m, "TMP", "AGL-2m"},
{:depr_2m, "DEPR", "AGL-2m"},
{:dpt_2m, "DPT", "AGL-2m"},
{:pres_sfc, "PRES", "Sfc"},
{:hpbl_sfc, "HPBL", "Sfc"},
{:ugrd_10m, "UGRD", "AGL-10m"},
{:vgrd_10m, "VGRD", "AGL-10m"},
{:tcdc_sfc, "TCDC", "Sfc"}
]
# Pressure-level set covering the lower troposphere where the refractivity
# gradient is computed. Matches HRRR's `@grid_pressure_levels` (1000→700 mb)
# so SoundingParams.derive sees a comparable profile shape.
@grid_pressure_levels [1000, 950, 900, 850, 800, 750, 700]
@pressure_var_kinds [{:tmp, "TMP"}, {:depr, "DEPR"}, {:hgt, "HGT"}]
# All variable atoms are generated from compile-time constants
# (@grid_pressure_levels + @pressure_var_kinds = 21 atoms max) — safe
# to use String.to_atom/1 here; not reachable from user input.
@pressure_vars (for level <- @grid_pressure_levels,
{kind, msc} <- @pressure_var_kinds do
atom = String.to_atom("#{kind}_#{level}mb")
msc_level = "ISBL_#{level |> Integer.to_string() |> String.pad_leading(4, "0")}"
{atom, msc, msc_level}
end)
@all_vars @surface_vars ++ @pressure_vars
@var_lookup Map.new(@all_vars, fn {atom, msc_var, msc_level} -> {atom, {msc_var, msc_level}} end)
# --- Public API ---
@doc "List of all variable atoms this client knows how to fetch."
@spec variables() :: [atom()]
def variables, do: Enum.map(@all_vars, fn {atom, _, _} -> atom end)
@doc """
Build the MSC Datamart URL for a single HRDPS variable file.
ECCC reorganized to a date-prefixed structure as of 2026; the old
`/model_hrdps/...` flat root 404s. URLs always look like:
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
"""
@spec grib_url(atom(), DateTime.t(), non_neg_integer()) :: String.t()
def grib_url(variable, %DateTime{} = run_time, forecast_hour) do
{msc_var, msc_level} =
Map.get(@var_lookup, variable) ||
raise ArgumentError, "unknown HRDPS variable #{inspect(variable)}"
cycle = nearest_hrdps_cycle(run_time)
date_str = Calendar.strftime(cycle, "%Y%m%d")
hour_str = cycle.hour |> Integer.to_string() |> String.pad_leading(2, "0")
fff_str = forecast_hour |> Integer.to_string() |> String.pad_leading(3, "0")
filename =
"#{date_str}T#{hour_str}Z_MSC_HRDPS_#{msc_var}_#{msc_level}_RLatLon0.0225_PT#{fff_str}H.grib2"
"#{datamart_base()}/#{date_str}/WXO-DD/model_hrdps/continental/2.5km/#{hour_str}/#{fff_str}/#{filename}"
end
@doc """
Snap a `DateTime` to the start of the most recent HRDPS cycle hour
(00/06/12/18Z). Always rounds DOWN — never points at a future cycle that
may not have published yet. Callers needing a later valid_time should
reach it via `forecast_hour` instead.
"""
@spec nearest_hrdps_cycle(DateTime.t()) :: DateTime.t()
def nearest_hrdps_cycle(%DateTime{} = dt) do
cycle_hour = div(dt.hour, 6) * 6
seconds_into_cycle = (dt.hour - cycle_hour) * 3600 + dt.minute * 60 + dt.second
DateTime.add(dt, -seconds_into_cycle, :second)
end
@doc """
Returns true if the HRDPS cycle for `run_time` is published. Probes the
cycle's f000 directory existence; the directory only appears once the
first forecast hour's files are uploaded.
Override the probe in tests via the `:microwaveprop, :hrdps_cycle_available_fn`
Application env (a 1-arity function from `DateTime` to `boolean`).
"""
@spec cycle_available?(DateTime.t()) :: boolean()
def cycle_available?(run_time) do
rounded = nearest_hrdps_cycle(run_time)
case Application.get_env(:microwaveprop, :hrdps_cycle_available_fn) do
fun when is_function(fun, 1) -> fun.(rounded)
_ -> probe_cycle(rounded)
end
end
defp probe_cycle(%DateTime{} = run_time) do
date_str = Calendar.strftime(run_time, "%Y%m%d")
hour_str = run_time.hour |> Integer.to_string() |> String.pad_leading(2, "0")
url = "#{datamart_base()}/#{date_str}/WXO-DD/model_hrdps/continental/2.5km/#{hour_str}/000/"
case http_head(url, req_options()) do
{:ok, %{status: 200}} -> true
_ -> false
end
end
@doc """
Fetch HRDPS profiles for the given list of `{lat, lon}` points at the
cycle's `forecast_hour`. Returns `{:ok, %{{lat, lon} => profile}}` matching
the shape `HrrrClient.fetch_grid/3` produces, so downstream code is
source-agnostic.
Internally:
1. Build URLs for every variable in `variables/0`.
2. Concurrently download each file as a binary.
3. Byte-concatenate into a single multi-record GRIB2.
4. Stage to a temp file; call `Wgrib2.extract_points_from_file/3` with all
points in one pass — wgrib2 handles the rotated-lat/lon reprojection.
5. Build per-point profiles via `build_profile_from_extracted/1`.
"""
@spec fetch_grid([{float(), float()}], DateTime.t(), keyword()) ::
{:ok, %{{float(), float()} => map()}} | {:error, term()}
def fetch_grid(points, run_time, opts \\ []) do
Microwaveprop.Instrument.span(
[:hrdps, :fetch_grid],
%{point_count: length(points), forecast_hour: Keyword.get(opts, :forecast_hour, 0)},
fn -> do_fetch_grid(points, run_time, opts) end
)
end
defp do_fetch_grid(points, run_time, opts) do
cycle = nearest_hrdps_cycle(run_time)
forecast_hour = Keyword.get(opts, :forecast_hour, 0)
with {:ok, binaries} <- fetch_all_variables(cycle, forecast_hour),
combined = concat_grib_binaries(binaries),
{:ok, path} <- write_temp_grib(combined),
{:ok, extracted} <- extract_points(path, points) do
_ = File.rm(path)
profiles =
Map.new(extracted, fn {point, raw} ->
profile =
raw
|> build_profile_from_extracted()
|> Map.put(:run_time, cycle)
|> Map.put(:forecast_hour, forecast_hour)
{point, profile}
end)
{:ok, profiles}
end
end
@doc """
Concatenate a list of GRIB2 binaries. wgrib2 reads multi-record GRIB2 files
natively, so byte-concatenation is a valid stand-in for the `wgrib2 -merge`
workflow — and skips the temp-file shuffle.
"""
@spec concat_grib_binaries([binary()]) :: binary()
def concat_grib_binaries(binaries), do: IO.iodata_to_binary(binaries)
@doc """
Translate the `Wgrib2.extract_points_from_file/3` output (`%{"VAR:LEVEL" => float}`)
into the same profile shape `HrrrClient.build_profile/1` produces, so
downstream code can consume HRDPS-derived rows interchangeably with
HRRR-derived rows.
HRDPS publishes DEPR (T-Td depression in K) as a primary surface variable
rather than DPT. When DPT is absent this builder derives dewpoint as
`temp - depression`.
"""
@spec build_profile_from_extracted(%{String.t() => number()}) :: map()
def build_profile_from_extracted(extracted) do
sfc_temp_k = extracted["TMP:2 m above ground"]
sfc_dpt_k = surface_dewpoint_k(extracted, sfc_temp_k)
sfc_pres_pa = extracted["PRES:surface"]
profile_levels = build_pressure_profile(extracted)
lowest = List.first(profile_levels)
sfc_temp_c =
cond do
sfc_temp_k -> sfc_temp_k - 273.15
lowest -> lowest["tmpc"]
true -> nil
end
sfc_dpt_c =
cond do
sfc_dpt_k -> sfc_dpt_k - 273.15
lowest -> lowest["dwpc"]
true -> nil
end
%{
surface_temp_c: sfc_temp_c,
surface_dewpoint_c: sfc_dpt_c,
surface_pressure_mb: if(sfc_pres_pa, do: sfc_pres_pa / 100.0),
hpbl_m: extracted["HPBL:surface"],
pwat_mm: extracted["PWAT:entire atmosphere (considered as a single layer)"],
wind_u: extracted["UGRD:10 m above ground"],
wind_v: extracted["VGRD:10 m above ground"],
cloud_cover_pct: extracted["TCDC:surface"] || extracted["TCDC:entire atmosphere"],
precip_mm: extracted["APCP:surface"],
profile: profile_levels
}
end
defp surface_dewpoint_k(extracted, sfc_temp_k) do
dpt = extracted["DPT:2 m above ground"]
depr = extracted["DEPR:2 m above ground"]
cond do
not is_nil(dpt) -> dpt
not is_nil(sfc_temp_k) and not is_nil(depr) -> sfc_temp_k - depr
true -> nil
end
end
defp build_pressure_profile(extracted) do
Enum.flat_map(@grid_pressure_levels, fn level ->
level_str = "#{level} mb"
tmp = extracted["TMP:#{level_str}"]
dpt = pressure_level_dewpoint(extracted, level_str, tmp)
hgt = extracted["HGT:#{level_str}"]
if tmp && dpt && hgt do
[
%{
"pres" => level * 1.0,
"tmpc" => tmp - 273.15,
"dwpc" => dpt - 273.15,
"hght" => hgt
}
]
else
[]
end
end)
end
defp pressure_level_dewpoint(extracted, level_str, tmp) do
dpt = extracted["DPT:#{level_str}"]
depr = extracted["DEPR:#{level_str}"]
cond do
not is_nil(dpt) -> dpt
not is_nil(tmp) and not is_nil(depr) -> tmp - depr
true -> nil
end
end
# --- Internal: file fetch + concat ---
# Fetch every variable's file concurrently. Datamart files are small
# (~3.5 MB) and individually quick, but ~14 files done serially still adds
# up to several seconds. 4-way concurrency stays well under any sensible
# rate limit.
defp fetch_all_variables(%DateTime{} = cycle, forecast_hour) do
supervisor = {:via, PartitionSupervisor, {Microwaveprop.TaskSupervisor, self()}}
results =
supervisor
|> Task.Supervisor.async_stream_nolink(
variables(),
fn variable -> fetch_variable(variable, cycle, forecast_hour) end,
max_concurrency: 4,
timeout: 120_000,
ordered: true
)
|> Enum.map(fn
{:ok, result} ->
result
{:exit, reason} ->
# Per CLAUDE.md: never silently drop async failures. A swallowed
# download here means a Canadian dead-zone we won't notice for
# hours.
Logger.error(
"HRDPS variable fetch crashed: cycle=#{inspect(cycle)} f=#{forecast_hour} reason=#{inspect(reason)}"
)
{:error, reason}
end)
case Enum.find(results, &match?({:error, _}, &1)) do
nil ->
binaries = Enum.map(results, fn {:ok, _var, body} -> body end)
{:ok, binaries}
{:error, _} = error ->
error
end
end
defp fetch_variable(variable, cycle, forecast_hour) do
url = grib_url(variable, cycle, forecast_hour)
case http_get(url, req_options()) do
{:ok, %{status: 200, body: body}} ->
{:ok, variable, body}
{:ok, %{status: status}} ->
Logger.error("HRDPS fetch HTTP #{status} for variable=#{variable} url=#{url}")
{:error, "HRDPS fetch HTTP #{status}"}
{:error, reason} ->
Logger.error("HRDPS fetch crashed: variable=#{variable} url=#{url} reason=#{inspect(reason)}")
{:error, reason}
end
end
defp write_temp_grib(binary) do
path = Path.join(System.tmp_dir!(), "hrdps_#{System.unique_integer([:positive])}.grib2")
case File.write(path, binary) do
:ok -> {:ok, path}
{:error, reason} -> {:error, {:tmp_write, reason}}
end
end
defp extract_points(path, points) do
Wgrib2.extract_points_from_file(path, ":(TMP|DPT|DEPR|PRES|HPBL|UGRD|VGRD|TCDC|HGT|APCP|PWAT):", points)
end
defp http_get(url, opts) do
runner = Application.get_env(:microwaveprop, :hrdps_http_get, &Req.get/2)
runner.(url, opts)
end
defp http_head(url, opts) do
runner = Application.get_env(:microwaveprop, :hrdps_http_head, &Req.head/2)
runner.(url, opts)
end
defp req_options do
defaults = [receive_timeout: 120_000, retry: &retry?/2, max_retries: 5, retry_delay: &retry_delay/1]
overrides = Application.get_env(:microwaveprop, :hrdps_req_options, [])
Keyword.merge(defaults, overrides)
end
defp retry?(_request, response) do
case response do
%Req.Response{status: status} when status in [429, 500, 502, 503, 504] -> true
%{__exception__: true} -> true
_ -> false
end
end
defp retry_delay(n) do
base = Integer.pow(2, n) * 1_000
jitter = :rand.uniform(1_000)
base + jitter
end
end