prop/lib/microwaveprop/weather/grib2/section.ex
Graham McIntire 5c5d398966
Make GRIB2 section parser resilient to trailing padding and truncation
Instead of returning "malformed section" on trailing bytes or truncated
sections, attempt to return parsed results. This handles older HRRR
files (2019) that have padding bytes after the last section.
2026-03-30 09:21:27 -05:00

265 lines
7.1 KiB
Elixir

defmodule Microwaveprop.Weather.Grib2.Section do
@moduledoc false
@doc """
Parse a single GRIB2 message binary into its component sections.
Returns `{:ok, %{grid_params, product, packing_params, data, bitmap}}`.
"""
def parse_message(<<"GRIB", _reserved::16, _discipline::8, 2::8, total_length::64-big, rest::binary>> = msg) do
discipline = :binary.at(msg, 6)
# Skip indicator section (16 bytes), parse remaining sections
message_body = binary_part(rest, 0, total_length - 16)
parse_sections(message_body, %{discipline: discipline})
end
def parse_message(<<"GRIB", _reserved::16, _discipline::8, edition::8, _::binary>>) do
{:error, "unsupported GRIB edition #{edition}"}
end
def parse_message(_), do: {:error, "not a GRIB2 message"}
@doc """
Identify variable name from discipline, parameter category, and parameter number.
"""
def identify_var(0, 0, 0), do: "TMP"
def identify_var(0, 0, 6), do: "DPT"
def identify_var(0, 3, 0), do: "PRES"
def identify_var(0, 3, 5), do: "HGT"
def identify_var(0, 3, 18), do: "HPBL"
def identify_var(0, 1, 3), do: "PWAT"
def identify_var(_discipline, cat, num), do: "#{cat}:#{num}"
@doc """
Identify level string from surface type and scaled value.
"""
def identify_level(1, _value), do: "surface"
def identify_level(100, value_pa), do: "#{div(value_pa, 100)} mb"
def identify_level(103, value_m), do: "#{value_m} m above ground"
def identify_level(200, _value), do: "entire atmosphere (considered as a single layer)"
def identify_level(type, value), do: "unknown:#{type}:#{value}"
@doc """
Decode a 16-bit sign-magnitude integer.
"""
def sign_magnitude_16(<<0::1, value::15>>), do: value
def sign_magnitude_16(<<1::1, value::15>>), do: -value
# --- Private section parsing ---
defp parse_sections(<<"7777", _rest::binary>>, acc) do
build_result(acc)
end
defp parse_sections(<<length::32-big, section_num::8, rest::binary>>, acc) when length > 5 do
body_length = length - 5
if body_length <= byte_size(rest) do
<<body::binary-size(body_length), remaining::binary>> = rest
acc = parse_section(section_num, body, acc)
parse_sections(remaining, acc)
else
# Truncated section — return what we have
build_result(acc)
end
end
defp parse_sections(<<>>, acc) do
build_result(acc)
end
# Trailing padding or short garbage — return what we have
defp parse_sections(_other, acc) do
build_result(acc)
end
defp parse_section(1, _body, acc) do
# Section 1 (Identification) — we don't need anything from it
acc
end
defp parse_section(3, body, acc) do
<<
_source::8,
_num_points::32-big,
_optional_octets::8,
_interpretation::8,
template::16-big,
rest::binary
>> = body
grid_params = parse_grid_template(template, rest)
Map.put(acc, :grid_params, grid_params)
end
defp parse_section(4, body, acc) do
<<
_num_coords::16-big,
_template::16-big,
param_category::8,
param_number::8,
_generating_process::8,
_background::8,
_forecast_process::8,
_cutoff_hours::16-big,
_cutoff_minutes::8,
_time_unit::8,
_forecast_time::32-big,
first_surface_type::8,
_first_scale_factor::8,
first_scaled_value::32-big,
_rest::binary
>> = body
discipline = Map.get(acc, :discipline, 0)
var = identify_var(discipline, param_category, param_number)
level = identify_level(first_surface_type, first_scaled_value)
Map.put(acc, :product, %{var: var, level: level})
end
defp parse_section(5, body, acc) do
<<
num_data_points::32-big,
template::16-big,
ref_value_bytes::binary-size(4),
binary_scale_bytes::binary-size(2),
decimal_scale_bytes::binary-size(2),
bits_per_value::8,
rest::binary
>> = body
<<ref_value::float-32-big>> = ref_value_bytes
base_params = %{
num_data_points: num_data_points,
template: template,
reference_value: ref_value,
binary_scale: sign_magnitude_16(binary_scale_bytes),
decimal_scale: sign_magnitude_16(decimal_scale_bytes),
bits_per_value: bits_per_value
}
packing_params =
case template do
0 -> base_params
3 -> parse_complex_packing_params(base_params, rest)
_ -> base_params
end
Map.put(acc, :packing_params, packing_params)
end
defp parse_section(6, <<indicator::8, _rest::binary>>, acc) do
bitmap =
if indicator == 255 do
:none
else
:present
end
Map.put(acc, :bitmap, bitmap)
end
defp parse_section(7, data, acc) do
Map.put(acc, :data, data)
end
defp parse_section(_section_num, _body, acc) do
# Skip unknown sections (Section 2, etc.)
acc
end
defp parse_complex_packing_params(base, rest) do
<<
_original_type::8,
_splitting_method::8,
missing_mgmt::8,
_primary_missing::32-big,
_secondary_missing::32-big,
num_groups::32-big,
ref_group_widths::8,
nbits_group_widths::8,
ref_group_lengths::32-big,
length_increment::8,
last_group_length::32-big,
nbits_group_lengths::8,
spatial_order::8,
num_extra_octets::8,
_extra::binary
>> = rest
Map.merge(base, %{
missing_mgmt: missing_mgmt,
num_groups: num_groups,
ref_group_widths: ref_group_widths,
nbits_group_widths: nbits_group_widths,
ref_group_lengths: ref_group_lengths,
length_increment: length_increment,
last_group_length: last_group_length,
nbits_group_lengths: nbits_group_lengths,
spatial_order: spatial_order,
num_extra_octets: num_extra_octets
})
end
# Template 3.30 — Lambert Conformal Conic
defp parse_grid_template(30, body) do
<<
_shape::8,
_scale_factor_radius::8,
_scaled_radius::32-big,
_scale_factor_major::8,
_scaled_major::32-big,
_scale_factor_minor::8,
_scaled_minor::32-big,
nx::32-big,
ny::32-big,
la1_micro::32-big,
lo1_micro::32-big,
_resolution::8,
lad_micro::32-big,
lov_micro::32-big,
dx_mm::32-big,
dy_mm::32-big,
_projection_centre::8,
scan_mode::8,
latin1_micro::32-big,
latin2_micro::32-big,
_lat_sp::32-big,
_lon_sp::32-big,
_rest::binary
>> = body
%{
nx: nx,
ny: ny,
la1: la1_micro / 1_000_000,
lo1: lo1_micro / 1_000_000,
lad: lad_micro / 1_000_000,
lov: lov_micro / 1_000_000,
dx: dx_mm / 1000,
dy: dy_mm / 1000,
latin1: latin1_micro / 1_000_000,
latin2: latin2_micro / 1_000_000,
scan_mode: scan_mode
}
end
defp parse_grid_template(template, _body) do
%{error: "unsupported grid template #{template}"}
end
defp build_result(acc) do
required = [:grid_params, :product, :packing_params, :data, :bitmap]
if Enum.all?(required, &Map.has_key?(acc, &1)) do
{:ok, Map.take(acc, required)}
else
missing = Enum.reject(required, &Map.has_key?(acc, &1))
{:error, "missing sections: #{inspect(missing)}"}
end
end
end