prop/test/microwaveprop/weather/hrrr_native_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

234 lines
8 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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