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.
284 lines
8.9 KiB
Elixir
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
|