prop/test/microwaveprop/weather/grib2/section_test.exs
Graham McIntire a7250c39e7
test: pin GRIB2 Section parsing to its full contract
- identify_var: full round-trip across every known (discipline,
  category, number) triple, plus a property that any triple outside
  the 12-entry lookup falls back to the "cat:num" default.
- identify_level: hybrid-level and entire-atmosphere branches, plus
  two properties — pressure levels always render as
  `div(value_pa, 100) <> " mb"` and unknown surface types always
  render as `unknown:<type>:<value>` (generator samples only from
  types outside the 6-entry lookup set).
- sign_magnitude_16: round-trip property over [-32_767, 32_767] (±0
  both decode to 0 as intended) and a monotonicity property inside
  a single sign band.

Suite: 2,419 tests + 164 properties (was 2,416 + 159); credo strict
clean.
2026-04-23 15:56:38 -05:00

497 lines
14 KiB
Elixir

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> 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:<type>:<value>`" 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 = <<sign_bit::1, mag::15>>
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 = <<sign_bit::1, a::15>>
packed_b = <<sign_bit::1, b::15>>
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)
<<length::32-big, section_number::8, body::binary>>
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