- 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.
497 lines
14 KiB
Elixir
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
|