prop/test/microwaveprop/weather/grib2/extractor_test.exs

377 lines
11 KiB
Elixir

defmodule Microwaveprop.Weather.Grib2.ExtractorTest do
use ExUnit.Case, async: true
alias Microwaveprop.Weather.Grib2.Extractor
# HRRR grid parameters for building synthetic messages
@hrrr_nx 1799
@hrrr_ny 1059
describe "split_messages/1" do
test "splits concatenated GRIB2 messages" do
msg1 = build_synthetic_grib2_message(0, 0, 0, 103, 2)
msg2 = build_synthetic_grib2_message(0, 0, 6, 103, 2)
blob = msg1 <> msg2
messages = Extractor.split_messages(blob)
assert length(messages) == 2
assert Enum.at(messages, 0) == msg1
assert Enum.at(messages, 1) == msg2
end
test "handles single message" do
msg = build_synthetic_grib2_message(0, 0, 0, 103, 2)
messages = Extractor.split_messages(msg)
assert length(messages) == 1
end
test "handles empty binary" do
assert Extractor.split_messages(<<>>) == []
end
end
describe "extract_points/3" do
test "extracts single message at first grid point" do
msg = build_synthetic_grib2_message(0, 0, 0, 103, 2)
# la1/lo1 = first grid point, so index (0,0) → linear index 0
assert {:ok, result} = Extractor.extract_points(msg, 21.138123, 237.280472)
assert Map.has_key?(result, "TMP:2 m above ground")
assert is_float(result["TMP:2 m above ground"])
end
test "extracts multiple concatenated messages" do
msg1 = build_synthetic_grib2_message(0, 0, 0, 103, 2)
msg2 = build_synthetic_grib2_message(0, 0, 6, 103, 2)
msg3 = build_synthetic_grib2_message(0, 3, 0, 1, 0)
blob = msg1 <> msg2 <> msg3
assert {:ok, result} = Extractor.extract_points(blob, 21.138123, 237.280472)
assert Map.has_key?(result, "TMP:2 m above ground")
assert Map.has_key?(result, "DPT:2 m above ground")
assert Map.has_key?(result, "PRES:surface")
assert map_size(result) == 3
end
test "handles negative longitude" do
msg = build_synthetic_grib2_message(0, 0, 0, 103, 2)
# -122.719528 == 237.280472
assert {:ok, result} = Extractor.extract_points(msg, 21.138123, -122.719528)
assert Map.has_key?(result, "TMP:2 m above ground")
end
test "returns error for point outside grid" do
msg = build_synthetic_grib2_message(0, 0, 0, 103, 2)
# London — way outside HRRR domain
assert {:error, _} = Extractor.extract_points(msg, 51.5, -0.12)
end
end
describe "linear_index/3" do
test "scan mode 64 (j positive, i positive, i consecutive)" do
nx = 100
scan_mode = 64
assert Extractor.linear_index({5, 10}, nx, scan_mode) == 10 * 100 + 5
end
test "scan mode 0 (j negative, i positive, i consecutive)" do
# j decreasing means row 0 is top, so index = (ny - 1 - j) * nx + i
# But we just return j * nx + i for now since we don't flip
nx = 100
scan_mode = 0
assert Extractor.linear_index({5, 10}, nx, scan_mode) == 10 * 100 + 5
end
end
describe "extract_grid/2" do
test "extracts values for multiple points from a single message" do
msg = build_synthetic_grib2_message(0, 0, 0, 103, 2)
# la1/lo1 is the first grid point at (21.138123, 237.280472)
# Use the same point twice to verify both get values
points = [{21.138123, -122.719528}, {21.138123, -122.719528}]
assert {:ok, result} = Extractor.extract_grid(msg, points)
# Both points are the same, so we should get 1 unique key
assert map_size(result) == 1
assert Map.has_key?(result, {21.138123, -122.719528})
assert Map.has_key?(result[{21.138123, -122.719528}], "TMP:2 m above ground")
end
test "extracts from concatenated messages for multiple points" do
msg1 = build_synthetic_grib2_message(0, 0, 0, 103, 2)
msg2 = build_synthetic_grib2_message(0, 0, 6, 103, 2)
blob = msg1 <> msg2
points = [{21.138123, -122.719528}]
assert {:ok, result} = Extractor.extract_grid(blob, points)
point_data = result[{21.138123, -122.719528}]
assert Map.has_key?(point_data, "TMP:2 m above ground")
assert Map.has_key?(point_data, "DPT:2 m above ground")
end
test "skips points outside the grid" do
msg = build_synthetic_grib2_message(0, 0, 0, 103, 2)
# Mix valid and invalid points
points = [
{21.138123, -122.719528},
{51.5, -0.12}
]
assert {:ok, result} = Extractor.extract_grid(msg, points)
# Only the valid point should be in the result
assert Map.has_key?(result, {21.138123, -122.719528})
refute Map.has_key?(result, {51.5, -0.12})
end
test "returns empty map for empty points list" do
msg = build_synthetic_grib2_message(0, 0, 0, 103, 2)
assert {:ok, result} = Extractor.extract_grid(msg, [])
assert result == %{}
end
test "handles all points outside grid" do
msg = build_synthetic_grib2_message(0, 0, 0, 103, 2)
points = [{51.5, -0.12}, {48.8, 2.3}]
assert {:ok, result} = Extractor.extract_grid(msg, points)
assert result == %{}
end
end
describe "extract_grid/2 with real HRRR fixture" do
@fixture_path "test/fixtures/grib2/hrrr_tmp_2m.grib2"
@multi_fixture_path "test/fixtures/grib2/hrrr_multi.grib2"
@tag :slow
@tag timeout: 300_000
test "extracts grid values matching extract_points for same location" do
if File.exists?(@fixture_path) do
fixture = File.read!(@fixture_path)
lat = 32.78
lon = -96.8
{:ok, single_result} = Extractor.extract_points(fixture, lat, lon)
{:ok, grid_result} = Extractor.extract_grid(fixture, [{lat, lon}])
point_data = grid_result[{lat, lon}]
for {key, value} <- single_result do
assert_in_delta point_data[key], value, 0.001, "#{key}: grid #{point_data[key]} != single #{value}"
end
end
end
@tag :slow
@tag timeout: 300_000
test "extracts grid values for multiple US cities" do
if File.exists?(@fixture_path) do
fixture = File.read!(@fixture_path)
points = [
{32.78, -96.8},
{40.71, -74.01},
{34.05, -118.24}
]
{:ok, result} = Extractor.extract_grid(fixture, 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")
temp_k = result[point]["TMP:2 m above ground"]
temp_c = temp_k - 273.15
assert temp_c > -30.0 and temp_c < 50.0,
"#{inspect(point)} temp #{temp_c}C unreasonable"
end
end
end
end
describe "extract_points/3 with real HRRR fixture" do
@fixture_path "test/fixtures/grib2/hrrr_tmp_2m.grib2"
@multi_fixture_path "test/fixtures/grib2/hrrr_multi.grib2"
@tag :slow
@tag timeout: 120_000
test "extracts TMP:2m from real single-message GRIB2" do
if File.exists?(@fixture_path) do
fixture = File.read!(@fixture_path)
assert {:ok, result} = Extractor.extract_points(fixture, 32.78, -96.8)
assert Map.has_key?(result, "TMP:2 m above ground")
temp_k = result["TMP:2 m above ground"]
temp_c = temp_k - 273.15
assert temp_c > -10.0 and temp_c < 45.0, "Dallas temp #{temp_c}C unreasonable"
end
end
@tag :slow
@tag timeout: 300_000
test "extracts from multi-message GRIB2" do
if File.exists?(@multi_fixture_path) do
fixture = File.read!(@multi_fixture_path)
assert {:ok, result} = Extractor.extract_points(fixture, 32.78, -96.8)
assert Map.has_key?(result, "TMP:2 m above ground")
assert Map.has_key?(result, "DPT:2 m above ground")
assert map_size(result) == 2
end
end
@tag :slow
@tag timeout: 300_000
test "DPT values are meteorologically reasonable (not corrupted by bit padding)" do
if File.exists?(@multi_fixture_path) do
fixture = File.read!(@multi_fixture_path)
assert {:ok, result} = Extractor.extract_points(fixture, 32.78, -96.8)
tmp_k = result["TMP:2 m above ground"]
dpt_k = result["DPT:2 m above ground"]
assert tmp_k > 200.0 and tmp_k < 340.0,
"TMP #{tmp_k}K unreasonable"
assert dpt_k > 200.0 and dpt_k < 340.0,
"DPT #{dpt_k}K unreasonable — likely bit-padding corruption"
end
end
end
# --- Synthetic GRIB2 builder ---
defp build_synthetic_grib2_message(discipline, param_category, param_number, surface_type, surface_value) do
num_points = @hrrr_nx * @hrrr_ny
sec1 = section_wrap(1, build_section1())
sec3 =
section_wrap(3, build_section3(@hrrr_nx, @hrrr_ny))
sec4 =
section_wrap(
4,
build_section4(param_category, param_number, surface_type, surface_value)
)
sec5 = section_wrap(5, build_section5(num_points, 16))
sec6 = section_wrap(6, <<255::8>>)
# Data: num_points * 2 bytes (16-bit values), all set to 42
data_bytes = :binary.copy(<<42::16-big>>, num_points)
sec7 = section_wrap(7, data_bytes)
sec8 = "7777"
body = sec1 <> sec3 <> sec4 <> sec5 <> sec6 <> sec7 <> sec8
total_length = 16 + byte_size(body)
sec0 = <<"GRIB", 0::16, discipline::8, 2::8, total_length::64-big>>
sec0 <> body
end
defp build_section1 do
<<
0::16-big,
0::16-big,
2::8,
1::8,
1::8,
2026::16-big,
3::8,
28::8,
18::8,
0::8,
0::8,
0::8,
1::8
>>
end
defp build_section3(nx, ny) do
<<
0::8,
nx * ny::32-big,
0::8,
0::8,
30::16-big,
6::8,
0::8,
0::32-big,
0::8,
0::32-big,
0::8,
0::32-big,
nx::32-big,
ny::32-big,
21_138_123::32-big,
237_280_472::32-big,
0b00111000::8,
38_500_000::32-big,
262_500_000::32-big,
3_000_000::32-big,
3_000_000::32-big,
0::8,
64::8,
38_500_000::32-big,
38_500_000::32-big,
0::32-big,
0::32-big
>>
end
defp build_section4(param_category, param_number, surface_type, surface_value) do
<<
0::16-big,
0::16-big,
param_category::8,
param_number::8,
2::8,
0::8,
0::8,
0::16-big,
0::8,
1::8,
0::32-big,
surface_type::8,
0::8,
surface_value::32-big,
255::8,
0::8,
0::32-big
>>
end
defp build_section5(num_points, bits_per_value) do
<<
num_points::32-big,
0::16-big,
0.0::float-32-big,
0::16-big,
0::16-big,
bits_per_value::8
>>
end
defp section_wrap(section_number, body) do
length = 5 + byte_size(body)
<<length::32-big, section_number::8, body::binary>>
end
end