prop/lib/microwaveprop/weather/grib2/section.ex
Graham McIntire f15b2aaa69 Fix native-level GRIB2 decoding and add wgrib2 extraction path
The Elixir GRIB2 decoder didn't map level type 105 (hybrid) or
variable IDs for SPFH and TKE, so native-level messages decoded as
"unknown:105:N" keys that build_native_profile couldn't find. Add
the three missing mappings to Section.identify_level/identify_var.

Also add HrrrNativeClient.extract_native_profiles/2 which uses
wgrib2's -lola on a tight bounding-box subgrid for speed (the pure
Elixir decoder takes ~70s per point on a 395 MB file; wgrib2 handles
40 points in seconds). The worker now routes through this path.
2026-04-10 07:54:08 -05:00

273 lines
7.4 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, 0), do: "SPFH"
def identify_var(0, 1, 3), do: "PWAT"
def identify_var(0, 1, 8), do: "APCP"
def identify_var(0, 2, 2), do: "UGRD"
def identify_var(0, 2, 3), do: "VGRD"
def identify_var(0, 6, 1), do: "TCDC"
def identify_var(0, 19, 11), do: "TKE"
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(10, _value), do: "entire atmosphere"
def identify_level(105, level), do: "#{level} hybrid level"
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