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, "not idx") 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 end