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