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