prop/test/microwaveprop/weather/gefs_client_test.exs
Graham McIntire 764643bc62
test: expand coverage and refactor to idiomatic style
Changes under lib/ (source):
- iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate
  + `if/else` with a classifier that pattern-matches the error reason
  directly, returning `:transient | :permanent`, and dispatch via
  `case`. Three-clause head is clearer than the boolean predicate.
- scores_file.extract_points and nexrad_client.extract_box: force `//1`
  step on the row/col comprehensions so an image box whose centre
  lands outside the raster collapses to an empty iteration instead
  of firing Elixir 1.19's ambiguous-range warning. Adds a regression
  test for the right-edge box.

Test coverage added:
- Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top,
  duct_thickness, best_duct_freq, duct_usable_for_band,
  distance_to_front / parallel_to_front stubs, all_features/0).
- NotifyListener handle_info tolerance for malformed payloads +
  unknown messages, plus the PubSub broadcast side effect.
- Viewshed.effective_reach_km across every verdict / diffraction
  tier / ducting-score tier combination.
- SnmpClient parse_af60_output unit conversions + unknown-column
  handling; parse_snmpget_output OID normalisation and junk-line
  tolerance.
- HrrrNativeClient native_level_count, native_variables,
  native_messages shape, duct_messages / duct_byte_ranges.
- Changeset coverage for GeomagneticObservation, SolarFluxObservation,
  SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and
  Metar5minObservation — lifted each from ~50% / 0% to 100%.
- Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T,
  monotonic in RH, finite across the operating envelope) + idempotent
  nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded
  output; latlon_to_pixel round-trip for every grid cell.

Also cleared several unrelated compiler/linter warnings:
- Three private test helpers (create_contact, insert_contact,
  current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used.
- profiles_file_test bound `dir` in a pattern match without using it.

Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage
70.38% → 70.89%; credo strict clean.
2026-04-23 13:28:42 -05:00

284 lines
8.9 KiB
Elixir

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 "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