Wires the GEFS fetch pipeline into the existing scoring machinery: - GefsFetchWorker runs Propagation.score_grid_point over every fetched grid cell and persists the result via replace_scores/2, so Day 2-7 valid_times show up on the map through the same Propagation.scores_at/3 path HRRR uses - Empty-args perform/1 seeds the chain by enqueueing f024-f168 (25 jobs at 6-hour cadence) for the most recent GEFS run that should be published given NOMADS' ~3-4h publication lag - Cron: seeds at 05:30, 11:30, 17:30, 23:30 UTC — 5 hours after each 00/06/12/18Z run - GefsClient.build_profile now emits :wind_u / :wind_v atom keys to match the scorer's input shape; DB columns keep the *_mps suffix for unit clarity, remapped at write time GEFS pgrb2a lacks HPBL, native-level duct data, NEXRAD, and commercial-link degradation — the extended-horizon score is a rougher signal than the HRRR-driven one but covers the window HRRR can't reach.
207 lines
6.4 KiB
Elixir
207 lines
6.4 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 "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
|
|
end
|
|
end
|