defmodule Microwaveprop.Weather.Grib2.SectionTest do use ExUnit.Case, async: true use ExUnitProperties alias Microwaveprop.Weather.Grib2.Section describe "parse_message/1" do test "parses a synthetic GRIB2 message" do msg = build_synthetic_grib2() assert {:ok, parsed} = Section.parse_message(msg) # Grid params from Section 3 assert parsed.grid_params.nx == 1799 assert parsed.grid_params.ny == 1059 assert_in_delta parsed.grid_params.la1, 21.138123, 0.000001 assert_in_delta parsed.grid_params.lo1, 237.280472, 0.000001 assert_in_delta parsed.grid_params.dx, 3000.0, 0.1 assert_in_delta parsed.grid_params.dy, 3000.0, 0.1 assert_in_delta parsed.grid_params.latin1, 38.5, 0.000001 assert_in_delta parsed.grid_params.latin2, 38.5, 0.000001 assert parsed.grid_params.scan_mode == 64 # Product from Section 4 assert parsed.product.var == "TMP" assert parsed.product.level == "2 m above ground" # Packing params from Section 5 assert parsed.packing_params.num_data_points == 100 assert parsed.packing_params.bits_per_value == 16 # Data from Section 7 assert is_binary(parsed.data) # Bitmap indicator assert parsed.bitmap == :none end test "rejects non-GRIB2 binary" do assert {:error, _} = Section.parse_message(<<"NOT_GRIB", 0::64-big>>) end test "rejects GRIB edition 1" do indicator = <<"GRIB", 0::16, 0::8, 1::8, 100::64-big>> assert {:error, _} = Section.parse_message(indicator) end test "parses message with trailing padding bytes instead of 7777" do msg = build_synthetic_grib2_with_trailing_padding() assert {:ok, parsed} = Section.parse_message(msg) assert parsed.product.var == "TMP" assert parsed.bitmap == :none end test "parses message with truncated trailing section" do msg = build_synthetic_grib2_with_truncated_section() assert {:ok, parsed} = Section.parse_message(msg) assert parsed.product.var == "TMP" assert parsed.bitmap == :none end end describe "product identification" do test "identifies all supported variables" do # discipline 0 (meteorological) assert Section.identify_var(0, 0, 0) == "TMP" assert Section.identify_var(0, 0, 6) == "DPT" assert Section.identify_var(0, 3, 0) == "PRES" assert Section.identify_var(0, 3, 5) == "HGT" assert Section.identify_var(0, 3, 18) == "HPBL" assert Section.identify_var(0, 1, 3) == "PWAT" end test "returns category:number for unknown variables" do assert Section.identify_var(0, 99, 99) == "99:99" end test "covers the full set of known variables" do # Pin the complete lookup set so adding a new variable without # a matching clause surfaces as a test failure, not a silent # fallback to "cat:num". known = [ {{0, 0, 0}, "TMP"}, {{0, 0, 6}, "DPT"}, {{0, 1, 0}, "SPFH"}, {{0, 1, 3}, "PWAT"}, {{0, 1, 8}, "APCP"}, {{0, 2, 2}, "UGRD"}, {{0, 2, 3}, "VGRD"}, {{0, 3, 0}, "PRES"}, {{0, 3, 5}, "HGT"}, {{0, 3, 18}, "HPBL"}, {{0, 6, 1}, "TCDC"}, {{0, 19, 11}, "TKE"} ] for {{d, c, n}, name} <- known do assert Section.identify_var(d, c, n) == name end end property "falls back to `cat:num` for every unknown (discipline, cat, num) triple" do known_cat_nums = MapSet.new([{0, 0}, {0, 6}, {1, 0}, {1, 3}, {1, 8}, {2, 2}, {2, 3}, {3, 0}, {3, 5}, {3, 18}, {6, 1}, {19, 11}]) check all( discipline <- integer(0..50), cat <- integer(0..50), num <- integer(0..50), not MapSet.member?(known_cat_nums, {cat, num}) ) do assert Section.identify_var(discipline, cat, num) == "#{cat}:#{num}" end end end describe "level identification" do test "surface" do assert Section.identify_level(1, 0) == "surface" end test "pressure level in mb" do # Stored in Pa, displayed in mb assert Section.identify_level(100, 100_000) == "1000 mb" assert Section.identify_level(100, 85_000) == "850 mb" end test "meters above ground" do assert Section.identify_level(103, 2) == "2 m above ground" assert Section.identify_level(103, 10) == "10 m above ground" end test "entire atmosphere" do assert Section.identify_level(200, 0) == "entire atmosphere (considered as a single layer)" end test "unknown level type" do assert Section.identify_level(255, 0) == "unknown:255:0" end test "entire atmosphere (type 10)" do assert Section.identify_level(10, 0) == "entire atmosphere" end test "hybrid level (type 105) carries the level index through" do assert Section.identify_level(105, 1) == "1 hybrid level" assert Section.identify_level(105, 50) == "50 hybrid level" end property "pressure levels always render as ` mb` where mb = value_pa div 100" do check all(value_pa <- integer(0..110_000)) do assert Section.identify_level(100, value_pa) == "#{div(value_pa, 100)} mb" end end property "unknown surface type always renders as `unknown::`" do # Pre-compute the unknown-type domain so the generator doesn't # fight a 97% rejection rate trying to sample 6 known types out # of 256. known = MapSet.new([1, 10, 100, 103, 105, 200]) unknown_types = Enum.reject(0..255, &MapSet.member?(known, &1)) check all( type <- member_of(unknown_types), value <- integer(0..1_000_000) ) do assert Section.identify_level(type, value) == "unknown:#{type}:#{value}" end end end describe "sign_magnitude_16/1" do test "positive value" do assert Section.sign_magnitude_16(<<0::1, 5::15>>) == 5 end test "negative value" do assert Section.sign_magnitude_16(<<1::1, 5::15>>) == -5 end test "zero" do assert Section.sign_magnitude_16(<<0::16>>) == 0 end property "round-trips any |n| in 0..32_767 back to n through |magnitude| packing" do # The 16-bit field has one sign bit and 15 magnitude bits, so # the representable range is -32_767..32_767. check all(n <- integer(-32_767..32_767)) do mag = abs(n) sign_bit = cond do n < 0 -> 1 n == 0 -> 0 true -> 0 end packed = <> decoded = Section.sign_magnitude_16(packed) # Note: +0 and -0 both decode to 0 (sign bit is irrelevant # when magnitude is zero) — that's the expected contract. if n == 0, do: assert(decoded == 0), else: assert(decoded == n) end end property "magnitude packing is monotonic in the magnitude bits (same sign)" do check all( sign_bit <- integer(0..1), a <- integer(0..32_766), delta <- integer(0..(32_767 - a)) ) do b = a + delta packed_a = <> packed_b = <> if sign_bit == 0 do assert Section.sign_magnitude_16(packed_a) <= Section.sign_magnitude_16(packed_b) else # Negative magnitudes grow more negative as the magnitude bits grow. assert Section.sign_magnitude_16(packed_a) >= Section.sign_magnitude_16(packed_b) end end end end # --- Helpers --- defp build_synthetic_grib2 do # Section 1 (Identification) - minimal sec1_body = << # Originating center, subcenter, etc. 0::16-big, 0::16-big, # Master/local tables 2::8, 1::8, # Significance of reference time 1::8, # Year, month, day, hour, minute, second 2026::16-big, 3::8, 28::8, 18::8, 0::8, 0::8, # Production status, type 0::8, 1::8 >> sec1 = section_wrap(1, sec1_body) # Section 3 (Grid Definition) - Template 3.30 (Lambert Conformal) sec3_body = << # Source of grid definition 0::8, # Number of data points 1_905_141::32-big, # Number of octets for optional list 0::8, # Interpretation 0::8, # Grid Definition Template Number = 30 (Lambert Conformal) 30::16-big, # Shape of earth (6 = spherical, R=6371229) 6::8, # Scale factor/value of radius (not used for shape 6) 0::8, 0::32-big, # Scale factor/value of major axis 0::8, 0::32-big, # Scale factor/value of minor axis 0::8, 0::32-big, # Nx, Ny 1799::32-big, 1059::32-big, # La1 in microdegrees 21_138_123::32-big, # Lo1 in microdegrees 237_280_472::32-big, # Resolution and component flags 0b00111000::8, # LaD in microdegrees 38_500_000::32-big, # LoV in microdegrees 262_500_000::32-big, # Dx in millimetres 3_000_000::32-big, # Dy in millimetres 3_000_000::32-big, # Projection centre flag 0::8, # Scanning mode 64::8, # Latin1 in microdegrees 38_500_000::32-big, # Latin2 in microdegrees 38_500_000::32-big, # Latitude/longitude of southern pole 0::32-big, 0::32-big >> sec3 = section_wrap(3, sec3_body) # Section 4 (Product Definition) - Template 4.0 sec4_body = << # Number of coordinate values 0::16-big, # Product Definition Template Number = 0 0::16-big, # Parameter category (0 = Temperature) 0::8, # Parameter number (0 = Temperature) 0::8, # Type of generating process 2::8, # Background + forecast generating process 0::8, 0::8, # Hours/minutes of observational data cutoff 0::16-big, 0::8, # Indicator of unit of time range (1=hour) 1::8, # Forecast time 0::32-big, # Type of first fixed surface (103 = above ground) 103::8, # Scale factor and scaled value of first fixed surface 0::8, 2::32-big, # Type of second fixed surface (255 = missing) 255::8, 0::8, 0::32-big >> sec4 = section_wrap(4, sec4_body) # Section 5 (Data Representation) - Template 5.0 (Simple packing) # Reference value as IEEE 754 float32 ref_val = <<0.0::float-32-big>> sec5_body = << # Number of data points 100::32-big, # Data Representation Template Number = 0 0::16-big, # Reference value (IEEE 754) ref_val::binary, # Binary scale factor (sign-magnitude 16-bit) 0::16-big, # Decimal scale factor (sign-magnitude 16-bit) 0::16-big, # Number of bits per value 16::8 >> sec5 = section_wrap(5, sec5_body) # Section 6 (Bitmap) - no bitmap sec6_body = <<255::8>> sec6 = section_wrap(6, sec6_body) # Section 7 (Data) - 100 values of 16 bits = 200 bytes data_bytes = :binary.copy(<<42::16-big>>, 100) sec7 = section_wrap(7, data_bytes) # Section 8 (End) sec8 = "7777" body = sec1 <> sec3 <> sec4 <> sec5 <> sec6 <> sec7 <> sec8 total_length = 16 + byte_size(body) # Section 0 (Indicator) sec0 = <<"GRIB", 0::16, 0::8, 2::8, total_length::64-big>> sec0 <> body end defp section_wrap(section_number, body) do length = 5 + byte_size(body) <> end # Like build_synthetic_grib2 but with 3 padding bytes instead of "7777" defp build_synthetic_grib2_with_trailing_padding do body = build_sections_body() <> <<0, 0, 0>> total_length = 16 + byte_size(body) sec0 = <<"GRIB", 0::16, 0::8, 2::8, total_length::64-big>> sec0 <> body end # Like build_synthetic_grib2 but total_length includes extra bytes after "7777" # that look like a truncated section header defp build_synthetic_grib2_with_truncated_section do # All valid sections + "7777" + a truncated next section (length says 500 but only 10 bytes) truncated = <<500::32-big, 4::8, 0, 0, 0, 0, 0>> body = build_sections_body() <> "7777" <> truncated total_length = 16 + byte_size(body) sec0 = <<"GRIB", 0::16, 0::8, 2::8, total_length::64-big>> sec0 <> body end defp build_sections_body do sec1_body = << 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 >> sec3_body = << 0::8, 1_905_141::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, 1799::32-big, 1059::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 >> sec4_body = << 0::16-big, 0::16-big, 0::8, 0::8, 2::8, 0::8, 0::8, 0::16-big, 0::8, 1::8, 0::32-big, 103::8, 0::8, 2::32-big, 255::8, 0::8, 0::32-big >> ref_val = <<0.0::float-32-big>> sec5_body = << 100::32-big, 0::16-big, ref_val::binary, 0::16-big, 0::16-big, 16::8 >> sec6_body = <<255::8>> data_bytes = :binary.copy(<<42::16-big>>, 100) section_wrap(1, sec1_body) <> section_wrap(3, sec3_body) <> section_wrap(4, sec4_body) <> section_wrap(5, sec5_body) <> section_wrap(6, sec6_body) <> section_wrap(7, data_bytes) end end