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) <> end end