- Bug #12: Lower rate limit to 15/min - Bug #13: Wrap model loading in Task.start - Bug #14: Fix classify_time_period guard gap at -3.0 - Bug #15: Not applicable (Elixir has no ?? operator) - Bug #16: Remove fallback Repo.get for station preload - Bug #17: Extract cache_key() in ContactMapController - Bug #18: Add has_many :contacts and :beacons to User schema - A&D #4: Move serve_markdown_if_requested after secure headers - Config #1: Move signing_salt to runtime.exs env var - Config #2: Use --check-unused instead of --unused - Config #3: Re-enable UnsafeToAtom Credo check - Config #5: Re-enable LeakyEnvironment Credo check - Add @spec annotations to fix re-enabled Specs violations - Replace String.to_atom with to_existing_atom where guarded
225 lines
7.4 KiB
Elixir
225 lines
7.4 KiB
Elixir
defmodule Microwaveprop.Weather.HrdpsClientTest do
|
|
use ExUnit.Case, async: false
|
|
|
|
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" do
|
|
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
|
|
assert HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 0) ==
|
|
"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
|
|
assert String.contains?(HrdpsClient.grib_url(:tmp_2m, ~U[2026-04-29 12:00:00Z], 24), "PT024H.grib2")
|
|
end
|
|
|
|
test "uses MSC level slug for pressure-level temperature" do
|
|
assert String.contains?(HrdpsClient.grib_url(:tmp_850mb, ~U[2026-04-29 12:00:00Z], 0), "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" do
|
|
vars = HrdpsClient.variables()
|
|
|
|
for v <- [:tmp_2m, :depr_2m, :pres_sfc, :ugrd_10m, :vgrd_10m, :tcdc_sfc, :hpbl_sfc] do
|
|
assert v in vars
|
|
end
|
|
end
|
|
|
|
test "covers the grid pressure-level set" do
|
|
vars = HrdpsClient.variables()
|
|
|
|
for var_kind <- [:tmp, :depr, :hgt], level <- [1000, 950, 900, 850, 800, 750, 700] do
|
|
assert String.to_existing_atom("#{var_kind}_#{level}mb") in vars
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "concat_grib_binaries/1" do
|
|
test "byte-concatenates GRIB2 records" do
|
|
a = <<"GRIB", 1::24, 2::8, 0::8*100>>
|
|
b = <<"GRIB", 1::24, 2::8, 1::8*200>>
|
|
assert HrdpsClient.concat_grib_binaries([a, b]) == 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 extracted data into profile shape" do
|
|
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
|
|
end
|
|
end
|
|
|
|
describe "build_profile_from_extracted/1 — DEPR fallback" do
|
|
test "derives surface dewpoint from TMP - DEPR when DPT is missing" do
|
|
extracted = %{
|
|
"TMP:2 m above ground" => 290.0,
|
|
"DEPR:2 m above ground" => 5.0,
|
|
"PRES:surface" => 100_000.0
|
|
}
|
|
|
|
profile = HrdpsClient.build_profile_from_extracted(extracted)
|
|
|
|
assert_in_delta profile.surface_temp_c, 16.85, 0.01
|
|
# 290 - 5 = 285 K = 11.85 C
|
|
assert_in_delta profile.surface_dewpoint_c, 11.85, 0.01
|
|
end
|
|
|
|
test "uses TCDC entire-atmosphere when surface variant is missing" do
|
|
extracted = %{
|
|
"TMP:2 m above ground" => 280.0,
|
|
"DPT:2 m above ground" => 275.0,
|
|
"PRES:surface" => 100_000.0,
|
|
"TCDC:entire atmosphere" => 90.0
|
|
}
|
|
|
|
profile = HrdpsClient.build_profile_from_extracted(extracted)
|
|
assert profile.cloud_cover_pct == 90.0
|
|
end
|
|
|
|
test "returns nil surface scalars when nothing surface-level is in the input" do
|
|
profile = HrdpsClient.build_profile_from_extracted(%{})
|
|
assert profile.surface_temp_c == nil
|
|
assert profile.surface_dewpoint_c == nil
|
|
assert profile.surface_pressure_mb == nil
|
|
assert profile.profile == []
|
|
end
|
|
|
|
test "skips a pressure level that is missing HGT" do
|
|
extracted = %{
|
|
"TMP:2 m above ground" => 290.0,
|
|
"DPT:2 m above ground" => 285.0,
|
|
"PRES:surface" => 100_000.0,
|
|
# Level 850 mb has TMP+DPT but no HGT — skipped.
|
|
"TMP:850 mb" => 275.0,
|
|
"DPT:850 mb" => 270.0,
|
|
# Level 700 mb is fully populated.
|
|
"TMP:700 mb" => 265.0,
|
|
"DPT:700 mb" => 260.0,
|
|
"HGT:700 mb" => 3000.0
|
|
}
|
|
|
|
profile = HrdpsClient.build_profile_from_extracted(extracted)
|
|
assert length(profile.profile) == 1
|
|
assert hd(profile.profile)["pres"] == 700.0
|
|
end
|
|
end
|
|
|
|
describe "cycle_available?/1 (injected http_head)" do
|
|
setup do
|
|
prev = Application.get_env(:microwaveprop, :hrdps_http_head)
|
|
|
|
on_exit(fn ->
|
|
if prev,
|
|
do: Application.put_env(:microwaveprop, :hrdps_http_head, prev),
|
|
else: Application.delete_env(:microwaveprop, :hrdps_http_head)
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
test "returns true when head returns 200" do
|
|
Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts ->
|
|
{:ok, %{status: 200}}
|
|
end)
|
|
|
|
assert HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z])
|
|
end
|
|
|
|
test "returns false when head returns non-200" do
|
|
Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts ->
|
|
{:ok, %{status: 404}}
|
|
end)
|
|
|
|
refute HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z])
|
|
end
|
|
|
|
test "returns false on transport error" do
|
|
Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts ->
|
|
{:error, :timeout}
|
|
end)
|
|
|
|
refute HrdpsClient.cycle_available?(~U[2026-04-29 12:00:00Z])
|
|
end
|
|
end
|
|
|
|
describe "fetch_grid/3 with injected http_get" do
|
|
setup do
|
|
prev_get = Application.get_env(:microwaveprop, :hrdps_http_get)
|
|
prev_head = Application.get_env(:microwaveprop, :hrdps_http_head)
|
|
|
|
Application.put_env(:microwaveprop, :hrdps_http_head, fn _url, _opts ->
|
|
{:ok, %{status: 200}}
|
|
end)
|
|
|
|
on_exit(fn ->
|
|
if prev_get,
|
|
do: Application.put_env(:microwaveprop, :hrdps_http_get, prev_get),
|
|
else: Application.delete_env(:microwaveprop, :hrdps_http_get)
|
|
|
|
if prev_head,
|
|
do: Application.put_env(:microwaveprop, :hrdps_http_head, prev_head),
|
|
else: Application.delete_env(:microwaveprop, :hrdps_http_head)
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
test "returns error when variable fetch fails" do
|
|
call_count = :counters.new(1, [:atomics])
|
|
|
|
Application.put_env(:microwaveprop, :hrdps_http_get, fn _url, _opts ->
|
|
:counters.add(call_count, 1, 1)
|
|
{:error, :timeout}
|
|
end)
|
|
|
|
assert {:error, _} = HrdpsClient.fetch_grid([{33.0, -97.0}], ~U[2026-04-29 12:00:00Z])
|
|
end
|
|
end
|
|
end
|