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
377 lines
13 KiB
Elixir
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
|