prop/test/microwaveprop/weather/grib2/wgrib2_test.exs
Graham McIntire 6aa91e7656
fix: April 2026 codebase review — address 13 bugs across propagation chain
Each fix is covered by a regression test that fails on `main` and
passes on this commit.

Round 1 (initial review):

* propagation: thread `latitude` into the conditions map so
  `score_season/4` actually picks up regional multipliers
* hrrr_client / fetcher.rs: `nearest_hrrr_hour` rounds DOWN, never at
  a future cycle that NOAA hasn't published yet
* radio: spherical-vector great-circle midpoint replaces the
  arithmetic mean — anti-meridian paths no longer fold to Greenwich
* weather: `reconcile_weather_statuses` scales the longitude band by
  `1 / cos(lat)` so the bbox stays ~150 km wide at every latitude
* radio/maidenhead: clamp 90°/180° below the field-bucket overflow so
  `from_latlon` never emits invalid characters like 'S'
* prop_grid_rs/pipeline: merge HRRR + NEXRAD-derived rain rates and
  read `best_duct_freq_ghz` into `best_duct_band_ghz` so the Native
  Duct Boost actually fires
* propagation/region (Elixir + Rust): inclusive upper bounds so points
  exactly at lat_max get the regional multiplier
* weather/sounding_params (Elixir + Rust): drop the 10 m gradient
  floor so HRRR's thin near-surface layers stop hiding sharp ducts
* weather/sounding_params: when the profile ends inside a duct,
  finalize it with the highest sample as the top instead of throwing
  it away (Rust port already correct)

Round 2 (post-fix sweep):

* radio + commercial: single canonical haversine in Radio (atan2
  form); Commercial delegates instead of carrying a second copy that
  could disagree at threshold distances
* prop_grid_rs/profiles_file: `snap_coords` matches Elixir's
  step-aware snap (`round(coord/0.125) * 0.125`, then 3-dp round) so
  Rust-keyed and Elixir-keyed profile maps land on the same cell
* weather/grib2/wgrib2: `parse_lon_val_segment` uses `Float.parse`
  uniformly — wgrib2 dropping the trailing `.0` from a longitude no
  longer crashes the whole chain step
2026-04-25 10:52:51 -05:00

377 lines
13 KiB
Elixir

