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.
234 lines
8 KiB
Elixir
234 lines
8 KiB
Elixir
defmodule Microwaveprop.Weather.HrrrNativeClientTest do
|
||
use ExUnit.Case, async: true
|
||
|
||
alias Microwaveprop.Weather.HrrrClient
|
||
alias Microwaveprop.Weather.HrrrNativeClient
|
||
|
||
describe "hrrr_native_url/3" do
|
||
test "builds the wrfnatf00 URL for a given date and hour" do
|
||
url = HrrrNativeClient.hrrr_native_url(~D[2026-04-09], 12)
|
||
|
||
assert url ==
|
||
"https://noaa-hrrr-bdp-pds.s3.amazonaws.com/hrrr.20260409/conus/hrrr.t12z.wrfnatf00.grib2"
|
||
end
|
||
|
||
test "pads single-digit hours" do
|
||
url = HrrrNativeClient.hrrr_native_url(~D[2026-04-09], 6)
|
||
assert url =~ "hrrr.t06z."
|
||
end
|
||
|
||
test "supports non-zero forecast hours" do
|
||
url = HrrrNativeClient.hrrr_native_url(~D[2026-04-09], 12, 3)
|
||
assert url =~ "wrfnatf03.grib2"
|
||
end
|
||
end
|
||
|
||
describe "native_messages/0" do
|
||
test "lists 350 messages (7 vars × 50 levels)" do
|
||
messages = HrrrNativeClient.native_messages()
|
||
assert length(messages) == 350
|
||
end
|
||
|
||
test "every level appears with every essential variable" do
|
||
messages = HrrrNativeClient.native_messages()
|
||
|
||
# Pick level 5 — should have all 7 vars
|
||
level5 = Enum.filter(messages, fn %{level: l} -> l == "5 hybrid level" end)
|
||
vars = level5 |> Enum.map(& &1.var) |> Enum.sort()
|
||
|
||
assert vars == ~w(HGT PRES SPFH TKE TMP UGRD VGRD)
|
||
end
|
||
|
||
test "covers levels 1 through 50" do
|
||
levels =
|
||
HrrrNativeClient.native_messages()
|
||
|> Enum.map(& &1.level)
|
||
|> Enum.uniq()
|
||
|> Enum.map(fn level_str ->
|
||
[digits, _] = String.split(level_str, " ", parts: 2)
|
||
String.to_integer(digits)
|
||
end)
|
||
|> Enum.sort()
|
||
|
||
assert levels == Enum.to_list(1..50)
|
||
end
|
||
end
|
||
|
||
describe "build_native_profile/1" do
|
||
test "assembles arrays in level order and caches surface scalars" do
|
||
parsed = %{
|
||
"HGT:1 hybrid level" => 8.0,
|
||
"TMP:1 hybrid level" => 295.0,
|
||
"SPFH:1 hybrid level" => 0.010,
|
||
"PRES:1 hybrid level" => 101_000.0,
|
||
"UGRD:1 hybrid level" => 2.0,
|
||
"VGRD:1 hybrid level" => 1.0,
|
||
"TKE:1 hybrid level" => 0.5,
|
||
"HGT:2 hybrid level" => 25.0,
|
||
"TMP:2 hybrid level" => 293.0,
|
||
"SPFH:2 hybrid level" => 0.008,
|
||
"PRES:2 hybrid level" => 99_800.0,
|
||
"UGRD:2 hybrid level" => 3.0,
|
||
"VGRD:2 hybrid level" => 1.5,
|
||
"TKE:2 hybrid level" => 0.3,
|
||
"TMP:surface" => 295.5,
|
||
"SPFH:2 m above ground" => 0.011,
|
||
"PRES:surface" => 101_325.0
|
||
}
|
||
|
||
profile = HrrrNativeClient.build_native_profile(parsed)
|
||
|
||
assert profile.level_count == 2
|
||
assert profile.heights_m == [8.0, 25.0]
|
||
assert profile.temp_k == [295.0, 293.0]
|
||
assert profile.spfh == [0.010, 0.008]
|
||
assert profile.u_wind_ms == [2.0, 3.0]
|
||
assert profile.surface_temp_k == 295.5
|
||
assert profile.surface_spfh == 0.011
|
||
assert profile.surface_pressure_pa == 101_325.0
|
||
end
|
||
|
||
test "skips levels missing either HGT or TMP" do
|
||
parsed = %{
|
||
"HGT:1 hybrid level" => 8.0,
|
||
"TMP:1 hybrid level" => 295.0,
|
||
# level 2 has HGT but no TMP → skipped
|
||
"HGT:2 hybrid level" => 25.0,
|
||
"SPFH:2 hybrid level" => 0.008
|
||
}
|
||
|
||
profile = HrrrNativeClient.build_native_profile(parsed)
|
||
assert profile.level_count == 1
|
||
assert profile.heights_m == [8.0]
|
||
end
|
||
|
||
test "orders levels by ascending height, not by hybrid level number" do
|
||
# Pathological: level 2 happens to be lower than level 1 (terrain).
|
||
parsed = %{
|
||
"HGT:1 hybrid level" => 50.0,
|
||
"TMP:1 hybrid level" => 290.0,
|
||
"HGT:2 hybrid level" => 30.0,
|
||
"TMP:2 hybrid level" => 292.0
|
||
}
|
||
|
||
profile = HrrrNativeClient.build_native_profile(parsed)
|
||
assert profile.heights_m == [30.0, 50.0]
|
||
assert profile.temp_k == [292.0, 290.0]
|
||
end
|
||
|
||
test "falls back to lowest level for surface scalars when parsed has no surface entries" do
|
||
parsed = %{
|
||
"HGT:1 hybrid level" => 8.0,
|
||
"TMP:1 hybrid level" => 295.0,
|
||
"SPFH:1 hybrid level" => 0.010,
|
||
"PRES:1 hybrid level" => 101_000.0
|
||
}
|
||
|
||
profile = HrrrNativeClient.build_native_profile(parsed)
|
||
assert profile.surface_temp_k == 295.0
|
||
assert profile.surface_spfh == 0.010
|
||
assert profile.surface_pressure_pa == 101_000.0
|
||
end
|
||
end
|
||
|
||
describe "essential_byte_ranges/1" do
|
||
test "produces one range per essential message present in the idx" do
|
||
# Minimal fake idx: level 1 TMP/SPFH/HGT with nothing else.
|
||
idx_entries = [
|
||
%{msg: 1, offset: 0, var: "TMP", level: "1 hybrid level"},
|
||
%{msg: 2, offset: 1000, var: "SPFH", level: "1 hybrid level"},
|
||
%{msg: 3, offset: 2000, var: "HGT", level: "1 hybrid level"},
|
||
%{msg: 4, offset: 3000, var: "REFC", level: "entire atmosphere"}
|
||
]
|
||
|
||
ranges = HrrrNativeClient.essential_byte_ranges(idx_entries)
|
||
|
||
# Expect 3 ranges (the three essentials we actually have)
|
||
assert length(ranges) == 3
|
||
# Each range is {start, end} tuples, all integers
|
||
Enum.each(ranges, fn {s, e} ->
|
||
assert is_integer(s) and is_integer(e)
|
||
assert s < e
|
||
end)
|
||
end
|
||
|
||
test "delegates to HrrrClient.byte_ranges_for_messages/2" do
|
||
# Just check the two stay in sync by computing the same ranges
|
||
# two different ways.
|
||
idx_entries = [
|
||
%{msg: 1, offset: 0, var: "TMP", level: "1 hybrid level"},
|
||
%{msg: 2, offset: 5000, var: "UGRD", level: "1 hybrid level"}
|
||
]
|
||
|
||
assert HrrrNativeClient.essential_byte_ranges(idx_entries) ==
|
||
HrrrClient.byte_ranges_for_messages(idx_entries, HrrrNativeClient.native_messages())
|
||
end
|
||
end
|
||
|
||
describe "native_level_count/0 and native_variables/0" do
|
||
test "native_level_count is 50 (full hybrid stack)" do
|
||
assert HrrrNativeClient.native_level_count() == 50
|
||
end
|
||
|
||
test "native_variables lists exactly the 7 native-grid fields" do
|
||
vars = HrrrNativeClient.native_variables()
|
||
assert length(vars) == 7
|
||
assert "TMP" in vars
|
||
assert "SPFH" in vars
|
||
assert "HGT" in vars
|
||
assert "PRES" in vars
|
||
assert "UGRD" in vars
|
||
assert "VGRD" in vars
|
||
assert "TKE" in vars
|
||
end
|
||
end
|
||
|
||
describe "native_messages/0 shape" do
|
||
test "emits one message per (level, variable) across the 50-level hybrid stack" do
|
||
messages = HrrrNativeClient.native_messages()
|
||
expected = HrrrNativeClient.native_level_count() * length(HrrrNativeClient.native_variables())
|
||
assert length(messages) == expected
|
||
|
||
# Every message carries a non-empty var and a hybrid-level string.
|
||
Enum.each(messages, fn %{var: var, level: level} ->
|
||
assert var in HrrrNativeClient.native_variables()
|
||
assert level =~ ~r/\A\d+ hybrid level\z/
|
||
end)
|
||
end
|
||
|
||
test "each (var, level) pair is unique" do
|
||
messages = HrrrNativeClient.native_messages()
|
||
unique = messages |> Enum.uniq_by(fn %{var: v, level: l} -> {v, l} end) |> length()
|
||
assert unique == length(messages)
|
||
end
|
||
end
|
||
|
||
describe "duct_messages/0 and duct_byte_ranges/1" do
|
||
test "emits one message per (level, duct-var) across the 50-level stack (4 vars)" do
|
||
messages = HrrrNativeClient.duct_messages()
|
||
assert length(messages) == 50 * 4
|
||
|
||
# Exactly the duct-detection variables — no UGRD/VGRD/TKE.
|
||
vars = messages |> Enum.map(& &1.var) |> Enum.uniq() |> Enum.sort()
|
||
assert vars == ["HGT", "PRES", "SPFH", "TMP"]
|
||
end
|
||
|
||
test "duct_byte_ranges/1 returns integer ranges for matching idx entries" do
|
||
idx_entries = [
|
||
%{msg: 1, offset: 0, var: "TMP", level: "1 hybrid level"},
|
||
%{msg: 2, offset: 1000, var: "SPFH", level: "1 hybrid level"},
|
||
%{msg: 3, offset: 2000, var: "UGRD", level: "1 hybrid level"},
|
||
%{msg: 4, offset: 3000, var: "HGT", level: "1 hybrid level"}
|
||
]
|
||
|
||
ranges = HrrrNativeClient.duct_byte_ranges(idx_entries)
|
||
# UGRD is not in the duct set, so only 3 ranges should come back.
|
||
assert length(ranges) == 3
|
||
|
||
Enum.each(ranges, fn {s, e} ->
|
||
assert is_integer(s) and is_integer(e)
|
||
assert s < e
|
||
end)
|
||
end
|
||
end
|
||
end
|