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:
parent
cc480652aa
commit
8c3c097009
1 changed files with 342 additions and 0 deletions
342
test/microwaveprop/weather/grib2/wgrib2_test.exs
Normal file
342
test/microwaveprop/weather/grib2/wgrib2_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue