Stage 1 of the HRDPS plan. Mirrors HrrrClient.fetch_grid/3's signature
so PropagationGridWorker can call either polymorphically.
Architectural shape per the probe wedge findings:
- ECCC's date-prefixed datamart URL structure
(dd.weather.gc.ca/{YYYYMMDD}/WXO-DD/model_hrdps/...)
- One-variable-per-file model — fetch ~14 small GRIB2s concurrently,
byte-concatenate into a single multi-record binary, hand to
Wgrib2.extract_points_from_file. wgrib2 reads multi-record files
natively so no -merge step is needed.
- wgrib2's -lon flag handles the rotated-lat/lon reprojection
internally — no manual rotation math.
Variable catalog covers the surface set the propagation scorer reads
plus the 1000-700 mb pressure-level set used by the refractivity
gradient. PWAT is unavailable in HRDPS f000 inventory; the scorer's
PWAT factor will fall back to its default for HRDPS-derived points.
DEPR (T-Td depression) is treated as an alternate primary; when DPT
is absent build_profile_from_extracted derives dewpoint as
temp - depression so downstream consumers see the same shape as HRRR.
Plan task 1.6 (CYYR projection sanity check vs UWYO sounding) is
covered by the live probe at lib/mix/tasks/hrdps_probe.ex which
already validated wgrib2's rotated-grid handling against five
Canadian cities; the production implementation routes through the
same Wgrib2.extract_points_from_file path that the probe used.
189 lines
6.7 KiB
Elixir
189 lines
6.7 KiB
Elixir
defmodule Microwaveprop.Weather.HrdpsClientTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Weather.HrdpsClient
|
|
|
|
describe "nearest_hrdps_cycle/1" do
|
|
test "snaps to the start of the current 6-hour cycle" do
|
|
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 13:45:00Z]) ==
|
|
~U[2026-04-29 12:00:00Z]
|
|
|
|
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 19:00:00Z]) ==
|
|
~U[2026-04-29 18:00:00Z]
|
|
|
|
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 00:30:00Z]) ==
|
|
~U[2026-04-29 00:00:00Z]
|
|
end
|
|
|
|
test "rounds DOWN — never points at a future cycle that hasn't published" do
|
|
# 11:59Z is firmly inside the 06Z cycle window (06-12Z); rounding up
|
|
# to 12Z would race the publish latency.
|
|
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 11:59:00Z]) ==
|
|
~U[2026-04-29 06:00:00Z]
|
|
end
|
|
|
|
test "stays same when exactly on a cycle boundary" do
|
|
assert HrdpsClient.nearest_hrdps_cycle(~U[2026-04-29 12:00:00Z]) ==
|
|
~U[2026-04-29 12:00:00Z]
|
|
end
|
|
end
|
|
|
|
describe "grib_url/3" do
|
|
test "builds surface 2m temperature URL with date-prefixed datamart path" do
|
|
url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 0)
|
|
|
|
assert url ==
|
|
"https://dd.weather.gc.ca/20260429/WXO-DD/model_hrdps/continental/2.5km/12/000/" <>
|
|
"20260429T12Z_MSC_HRDPS_TMP_AGL-2m_RLatLon0.0225_PT000H.grib2"
|
|
end
|
|
|
|
test "pads forecast hour to three digits" do
|
|
url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 24)
|
|
assert String.contains?(url, "PT024H.grib2")
|
|
end
|
|
|
|
test "pads cycle hour to two digits" do
|
|
url = HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 06:00:00Z], 0)
|
|
assert String.contains?(url, "/06/000/")
|
|
assert String.contains?(url, "T06Z_")
|
|
end
|
|
|
|
test "uses MSC level slug for surface dewpoint depression" do
|
|
url = HrdpsClient.grib_url(:depr_2m, ~U[2026-04-29 12:00:00Z], 0)
|
|
assert String.contains?(url, "MSC_HRDPS_DEPR_AGL-2m_")
|
|
end
|
|
|
|
test "uses ISBL_NNNN for pressure-level temperature" do
|
|
url = HrdpsClient.grib_url(:tmp_850mb, ~U[2026-04-29 12:00:00Z], 0)
|
|
assert String.contains?(url, "MSC_HRDPS_TMP_ISBL_0850_")
|
|
end
|
|
|
|
test "raises on unknown variable" do
|
|
assert_raise ArgumentError, ~r/unknown HRDPS variable/, fn ->
|
|
HrdpsClient.grib_url(:not_a_real_var, ~U[2026-04-29 12:00:00Z], 0)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "variables/0" do
|
|
test "covers the surface set the propagation scorer reads" do
|
|
vars = HrdpsClient.variables()
|
|
surface = [:tmp_2m, :depr_2m, :pres_sfc, :ugrd_10m, :vgrd_10m, :tcdc_sfc, :hpbl_sfc]
|
|
|
|
for v <- surface do
|
|
assert v in vars, "missing surface variable #{inspect(v)}"
|
|
end
|
|
end
|
|
|
|
test "covers the grid pressure-level set used for refractivity gradient" do
|
|
vars = HrdpsClient.variables()
|
|
|
|
pressure_levels =
|
|
for var_kind <- [:tmp, :depr, :hgt],
|
|
level <- [1000, 950, 900, 850, 800, 750, 700],
|
|
do: :"#{var_kind}_#{level}mb"
|
|
|
|
for v <- pressure_levels do
|
|
assert v in vars, "missing pressure-level variable #{inspect(v)}"
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "concat_grib_binaries/1" do
|
|
test "byte-concatenates GRIB2 records (wgrib2 reads multi-record files natively)" do
|
|
a = <<"GRIB", 1::24, 2::8, 0::8*100>>
|
|
b = <<"GRIB", 1::24, 2::8, 1::8*200>>
|
|
|
|
result = HrdpsClient.concat_grib_binaries([a, b])
|
|
assert result == a <> b
|
|
end
|
|
|
|
test "returns empty binary for empty list" do
|
|
assert HrdpsClient.concat_grib_binaries([]) == <<>>
|
|
end
|
|
end
|
|
|
|
describe "build_profile_from_extracted/1" do
|
|
test "translates the wgrib2 extract shape into the HRRR-style profile shape" do
|
|
# Simulates the output of Wgrib2.extract_points_from_file on a
|
|
# concatenated HRDPS multi-variable file. Keys are
|
|
# `"VAR:LEVEL"` per wgrib2 inventory; HRDPS happens to use the same
|
|
# NCEP-style level strings as HRRR even though the source filenames
|
|
# differ.
|
|
extracted = %{
|
|
"TMP:2 m above ground" => 285.15,
|
|
"DPT:2 m above ground" => 280.15,
|
|
"PRES:surface" => 100_000.0,
|
|
"HPBL:surface" => 800.0,
|
|
"UGRD:10 m above ground" => 3.0,
|
|
"VGRD:10 m above ground" => 1.0,
|
|
"TCDC:surface" => 50.0,
|
|
"TMP:850 mb" => 275.0,
|
|
"DPT:850 mb" => 270.0,
|
|
"HGT:850 mb" => 1500.0
|
|
}
|
|
|
|
profile = HrdpsClient.build_profile_from_extracted(extracted)
|
|
|
|
assert_in_delta profile.surface_temp_c, 12.0, 0.01
|
|
assert_in_delta profile.surface_dewpoint_c, 7.0, 0.01
|
|
assert profile.surface_pressure_mb == 1000.0
|
|
assert profile.hpbl_m == 800.0
|
|
assert profile.cloud_cover_pct == 50.0
|
|
assert profile.wind_u == 3.0
|
|
assert profile.wind_v == 1.0
|
|
|
|
assert [%{"pres" => 850.0, "tmpc" => tmpc, "dwpc" => dwpc, "hght" => 1500.0}] = profile.profile
|
|
|
|
assert_in_delta tmpc, 1.85, 0.01
|
|
assert_in_delta dwpc, -3.15, 0.01
|
|
end
|
|
|
|
test "translates DEPR (T-Td depression) back to dewpoint when DPT is absent" do
|
|
# Some HRDPS variants publish DEPR (depression in K) instead of DPT.
|
|
# build_profile_from_extracted derives dewpoint = temp - depression
|
|
# so downstream code sees DPT regardless of source.
|
|
extracted = %{
|
|
"TMP:2 m above ground" => 290.15,
|
|
"DEPR:2 m above ground" => 5.0
|
|
}
|
|
|
|
profile = HrdpsClient.build_profile_from_extracted(extracted)
|
|
|
|
assert_in_delta profile.surface_temp_c, 17.0, 0.01
|
|
assert_in_delta profile.surface_dewpoint_c, 12.0, 0.01
|
|
end
|
|
end
|
|
|
|
describe "cycle_available?/1 (stubbed probe)" do
|
|
setup do
|
|
original = Application.get_env(:microwaveprop, :hrdps_cycle_available_fn)
|
|
|
|
on_exit(fn ->
|
|
if original,
|
|
do: Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, original),
|
|
else: Application.delete_env(:microwaveprop, :hrdps_cycle_available_fn)
|
|
end)
|
|
end
|
|
|
|
test "returns whatever the configured probe function returns" do
|
|
Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn _dt -> true end)
|
|
assert HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z])
|
|
|
|
Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn _dt -> false end)
|
|
refute HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z])
|
|
end
|
|
|
|
test "passes the snapped run_time to the probe" do
|
|
test_pid = self()
|
|
|
|
Application.put_env(:microwaveprop, :hrdps_cycle_available_fn, fn dt ->
|
|
send(test_pid, {:probed, dt})
|
|
true
|
|
end)
|
|
|
|
HrdpsClient.cycle_available?(~U[2026-04-29 13:45:00Z])
|
|
assert_receive {:probed, ~U[2026-04-29 12:00:00Z]}
|
|
end
|
|
end
|
|
end
|