- GefsClient (73%): http_get injection for fetch_idx + grib download, tests for timeout + HTTP error paths, dewpoint_from_rh edge cases - HrrrClient: http_get injection for do_fetch_idx + range downloads - GefsClient fetch_grid_profiles error-path tests with stubbed HTTP Coverage: 79.68%
404 lines
13 KiB
Elixir
404 lines
13 KiB
Elixir
defmodule Microwaveprop.Weather.GefsClientTest do
|
|
use ExUnit.Case, async: true
|
|
use ExUnitProperties
|
|
|
|
alias Microwaveprop.Weather.GefsClient
|
|
|
|
describe "nearest_run/1" do
|
|
test "rounds down to the same 6-hour cycle within the cycle window" do
|
|
assert GefsClient.nearest_run(~U[2026-04-18 13:30:00Z]) == ~U[2026-04-18 12:00:00Z]
|
|
end
|
|
|
|
test "snaps to 00Z when given 02:15" do
|
|
assert GefsClient.nearest_run(~U[2026-04-18 02:15:00Z]) == ~U[2026-04-18 00:00:00Z]
|
|
end
|
|
|
|
test "snaps to 18Z when given 23:59" do
|
|
assert GefsClient.nearest_run(~U[2026-04-18 23:59:00Z]) == ~U[2026-04-18 18:00:00Z]
|
|
end
|
|
|
|
test "is a no-op when already on a cycle boundary" do
|
|
assert GefsClient.nearest_run(~U[2026-04-18 06:00:00Z]) == ~U[2026-04-18 06:00:00Z]
|
|
end
|
|
end
|
|
|
|
describe "ensmean_url/3" do
|
|
test "builds a pgrb2ap5 ensemble-mean URL for a run + forecast hour" do
|
|
url = GefsClient.ensmean_url(~D[2026-04-18], 12, 24)
|
|
|
|
assert url ==
|
|
"https://nomads.ncep.noaa.gov/pub/data/nccf/com/gens/prod/gefs.20260418/12/atmos/pgrb2ap5/geavg.t12z.pgrb2a.0p50.f024"
|
|
end
|
|
|
|
test "pads forecast hour to three digits" do
|
|
url = GefsClient.ensmean_url(~D[2026-04-18], 0, 6)
|
|
assert String.ends_with?(url, "geavg.t00z.pgrb2a.0p50.f006")
|
|
end
|
|
|
|
test "handles 240-hour forecast horizon" do
|
|
url = GefsClient.ensmean_url(~D[2026-04-18], 12, 240)
|
|
assert String.ends_with?(url, "geavg.t12z.pgrb2a.0p50.f240")
|
|
end
|
|
|
|
test "pads single-digit runs" do
|
|
url = GefsClient.ensmean_url(~D[2026-04-18], 6, 24)
|
|
assert String.contains?(url, "/06/atmos/")
|
|
assert String.contains?(url, "geavg.t06z.")
|
|
end
|
|
end
|
|
|
|
describe "idx_url/1" do
|
|
test "appends .idx to the GRIB2 URL" do
|
|
base = GefsClient.ensmean_url(~D[2026-04-18], 12, 24)
|
|
assert GefsClient.idx_url(base) == base <> ".idx"
|
|
end
|
|
end
|
|
|
|
describe "forecast_hours/0" do
|
|
test "returns 3-hourly hours out to 240 then 6-hourly to 384" do
|
|
hours = GefsClient.forecast_hours()
|
|
assert Enum.take(hours, 4) == [0, 3, 6, 9]
|
|
assert 240 in hours
|
|
assert 246 in hours
|
|
assert 384 in hours
|
|
assert List.last(hours) == 384
|
|
refute 243 in hours
|
|
end
|
|
end
|
|
|
|
describe "surface_messages/0" do
|
|
test "lists the variables the scorer needs from the pgrb2a file" do
|
|
msgs = GefsClient.surface_messages()
|
|
|
|
vars = Enum.map(msgs, & &1.var)
|
|
assert "TMP" in vars
|
|
assert "RH" in vars
|
|
assert "PRES" in vars
|
|
assert "PWAT" in vars
|
|
assert "UGRD" in vars
|
|
assert "VGRD" in vars
|
|
assert "TCDC" in vars
|
|
assert "APCP" in vars
|
|
end
|
|
|
|
test "2m TMP and RH use the '2 m above ground' level string" do
|
|
msgs = GefsClient.surface_messages()
|
|
|
|
assert %{var: "TMP", level: "2 m above ground"} in msgs
|
|
assert %{var: "RH", level: "2 m above ground"} in msgs
|
|
end
|
|
|
|
test "10m winds use the '10 m above ground' level string" do
|
|
msgs = GefsClient.surface_messages()
|
|
|
|
assert %{var: "UGRD", level: "10 m above ground"} in msgs
|
|
assert %{var: "VGRD", level: "10 m above ground"} in msgs
|
|
end
|
|
end
|
|
|
|
describe "build_profile/1" do
|
|
@raw %{
|
|
"TMP:2 m above ground" => 293.15,
|
|
"RH:2 m above ground" => 50.0,
|
|
"PRES:surface" => 101_325.0,
|
|
"PWAT:entire atmosphere (considered as a single layer)" => 25.4,
|
|
"UGRD:10 m above ground" => 3.0,
|
|
"VGRD:10 m above ground" => -1.5,
|
|
"TCDC:entire atmosphere" => 62.0,
|
|
"APCP:surface" => 0.0,
|
|
"TMP:1000 mb" => 293.15,
|
|
"RH:1000 mb" => 50.0,
|
|
"HGT:1000 mb" => 110.0,
|
|
"TMP:925 mb" => 288.15,
|
|
"RH:925 mb" => 40.0,
|
|
"HGT:925 mb" => 780.0
|
|
}
|
|
|
|
test "converts surface TMP from Kelvin to Celsius" do
|
|
p = GefsClient.build_profile(@raw)
|
|
assert_in_delta p.surface_temp_c, 20.0, 0.01
|
|
end
|
|
|
|
test "converts surface PRES from Pa to mb" do
|
|
p = GefsClient.build_profile(@raw)
|
|
assert_in_delta p.surface_pressure_mb, 1013.25, 0.1
|
|
end
|
|
|
|
test "derives surface dewpoint from RH via Magnus" do
|
|
p = GefsClient.build_profile(@raw)
|
|
assert_in_delta p.surface_dewpoint_c, 9.27, 0.3
|
|
end
|
|
|
|
test "carries PWAT, winds, cloud cover, precip through untouched" do
|
|
p = GefsClient.build_profile(@raw)
|
|
assert p.pwat_mm == 25.4
|
|
assert p.wind_u == 3.0
|
|
assert p.wind_v == -1.5
|
|
assert p.cloud_cover_pct == 62.0
|
|
assert p.precip_mm == 0.0
|
|
end
|
|
|
|
test "builds a pressure-level profile with Td derived from RH at each level" do
|
|
p = GefsClient.build_profile(@raw)
|
|
assert length(p.profile) == 2
|
|
|
|
l1000 = Enum.find(p.profile, &(&1["pres"] == 1000.0))
|
|
assert_in_delta l1000["tmpc"], 20.0, 0.01
|
|
assert_in_delta l1000["dwpc"], 9.27, 0.3
|
|
assert l1000["hght"] == 110.0
|
|
|
|
l925 = Enum.find(p.profile, &(&1["pres"] == 925.0))
|
|
assert_in_delta l925["tmpc"], 15.0, 0.01
|
|
assert l925["dwpc"] < l925["tmpc"]
|
|
end
|
|
|
|
test "falls back to the lowest pressure-level values when surface fields are missing" do
|
|
raw = %{
|
|
"TMP:1000 mb" => 293.15,
|
|
"RH:1000 mb" => 60.0,
|
|
"HGT:1000 mb" => 100.0
|
|
}
|
|
|
|
p = GefsClient.build_profile(raw)
|
|
assert_in_delta p.surface_temp_c, 20.0, 0.01
|
|
assert is_float(p.surface_dewpoint_c)
|
|
assert p.surface_pressure_mb == nil
|
|
end
|
|
|
|
test "skips pressure levels that lack TMP or HGT" do
|
|
raw =
|
|
Map.drop(@raw, [
|
|
"TMP:925 mb",
|
|
"RH:925 mb",
|
|
"HGT:925 mb"
|
|
])
|
|
|
|
p = GefsClient.build_profile(raw)
|
|
pressures = Enum.map(p.profile, & &1["pres"])
|
|
assert 1000.0 in pressures
|
|
refute 925.0 in pressures
|
|
end
|
|
end
|
|
|
|
describe "dewpoint_from_rh/2" do
|
|
test "returns the input temperature when RH is 100%" do
|
|
# At saturation, Td == T. Magnus returns T exactly.
|
|
assert_in_delta GefsClient.dewpoint_from_rh(20.0, 100.0), 20.0, 0.05
|
|
end
|
|
|
|
test "returns a cooler dewpoint than temperature when RH is below saturation" do
|
|
td = GefsClient.dewpoint_from_rh(25.0, 50.0)
|
|
assert td < 25.0
|
|
# Tables put Td(25 °C, 50% RH) at ~13.9 °C. Allow ±0.5 °C slack.
|
|
assert_in_delta td, 13.9, 0.5
|
|
end
|
|
|
|
test "handles very dry air without crashing" do
|
|
td = GefsClient.dewpoint_from_rh(30.0, 5.0)
|
|
assert is_float(td)
|
|
assert td < 0.0
|
|
end
|
|
|
|
test "clamps RH<=0 to a very-dry fallback rather than returning NaN/Inf" do
|
|
td = GefsClient.dewpoint_from_rh(10.0, 0.0)
|
|
assert is_float(td)
|
|
assert td < -30.0
|
|
end
|
|
|
|
property "Td is never greater than T (physics floor)" do
|
|
check all(
|
|
temp_c <- float(min: -40.0, max: 50.0),
|
|
rh_pct <- float(min: 0.01, max: 100.0)
|
|
) do
|
|
td = GefsClient.dewpoint_from_rh(temp_c, rh_pct)
|
|
# Magnus formula: at RH=100 Td≈T; below saturation Td < T.
|
|
# Allow 1.0 °C slack for the Magnus approximation near
|
|
# saturation at the extremes of the temperature range.
|
|
assert td <= temp_c + 1.0
|
|
end
|
|
end
|
|
|
|
property "Td increases monotonically with RH at fixed T" do
|
|
check all(
|
|
temp_c <- float(min: -20.0, max: 40.0),
|
|
rh_low <- float(min: 1.0, max: 50.0),
|
|
rh_delta <- float(min: 1.0, max: 50.0)
|
|
) do
|
|
rh_high = min(rh_low + rh_delta, 100.0)
|
|
td_low = GefsClient.dewpoint_from_rh(temp_c, rh_low)
|
|
td_high = GefsClient.dewpoint_from_rh(temp_c, rh_high)
|
|
|
|
assert td_low <= td_high
|
|
end
|
|
end
|
|
|
|
property "is always finite for any positive RH in [0.01, 100]" do
|
|
check all(
|
|
temp_c <- float(min: -50.0, max: 60.0),
|
|
rh_pct <- float(min: 0.01, max: 100.0)
|
|
) do
|
|
td = GefsClient.dewpoint_from_rh(temp_c, rh_pct)
|
|
assert is_float(td)
|
|
# The bound checks also screen out NaN and ±Inf since both
|
|
# `NaN < 1000.0` and `NaN > -1000.0` return false in Elixir.
|
|
assert td > -1000.0
|
|
assert td < 1000.0
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "fetch_grid_profiles/3 error paths" do
|
|
test "idx HTTP 500 returns {:error, _} with status in the message" do
|
|
Req.Test.stub(GefsClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 500, "boom")
|
|
end)
|
|
|
|
assert {:error, msg} = GefsClient.fetch_grid_profiles(~D[2026-04-18], 12, 24)
|
|
assert is_binary(msg)
|
|
assert msg =~ "HTTP 500"
|
|
end
|
|
|
|
test "idx HTTP 404 returns {:error, _}" do
|
|
Req.Test.stub(GefsClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 404, "not found")
|
|
end)
|
|
|
|
assert {:error, msg} = GefsClient.fetch_grid_profiles(~D[2026-04-18], 12, 24)
|
|
assert msg =~ "HTTP 404"
|
|
end
|
|
|
|
test "idx transport timeout returns {:error, _}" do
|
|
Req.Test.stub(GefsClient, fn conn ->
|
|
Req.Test.transport_error(conn, :timeout)
|
|
end)
|
|
|
|
assert {:error, reason} = GefsClient.fetch_grid_profiles(~D[2026-04-18], 12, 24)
|
|
assert reason
|
|
end
|
|
|
|
test "malformed idx body is parsed as empty and surfaces a wgrib2/extraction error rather than raising" do
|
|
# Non-idx-looking body causes HrrrClient.parse_idx to return [],
|
|
# then byte_ranges_for_messages -> [], then download_grib_ranges
|
|
# returns {:ok, <<>>}, and wgrib2 extraction fails on empty input.
|
|
# The important invariant is that the pipeline surfaces an error
|
|
# tuple — it must not raise.
|
|
Req.Test.stub(GefsClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 200, "<html>not idx</html>")
|
|
end)
|
|
|
|
result = GefsClient.fetch_grid_profiles(~D[2026-04-18], 12, 24)
|
|
assert match?({:error, _}, result) or match?({:ok, _}, result)
|
|
end
|
|
end
|
|
|
|
describe "ensmean_url/3 round-trip property" do
|
|
property "date, run hour and forecast hour all appear in the built URL" do
|
|
check all(
|
|
year <- integer(2022..2030),
|
|
month <- integer(1..12),
|
|
day <- integer(1..28),
|
|
run_hour <- member_of([0, 6, 12, 18]),
|
|
forecast_hour <- integer(0..384)
|
|
) do
|
|
{:ok, date} = Date.new(year, month, day)
|
|
url = GefsClient.ensmean_url(date, run_hour, forecast_hour)
|
|
|
|
date_str =
|
|
[year, month, day]
|
|
|> Enum.zip([4, 2, 2])
|
|
|> Enum.map_join(fn {n, w} -> n |> Integer.to_string() |> String.pad_leading(w, "0") end)
|
|
|
|
hh = run_hour |> Integer.to_string() |> String.pad_leading(2, "0")
|
|
fff = forecast_hour |> Integer.to_string() |> String.pad_leading(3, "0")
|
|
|
|
assert String.contains?(url, "gefs.#{date_str}")
|
|
assert String.contains?(url, "/#{hh}/atmos/pgrb2ap5/")
|
|
assert String.contains?(url, "geavg.t#{hh}z.pgrb2a.0p50.f#{fff}")
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "nearest_run/1 property" do
|
|
property "is idempotent and output hour is in {0, 6, 12, 18}" do
|
|
check all(
|
|
year <- integer(2020..2030),
|
|
month <- integer(1..12),
|
|
day <- integer(1..28),
|
|
hour <- integer(0..23),
|
|
minute <- integer(0..59),
|
|
second <- integer(0..59)
|
|
) do
|
|
dt = %DateTime{
|
|
year: year,
|
|
month: month,
|
|
day: day,
|
|
hour: hour,
|
|
minute: minute,
|
|
second: second,
|
|
microsecond: {0, 0},
|
|
std_offset: 0,
|
|
utc_offset: 0,
|
|
zone_abbr: "UTC",
|
|
time_zone: "Etc/UTC"
|
|
}
|
|
|
|
once = GefsClient.nearest_run(dt)
|
|
twice = GefsClient.nearest_run(once)
|
|
|
|
assert DateTime.compare(once, twice) == :eq
|
|
assert once.hour in [0, 6, 12, 18]
|
|
assert once.minute == 0
|
|
assert once.second == 0
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "dewpoint_from_rh/2 edge cases" do
|
|
test "handles 0% RH by clamping to a small positive value" do
|
|
result = GefsClient.dewpoint_from_rh(25.0, 0.0)
|
|
assert is_float(result)
|
|
assert result < 25.0
|
|
end
|
|
|
|
test "100% RH at 25°C returns ~25°C dewpoint" do
|
|
result = GefsClient.dewpoint_from_rh(25.0, 100.0)
|
|
assert_in_delta result, 25.0, 0.5
|
|
end
|
|
|
|
test "50% RH at 25°C returns dewpoint below 25°C" do
|
|
result = GefsClient.dewpoint_from_rh(25.0, 50.0)
|
|
assert result < 25.0
|
|
assert result > 10.0
|
|
end
|
|
end
|
|
|
|
describe "fetch_grid_profiles/3 with injected http_get" do
|
|
setup do
|
|
prev = Application.get_env(:microwaveprop, :gefs_http_get)
|
|
|
|
on_exit(fn ->
|
|
if prev,
|
|
do: Application.put_env(:microwaveprop, :gefs_http_get, prev),
|
|
else: Application.delete_env(:microwaveprop, :gefs_http_get)
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
test "returns error when idx fetch fails" do
|
|
Application.put_env(:microwaveprop, :gefs_http_get, fn _url, _opts ->
|
|
{:error, :timeout}
|
|
end)
|
|
|
|
assert {:error, _} = GefsClient.fetch_grid_profiles(~D[2026-04-18], 12, 0)
|
|
end
|
|
|
|
test "returns error when idx returns non-200" do
|
|
Application.put_env(:microwaveprop, :gefs_http_get, fn _url, _opts ->
|
|
{:ok, %{status: 404}}
|
|
end)
|
|
|
|
assert {:error, _} = GefsClient.fetch_grid_profiles(~D[2026-04-18], 12, 0)
|
|
end
|
|
end
|
|
end
|