defmodule Microwaveprop.Weather.Grib2.Wgrib2Test do
@moduledoc """
Characterization tests for `Microwaveprop.Weather.Grib2.Wgrib2`.
These exercise the real `wgrib2` binary against checked-in HRRR GRIB2
fixtures. Tests that require the binary will check `available?()` and
fall back to a documented assertion if it's missing — the dev and CI
environments are expected to have `wgrib2` installed.
Covered:
* Public entry points: `extract_grid/3`, `extract_grid_from_file/3`,
`extract_grid_from_file_mapped/4`, `extract_grid_messages/3`,
`extract_grid_messages_from_file/3`, `extract_points_from_file/3`.
* Round-trip of longitude between -180..180 input and output.
* Reducer callback plumbing in the `_mapped` variant.
* Undefined-value filtering (9.999e20 sentinel) at the edge of the
HRRR CONUS mask.
* Date parsing via the inventory (DateTime truncation + UTC tag).
* Error shape when wgrib2 exits non-zero (invalid input file).
* Empty-match and empty-points corner cases.
Deferred (intentional):
* Stubbing `System.cmd/3` output strings — the module calls the
binary directly with no injection seam. The parser helpers
(`parse_wgrib2_inventory`, `parse_wgrib2_date_digits`,
`parse_lon_val_segment`) are covered indirectly via the real
binary round-trip. Mocking would require introducing DI we don't
want as part of characterization.
* `{:error, :wgrib2_not_available}` branch — `available?/0` is
computed from `System.find_executable/1` with no seam. With
wgrib2 present, we document the branch in the moduledoc and
cover the helper returning `true`; exercising the `false` arm
would require mocking `System.find_executable/1`.
* `{:error, :enoent}` -> `{:ok, %{}}` branch in `extract_file_*`
(requires wgrib2 to succeed yet produce no output file — never
seen in practice and wgrib2's behavior on empty matches is the
same whether run on binary or file).
"""
use ExUnit.Case, async: true
alias Microwaveprop.Weather.Grib2.Wgrib2
@single_fixture "test/fixtures/grib2/hrrr_tmp_2m.grib2"
@multi_fixture "test/fixtures/grib2/hrrr_multi.grib2"
# Small bbox around Dallas, TX that we know is inside the HRRR domain.
@dallas_grid %{
lon_start: -96.8,
lon_count: 2,
lon_step: 0.1,
lat_start: 32.78,
lat_count: 2,
lat_step: 0.1
}
describe "available?/0" do
test "returns a boolean" do
assert is_boolean(Wgrib2.available?())
end
test "reflects System.find_executable/1 result" do
expected = System.find_executable("wgrib2") != nil
assert Wgrib2.available?() == expected
end
end
describe "extract_grid/3 — real HRRR fixture" do
@describetag :slow
@describetag timeout: 60_000
test "extracts TMP/DPT values for every grid cell in bbox" do
fixture = File.read!(@multi_fixture)
assert {:ok, result} = Wgrib2.extract_grid(fixture, ":(TMP|DPT):", @dallas_grid)
# 2x2 grid of points — all should resolve inside HRRR CONUS.
assert map_size(result) == 4
for {{lat, lon}, cell} <- result do
assert lat in [32.78, 32.88]
assert lon in [-96.8, -96.7]
assert Map.has_key?(cell, "TMP:2 m above ground")
assert Map.has_key?(cell, "DPT:2 m above ground")
tmp = cell["TMP:2 m above ground"]
dpt = cell["DPT:2 m above ground"]
assert tmp > 200.0 and tmp < 340.0
assert dpt > 200.0 and dpt < 340.0
# Dew point must be <= dry-bulb temp physically.
assert dpt <= tmp
end
end
test "returns coordinates in -180..180 longitude convention" do
fixture = File.read!(@single_fixture)
{:ok, result} = Wgrib2.extract_grid(fixture, ":TMP:", @dallas_grid)
for {{_lat, lon}, _cell} <- result do
# Input was negative — denormalize_lon must round-trip to negative.
assert lon < 0
assert lon > -180
end
end
test "non-matching regex yields empty map" do
fixture = File.read!(@single_fixture)
# wgrib2 writes no output file when no messages match, which the
# module handles as {:ok, %{}}.
assert {:ok, result} = Wgrib2.extract_grid(fixture, ":NOPE_NO_SUCH_VAR:", @dallas_grid)
assert result == %{}
end
end
describe "extract_grid_from_file/3 — real HRRR fixture" do
@describetag :slow
@describetag timeout: 60_000
test "produces the same result as extract_grid/3 for the same inputs" do
fixture = File.read!(@multi_fixture)
{:ok, from_bin} = Wgrib2.extract_grid(fixture, ":(TMP|DPT):", @dallas_grid)
{:ok, from_file} = Wgrib2.extract_grid_from_file(@multi_fixture, ":(TMP|DPT):", @dallas_grid)
assert from_bin |> Map.keys() |> Enum.sort() ==
from_file |> Map.keys() |> Enum.sort()
for point <- Map.keys(from_bin) do
for {key, v_bin} <- from_bin[point] do
assert_in_delta from_file[point][key], v_bin, 0.001
end
end
end
test "returns error tuple for a nonexistent file" do
assert {:error, msg} =
Wgrib2.extract_grid_from_file(
"/tmp/definitely-not-a-grib2-file.grib2",
":TMP:",
@dallas_grid
)
assert is_binary(msg)
assert msg =~ "wgrib2 failed"
end
end
describe "extract_grid_from_file_mapped/4 — real HRRR fixture" do
@describetag :slow
@describetag timeout: 60_000
test "reducer sees per-cell map and output is keyed by {lat,lon}" do
# Reducer extracts just the TMP value as a scalar.
reducer = fn cell -> Map.get(cell, "TMP:2 m above ground") end
{:ok, result} =
Wgrib2.extract_grid_from_file_mapped(
@multi_fixture,
":(TMP|DPT):",
@dallas_grid,
reducer
)
assert map_size(result) == 4
for {{lat, lon}, scalar} <- result do
assert lat in [32.78, 32.88]
assert lon in [-96.8, -96.7]
assert is_float(scalar)
assert scalar > 200.0 and scalar < 340.0
end
end
test "reducer return value flows through unchanged to output map" do
reducer = fn _cell -> :reduced end
{:ok, result} =
Wgrib2.extract_grid_from_file_mapped(@multi_fixture, ":TMP:", @dallas_grid, reducer)
assert map_size(result) == 4
assert Enum.all?(Map.values(result), &(&1 == :reduced))
end
end
describe "extract_grid_messages/3 — real HRRR fixture" do
@describetag :slow
@describetag timeout: 60_000
test "returns one entry per matched message with real physical values" do
fixture = File.read!(@multi_fixture)
{:ok, messages} = Wgrib2.extract_grid_messages(fixture, ":(TMP|DPT):", @dallas_grid)
{:ok, grid} = Wgrib2.extract_grid(fixture, ":(TMP|DPT):", @dallas_grid)
# hrrr_multi.grib2 contains exactly TMP:2m and DPT:2m.
assert length(messages) == 2
vars = messages |> Enum.map(& &1.var) |> Enum.sort()
assert vars == ["DPT", "TMP"]
# Physical-value assertions per message — verifies the Fortran record
# overhead (8 bytes/message) is accounted for in the parser.
tmp_msg = Enum.find(messages, &(&1.var == "TMP"))
dpt_msg = Enum.find(messages, &(&1.var == "DPT"))
for msg <- messages do
assert msg.level == "2 m above ground"
assert %DateTime{} = msg.datetime
assert msg.datetime == DateTime.truncate(msg.datetime, :second)
assert msg.datetime.time_zone == "Etc/UTC"
assert is_map(msg.values)
assert map_size(msg.values) == 4
for {{lat, lon}, v} <- msg.values do
assert lat in [32.78, 32.88]
assert lon in [-96.8, -96.7]
assert is_float(v)
assert v > 200.0 and v < 340.0, "#{msg.var} value #{v} outside physical range"
end
end
# Dew point must be <= dry-bulb temp per point.
for {point, tmp} <- tmp_msg.values do
dpt = Map.fetch!(dpt_msg.values, point)
assert dpt <= tmp, "DPT #{dpt} > TMP #{tmp} at #{inspect(point)}"
end
# Cross-check: same inputs as extract_grid/3 — values must agree within 0.01 K.
for {point, tmp} <- tmp_msg.values do
assert_in_delta grid[point]["TMP:2 m above ground"], tmp, 0.01
end
for {point, dpt} <- dpt_msg.values do
assert_in_delta grid[point]["DPT:2 m above ground"], dpt, 0.01
end
end
end
describe "extract_grid_messages_from_file/3 — real HRRR fixture" do
@describetag :slow
@describetag timeout: 60_000
# Both the binary and file variants funnel into `build_messages_per_message`,
# so they must produce identical output for identical inputs.
test "mirrors extract_grid_messages/3 on the same data" do
fixture = File.read!(@multi_fixture)
{:ok, from_bin} = Wgrib2.extract_grid_messages(fixture, ":(TMP|DPT):", @dallas_grid)
{:ok, from_file} =
Wgrib2.extract_grid_messages_from_file(@multi_fixture, ":(TMP|DPT):", @dallas_grid)
assert length(from_bin) == length(from_file)
sorted_bin = Enum.sort_by(from_bin, & &1.var)
sorted_file = Enum.sort_by(from_file, & &1.var)
for {a, b} <- Enum.zip(sorted_bin, sorted_file) do
assert a.var == b.var
assert a.level == b.level
assert a.datetime == b.datetime
assert a.values |> Map.keys() |> Enum.sort() ==
b.values |> Map.keys() |> Enum.sort()
for {point, v} <- a.values do
assert_in_delta b.values[point], v, 0.001
end
end
end
end
describe "extract_points_from_file/3 — real HRRR fixture" do
@describetag :slow
@describetag timeout: 60_000
test "extracts values at requested points, snapping wgrib2 nearest coords back" do
points = [{32.78, -96.8}, {40.71, -74.01}]
{:ok, result} = Wgrib2.extract_points_from_file(@multi_fixture, ":(TMP|DPT):", points)
for point <- points do
assert Map.has_key?(result, point), "Missing point #{inspect(point)}"
assert Map.has_key?(result[point], "TMP:2 m above ground")
assert Map.has_key?(result[point], "DPT:2 m above ground")
tmp = result[point]["TMP:2 m above ground"]
assert tmp > 200.0 and tmp < 340.0
end
end
test "accepts an empty point list" do
assert {:ok, result} = Wgrib2.extract_points_from_file(@single_fixture, ":TMP:", [])
assert result == %{}
end
test "returns error for a nonexistent file" do
assert {:error, msg} =
Wgrib2.extract_points_from_file(
"/tmp/definitely-not-a-grib2-file.grib2",
":TMP:",
[{32.78, -96.8}]
)
assert is_binary(msg)
assert msg =~ "wgrib2 failed"
end
end
describe "bbox edge cases" do
@describetag :slow
@describetag timeout: 60_000
test "1x1 grid returns a single point" do
spec = %{
lon_start: -96.8,
lon_count: 1,
lon_step: 0.1,
lat_start: 32.78,
lat_count: 1,
lat_step: 0.1
}
fixture = File.read!(@single_fixture)
{:ok, result} = Wgrib2.extract_grid(fixture, ":TMP:", spec)
assert map_size(result) == 1
[{lat, lon}] = Map.keys(result)
assert_in_delta lat, 32.78, 0.001
assert_in_delta lon, -96.8, 0.001
end
test "undefined cells (HRRR mask) do not leak the 9.999e20 sentinel" do
# Middle of the Pacific, outside HRRR CONUS.
spec = %{
lon_start: -170.0,
lon_count: 2,
lon_step: 0.1,
lat_start: 20.0,
lat_count: 2,
lat_step: 0.1
}
fixture = File.read!(@single_fixture)
{:ok, result} = Wgrib2.extract_grid(fixture, ":TMP:", spec)
for {_point, cell} <- result do
for {_key, v} <- cell do
assert v < 1.0e10, "sentinel leaked: #{v}"
end
end
end
end
describe "parse_lon_val_segment/1" do
test "parses a typical decimal-formatted segment" do
assert {32.938, lon, 306.5} =
Wgrib2.parse_lon_val_segment("lon=242.958,lat=32.938,val=306.5")
# `denormalize_lon` flips the 0..360 wgrib2 form into -180..180.
assert_in_delta lon, -117.042, 0.001
end
test "tolerates a longitude string with no decimal point" do
# wgrib2 typically formats `lon=242.000` but a build that drops the
# trailing `.0` would crash `String.to_float/1` and bring the entire
# propagation chain step down. The parser should accept either form.
assert {32.938, lon, 306.5} =
Wgrib2.parse_lon_val_segment("lon=243,lat=32.938,val=306.5")
assert_in_delta lon, -117.0, 0.001
end
test "returns nil for unparseable segments" do
assert Wgrib2.parse_lon_val_segment("d=2024092219") == nil
end
end
end