prop/lib/microwaveprop/weather/hrdps_client.ex
Graham McIntire cea2507fac
test: HTTP injection for MsFootprints/HrdpsClient/NexradClient (79.68%)
- MsFootprints (51% → 93%): http_get injection for dataset_index and
  download_tile, with stubbed CSV parsing, empty CSV, caching, and
  disk-cache-skip tests
- HrdpsClient (47% → 71%): http_get/http_head injection for fetch_grid
  and cycle_available, with stubbed probe, success/failure/transport-
  error tests, plus fetch_grid error path
- NexradClient (57% → 58%): http_get injection, fetch_frame success
  path with valid PNG stub, process_frame coverage
- HrrrNativeClient: http_get injection for fetch_idx (no direct test
  since function is private)

Coverage: 79.43% → 79.68% (need 0.32% more)
2026-05-07 16:36:17 -05:00

392 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"}]
@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