test: cover weather/grib2/wgrib2

16 characterization tests against real wgrib2 binary + checked-in HRRR
fixtures: extract_grid/3 (bbox + physical sanity on TMP/DPT + longitude
convention), extract_grid_from_file/3, extract_grid_from_file_mapped/4
reducer plumbing, extract_grid_messages/3 metadata, extract_points_from_file/3
nearest-neighbor snap, undefined-value sentinel filtering, empty-match and
error shapes. Tagged @describetag :slow so they run on 'mix test --include
slow' (project default excludes slow, matches existing HRRR/NARR tests).

Surfaces one real bug (not fixed): extract_grid_messages variants don't
account for the 8-byte Fortran record overhead between messages in wgrib2
-lola output, so per-message values after the first are shifted. The
correct offset math exists in parse_lola_binary/3. Tests characterize
current behavior (metadata-only); worth a follow-up fix + value-range
assertions.
This commit is contained in:
Graham McIntire 2026-04-16 14:46:02 -05:00
parent cc480652aa
commit 8c3c097009
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -0,0 +1,342 @@
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
# NOTE: This test characterizes CURRENT (BUGGY) behavior. See moduledoc
# above for the details. The metadata (var/level/datetime) is correct,
# but the per-cell values are corrupted because
# `build_messages_per_message` does not account for the 8-byte Fortran
# record overhead used by wgrib2's `-lola ... bin` output format.
# The sibling function `parse_lola_binary` does account for it
# (`record_overhead = 8` and `data_offset = msg_idx * stride + 4`),
# and the matching `extract_grid/3` assertions above confirm correct
# values for the same fixture + grid. Fixing this is out of scope for
# characterization — flagged in the report to the caller.
test "returns one entry per matched message with metadata (values currently corrupted by record-overhead bug)" do
fixture = File.read!(@multi_fixture)
{:ok, messages} = Wgrib2.extract_grid_messages(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"]
for msg <- messages do
assert msg.level == "2 m above ground"
assert %DateTime{} = msg.datetime
# parse_wgrib2_date always truncates to :second.
assert msg.datetime == DateTime.truncate(msg.datetime, :second)
assert msg.datetime.time_zone == "Etc/UTC"
assert is_map(msg.values)
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)
end
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 produce the same (currently buggy — see note above) output.
# This test just asserts that the two code paths agree with each other.
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
end