prop/test/microwaveprop/weather/gefs_client_test.exs
Graham McIntire a5c7dce147
feat(gefs): scaffold extended-horizon forecast ingestion
Lays the groundwork for a Day 2-7 propagation outlook driven by NCEP
GEFS ensemble-mean output, picking up where HRRR's 18-hour horizon
leaves off.

- GefsClient: NOMADS URL builder, forecast-hour list (3h to +240,
  6h to +384), ensemble-mean message inventory, and a Magnus
  dewpoint derivation (pgrb2a publishes RH rather than Td)
- gefs_profiles table + GefsProfile schema mirroring hrrr_profiles
  so the scorer can run over rows from either source
- Weather.upsert_gefs_profile/1 and upsert_gefs_profiles_batch/1

No worker or UI yet — those land in follow-up commits.
2026-04-18 14:28:44 -05:00

123 lines
3.9 KiB
Elixir

defmodule Microwaveprop.Weather.GefsClientTest do
use ExUnit.Case, async: true
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 "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
end
end