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.
123 lines
3.9 KiB
Elixir
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
|