prop/lib/microwaveprop/weather/grib2/complex_packing.ex
Graham McIntire b077d4facb
Add batch GRIB2 extraction for multi-point grid queries
Add extract_values/3 to SimplePacking and ComplexPacking for batch
index extraction from a single GRIB2 message. Add extract_grid/2 to
Extractor which takes a list of {lat, lon} points and returns all
variable values for each point, skipping points outside the grid.

This enables extracting weather data for many grid points from a
single HRRR download instead of re-parsing per point.
2026-03-30 16:50:03 -05:00

248 lines
7.8 KiB
Elixir

defmodule Microwaveprop.Weather.Grib2.ComplexPacking do
@moduledoc false
@doc """
Extract a single value from GRIB2 complex-packed data with spatial differencing
(Template 5.3) at the given grid index.
Decodes all values since spatial differencing requires sequential access,
then returns the value at the target index.
"""
def extract_value(params, data, index) do
if index < 0 or index >= params.num_data_points do
{:error, :index_out_of_range}
else
case decode_all(params, data) do
{:ok, values} -> {:ok, :array.get(index, values)}
{:error, _} = err -> err
end
end
end
@doc """
Extract multiple values from GRIB2 complex-packed data at the given grid indices.
Decodes all values (since spatial differencing requires sequential access),
then returns the values at the requested indices.
Returns `{:ok, %{index => value}}`.
"""
def extract_values(params, data, indices) do
case decode_all(params, data) do
{:ok, array} ->
results = Map.new(indices, fn index -> {index, :array.get(index, array)} end)
{:ok, results}
{:error, _} = err ->
err
end
end
@doc """
Decode all values from complex-packed data with spatial differencing.
Returns an Erlang array of floats for O(1) index access.
"""
def decode_all(params, data) do
%{
reference_value: ref,
binary_scale: e,
decimal_scale: d,
bits_per_value: nbits,
num_groups: num_groups,
ref_group_widths: ref_gw,
nbits_group_widths: nbits_gw,
ref_group_lengths: ref_gl,
length_increment: len_inc,
last_group_length: last_gl,
nbits_group_lengths: nbits_gl,
spatial_order: spatial_order,
num_extra_octets: num_extra_octets
} = params
# Step 1: Extract spatial differencing initial values
octets_per_val = num_extra_octets
num_init_vals = spatial_order + 1
{init_vals, rest} = extract_init_values(data, num_init_vals, octets_per_val)
{spatial_init, [overall_min]} = Enum.split(init_vals, spatial_order)
# Step 2: Extract group reference values (byte-padded per GRIB2 spec)
{group_refs, rest} = extract_n_values_array(rest, num_groups, nbits)
# Step 3: Extract group widths (byte-padded)
{group_widths_arr, rest} = extract_n_values_array(rest, num_groups, nbits_gw)
# Step 4: Extract group lengths (byte-padded)
{group_lengths_arr, rest} = extract_n_values_array(rest, num_groups, nbits_gl)
# Step 5: Build group info lists
group_widths =
for g <- 0..(num_groups - 1) do
get_val(group_widths_arr, g) + ref_gw
end
group_lengths =
for g <- 0..(num_groups - 1) do
if g == num_groups - 1 do
last_gl
else
get_val(group_lengths_arr, g) * len_inc + ref_gl
end
end
group_refs_list =
for g <- 0..(num_groups - 1) do
get_val(group_refs, g)
end
# Step 6: Decode packed data for each group using bitstring operations
raw_values = decode_groups_bitwise(rest, group_refs_list, group_widths, group_lengths)
# Step 7: Apply spatial differencing in reverse
undiffed = apply_spatial_differencing(spatial_init, overall_min, raw_values)
# Step 8: Apply scaling formula: value = (R + X * 2^E) * 10^(-D)
factor_2e = :math.pow(2, e)
factor_10d = :math.pow(10, -d)
result =
undiffed
|> :array.to_list()
|> Enum.map(fn x -> (ref + x * factor_2e) * factor_10d end)
|> :array.from_list()
{:ok, result}
rescue
e in [MatchError, ArgumentError] ->
{:error, "GRIB2 complex packing decode failed: #{inspect(e)}"}
end
# --- Private helpers ---
defp extract_init_values(data, count, octets_per_val) do
total_bytes = count * octets_per_val
<<init_bytes::binary-size(total_bytes), rest::binary>> = data
values =
for i <- 0..(count - 1) do
offset = i * octets_per_val
val_bytes = binary_part(init_bytes, offset, octets_per_val)
decode_signed_big(val_bytes)
end
{values, rest}
end
defp decode_signed_big(bytes) do
bits = byte_size(bytes) * 8
<<sign::1, magnitude::size(bits - 1)>> = bytes
if sign == 1, do: -magnitude, else: magnitude
end
defp extract_n_values_array(data, _count, 0) do
{nil, data}
end
defp extract_n_values_array(data, count, nbits) do
total_bits = count * nbits
total_bytes = div(total_bits + 7, 8)
<<chunk::binary-size(total_bytes), rest::binary>> = data
vals = consume_bits_simple(<<chunk::binary>>, nbits, [])
# consume_bits_simple prepends (reversed); reverse first, then take count to discard padding
arr = vals |> Enum.reverse() |> Enum.take(count) |> :array.from_list()
{arr, rest}
end
defp consume_bits_simple(bits, nbits, acc) when bit_size(bits) < nbits, do: acc
defp consume_bits_simple(bits, nbits, acc) do
<<val::size(nbits)-unsigned-big, rest::bitstring>> = bits
consume_bits_simple(rest, nbits, [val | acc])
end
defp get_val(nil, _index), do: 0
defp get_val(arr, index), do: :array.get(index, arr)
# Decode all groups from packed data using a bitstring cursor.
# Returns an Erlang array of raw (pre-differencing) integer values.
defp decode_groups_bitwise(data, group_refs, group_widths, group_lengths) do
{all_values_reversed, _remaining_bits} =
[group_refs, group_widths, group_lengths]
|> Enum.zip()
|> Enum.reduce({[], data}, fn {gref, width, length}, {acc, remaining} ->
if width == 0 do
# All values equal gref — prepend in reverse (same values, order irrelevant)
new_acc = prepend_n(acc, gref, length)
{new_acc, remaining}
else
{new_acc, rest} = consume_group_bits(remaining, width, gref, length, acc)
{new_acc, rest}
end
end)
all_values_reversed
|> Enum.reverse()
|> :array.from_list()
end
# Consume `count` values of `width` bits each from bitstring, prepending to acc.
# Values are prepended in reverse order (last value first).
# Handles trailing padding: if fewer bits remain than needed, pad with gref.
defp consume_group_bits(bits, width, gref, count, acc) do
total_bits = count * width
available = bit_size(bits)
if available >= total_bits do
<<chunk::bitstring-size(total_bits), rest::bitstring>> = bits
new_acc = consume_bits(chunk, width, gref, acc)
{new_acc, rest}
else
# Extract what we can, fill remainder with gref
new_acc = consume_bits(bits, width, gref, acc)
extracted = div(available, width)
remaining = count - extracted
new_acc = prepend_n(new_acc, gref, remaining)
{new_acc, <<>>}
end
end
defp consume_bits(bits, width, _gref, acc) when bit_size(bits) < width, do: acc
defp consume_bits(bits, width, gref, acc) do
<<val::size(width)-unsigned-big, rest::bitstring>> = bits
consume_bits(rest, width, gref, [val + gref | acc])
end
defp prepend_n(acc, _value, 0), do: acc
defp prepend_n(acc, value, n), do: prepend_n([value | acc], value, n - 1)
defp apply_spatial_differencing(spatial_init, overall_min, raw_values) do
raw_list = :array.to_list(raw_values)
case spatial_init do
[ival1] ->
{result_reversed, _} =
Enum.reduce(tl(raw_list), {[ival1], ival1}, fn raw, {acc, prev} ->
current = prev + raw + overall_min
{[current | acc], current}
end)
:array.from_list(Enum.reverse(result_reversed))
[ival1, ival2] ->
{result_reversed, _, _} =
raw_list
|> Enum.drop(2)
|> Enum.reduce({[ival2, ival1], ival2, ival1}, fn raw, {acc, prev1, prev2} ->
current = raw + overall_min + 2 * prev1 - prev2
{[current | acc], current, prev1}
end)
:array.from_list(Enum.reverse(result_reversed))
end
end
end