Use wgrib2 for HRRR grid extraction (7s vs 80+ minutes)

Add Wgrib2 module that shells out to wgrib2 binary for fast GRIB2
grid extraction using -lola (nearest-neighbor to regular lat-lon grid).
Falls back to pure-Elixir decoder if wgrib2 is not installed.

Also: parallel GRIB2 range downloads, merge adjacent byte ranges,
skip corrupt messages instead of failing, pressure fetch is optional.
This commit is contained in:
Graham McIntire 2026-03-31 08:33:29 -05:00
parent 81cb735d6e
commit b7446e83f5
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 319 additions and 45 deletions

View file

@ -21,6 +21,21 @@ defmodule Microwaveprop.Propagation.Grid do
@doc "Returns the CONUS bounding box as a map."
def bounds, do: %{lat_min: @lat_min, lat_max: @lat_max, lon_min: @lon_min, lon_max: @lon_max}
@doc "Returns the grid specification for wgrib2 -lola extraction."
def wgrib2_grid_spec do
lon_count = round((@lon_max - @lon_min) / @step) + 1
lat_count = round((@lat_max - @lat_min) / @step) + 1
%{
lon_start: @lon_min,
lon_count: lon_count,
lon_step: @step,
lat_start: @lat_min,
lat_count: lat_count,
lat_step: @step
}
end
defp float_range(start, stop, step) do
count = round((stop - start) / step) + 1
Enum.map(0..(count - 1), fn i -> start + i * step end)

View file

@ -36,29 +36,46 @@ defmodule Microwaveprop.Weather.Grib2.Extractor do
def extract_grid(binary, points) do
messages = split_messages(binary)
result =
Enum.reduce(messages, {:ok, init_grid(points)}, fn msg, {:ok, acc} ->
case extract_single_grid(msg, points) do
{:ok, point_values} ->
merged =
Enum.reduce(point_values, acc, fn {point, key, value}, grid ->
Map.update!(grid, point, &Map.put(&1, key, value))
end)
# Pre-compute grid indices once from the first message's grid params.
# All messages in the same GRIB2 file share the same grid.
grid_indices =
case messages do
[first_msg | _] ->
case Section.parse_message(first_msg) do
{:ok, %{grid_params: grid}} -> precompute_indices(grid, points)
_ -> []
end
{:ok, merged}
[] ->
[]
end
{:error, _reason} ->
# Skip messages with missing/corrupt data sections
{:ok, acc}
end
end)
if grid_indices == [] do
{:ok, %{}}
else
result =
Enum.reduce(messages, {:ok, init_grid(points)}, fn msg, {:ok, acc} ->
case extract_grid_with_indices(msg, grid_indices) do
{:ok, key, values} ->
merged =
Enum.reduce(values, acc, fn {point, value}, grid ->
Map.update!(grid, point, &Map.put(&1, key, value))
end)
case result do
{:ok, grid} ->
{:ok, Map.reject(grid, fn {_point, values} -> values == %{} end)}
{:ok, merged}
error ->
error
{:error, _reason} ->
{:ok, acc}
end
end)
case result do
{:ok, grid} ->
{:ok, Map.reject(grid, fn {_point, values} -> values == %{} end)}
error ->
error
end
end
end
@ -100,36 +117,56 @@ defmodule Microwaveprop.Weather.Grib2.Extractor do
Map.new(points, fn point -> {point, %{}} end)
end
defp extract_single_grid(msg, points) do
# Pre-compute Lambert Conformal grid indices for all points (done once, reused per message)
defp precompute_indices(grid, points) do
Enum.flat_map(points, fn {lat, lon} = point ->
case LambertConformal.to_grid_index(grid, lat, lon) do
{:ok, {i, j}} ->
[{point, linear_index({i, j}, grid.nx, grid.scan_mode)}]
{:error, :outside_grid} ->
[]
end
end)
end
# Extract values for all pre-computed indices from a single GRIB message
defp extract_grid_with_indices(msg, grid_indices) do
with {:ok, parsed} <- Section.parse_message(msg) do
%{grid_params: grid, product: prod, packing_params: packing, data: data} = parsed
%{product: prod, packing_params: packing, data: data} = parsed
key = "#{prod.var}:#{prod.level}"
results =
Enum.reduce_while(points, {:ok, []}, fn {lat, lon} = point, {:ok, acc} ->
case LambertConformal.to_grid_index(grid, lat, lon) do
{:ok, {i, j}} ->
index = linear_index({i, j}, grid.nx, grid.scan_mode)
# For complex packing, decode_all is called once; for simple, each index is independent
unique_indices = grid_indices |> Enum.map(&elem(&1, 1)) |> Enum.uniq()
case unpack_value(packing, data, index) do
{:ok, value} -> {:cont, {:ok, [{point, key, value} | acc]}}
{:error, reason} -> {:halt, {:error, reason}}
case batch_unpack(packing, data, unique_indices) do
{:ok, index_values} ->
values =
Enum.flat_map(grid_indices, fn {point, index} ->
case Map.get(index_values, index) do
nil -> []
value -> [{point, value}]
end
end)
{:error, :outside_grid} ->
{:cont, {:ok, acc}}
end
end)
{:ok, key, values}
case results do
{:ok, point_values} -> {:ok, point_values}
{:error, reason} -> {:error, reason}
{:error, reason} ->
{:error, reason}
end
end
rescue
e -> {:error, "GRIB2 grid extraction failed: #{inspect(e)}"}
end
defp batch_unpack(%{template: 3} = params, data, indices) do
ComplexPacking.extract_values(params, data, indices)
end
defp batch_unpack(params, data, indices) do
SimplePacking.extract_values(params, data, indices)
end
defp extract_single(msg, lat, lon) do
with {:ok, parsed} <- Section.parse_message(msg),
%{grid_params: grid, product: prod, packing_params: packing, data: data} = parsed,

View file

@ -0,0 +1,166 @@
defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
@moduledoc """
Fast GRIB2 grid extraction using the wgrib2 binary.
Uses wgrib2's `-lola` option to interpolate HRRR Lambert Conformal data
onto a regular lat-lon grid, outputting IEEE 754 binary floats.
Falls back to the pure-Elixir decoder if wgrib2 is not available.
"""
require Logger
@wgrib2_path System.find_executable("wgrib2")
@undefined_value 9.999e20
@doc """
Extract values from a GRIB2 binary at a regular lat-lon grid.
Takes the raw GRIB2 binary data, a regex pattern to match desired messages
(e.g. ":(TMP|DPT|PRES):"), and grid specification.
Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, reason}`.
"""
def extract_grid(grib_binary, match_pattern, grid_spec) do
if available?() do
extract_with_wgrib2(grib_binary, match_pattern, grid_spec)
else
{:error, :wgrib2_not_available}
end
end
@doc "Check if wgrib2 is available on the system."
def available? do
@wgrib2_path != nil
end
defp extract_with_wgrib2(grib_binary, match_pattern, grid_spec) do
%{
lon_start: lon_start,
lon_count: lon_count,
lon_step: lon_step,
lat_start: lat_start,
lat_count: lat_count,
lat_step: lat_step
} = grid_spec
# wgrib2 uses 0-360 longitude convention
wgrib2_lon_start = normalize_lon(lon_start)
lon_spec = "#{wgrib2_lon_start}:#{lon_count}:#{lon_step}"
lat_spec = "#{lat_start}:#{lat_count}:#{lat_step}"
# Write GRIB to temp file
tmp_grib = Path.join(System.tmp_dir!(), "hrrr_#{System.unique_integer([:positive])}.grib2")
tmp_bin = tmp_grib <> ".lola.bin"
try do
File.write!(tmp_grib, grib_binary)
# Run wgrib2: match desired messages, extract to regular lat-lon grid as binary
args = [
tmp_grib,
"-match",
match_pattern,
"-lola",
lon_spec,
lat_spec,
tmp_bin,
"bin"
]
case System.cmd(@wgrib2_path, args, stderr_to_stdout: true) do
{output, 0} ->
# Parse message inventory from stdout to know which vars were extracted
messages = parse_wgrib2_inventory(output)
case File.read(tmp_bin) do
{:ok, bin_data} ->
parse_lola_binary(bin_data, messages, grid_spec)
{:error, :enoent} ->
# No output file means no matching messages
{:ok, %{}}
{:error, reason} ->
{:error, "Failed to read wgrib2 output: #{inspect(reason)}"}
end
{output, exit_code} ->
{:error, "wgrib2 failed (exit #{exit_code}): #{String.slice(output, 0, 200)}"}
end
after
File.rm(tmp_grib)
File.rm(tmp_bin)
end
end
defp parse_wgrib2_inventory(output) do
output
|> String.split("\n")
|> Enum.flat_map(fn line ->
case String.split(line, ":", parts: 8) do
[_n, _offset, _date, var, level | _] ->
[%{var: var, level: level}]
_ ->
[]
end
end)
end
defp parse_lola_binary(bin_data, messages, grid_spec) do
%{lon_count: nx, lat_count: ny, lon_start: lon_start, lon_step: lon_step, lat_start: lat_start, lat_step: lat_step} =
grid_spec
points_per_message = nx * ny
bytes_per_message = points_per_message * 4
result =
messages
|> Enum.with_index()
|> Enum.reduce(%{}, fn {msg, msg_idx}, acc ->
offset = msg_idx * bytes_per_message
key = "#{msg.var}:#{msg.level}"
if offset + bytes_per_message <= byte_size(bin_data) do
chunk = binary_part(bin_data, offset, bytes_per_message)
merge_message_values(acc, key, chunk, nx, ny, lon_start, lon_step, lat_start, lat_step)
else
acc
end
end)
{:ok, result}
end
defp merge_message_values(acc, key, chunk, nx, ny, lon_start, lon_step, lat_start, lat_step) do
# Binary is row-major: lat varies slowest, lon varies fastest
# Each value is a 32-bit IEEE 754 little-endian float
Enum.reduce(0..(ny - 1), acc, fn j, acc_outer ->
lat = Float.round(lat_start + j * lat_step, 3)
Enum.reduce(0..(nx - 1), acc_outer, fn i, acc_inner ->
offset = (j * nx + i) * 4
<<_::binary-size(offset), value::float-little-32, _::binary>> = chunk
if value > @undefined_value / 2 do
# Skip undefined values (ocean/outside-domain points)
acc_inner
else
lon = Float.round(denormalize_lon(lon_start + i * lon_step), 3)
point = {lat, lon}
existing = Map.get(acc_inner, point, %{})
Map.put(acc_inner, point, Map.put(existing, key, value))
end
end)
end)
end
# Convert -125.0 to 235.0 for wgrib2
defp normalize_lon(lon) when lon < 0, do: lon + 360.0
defp normalize_lon(lon), do: lon
# Convert back from 0-360 to -180..180
defp denormalize_lon(lon) when lon > 180.0, do: lon - 360.0
defp denormalize_lon(lon), do: lon
end

View file

@ -1,7 +1,9 @@
defmodule Microwaveprop.Weather.HrrrClient do
@moduledoc false
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Weather.Grib2.Extractor
alias Microwaveprop.Weather.Grib2.Wgrib2
require Logger
@ -210,7 +212,7 @@ defmodule Microwaveprop.Weather.HrrrClient do
end
end
defp fetch_product_grid(date, hour, product, points) do
defp fetch_product_grid(date, hour, product, _points) do
url = hrrr_url(date, hour, product)
idx_url = url <> ".idx"
@ -228,10 +230,28 @@ defmodule Microwaveprop.Weather.HrrrClient do
_ = Logger.info("HRRR grid downloading #{length(ranges)} GRIB ranges for #{product}"),
{:ok, grib_binary} <- download_grib_ranges(url, ranges) do
Logger.info("HRRR grid #{product} downloaded #{byte_size(grib_binary)} bytes, extracting")
extract_grid(grib_binary, wanted, product)
end
end
defp extract_grid(grib_binary, wanted, product) do
if Wgrib2.available?() do
match_pattern = wgrib2_match_pattern(wanted)
grid_spec = Grid.wgrib2_grid_spec()
Logger.info("HRRR grid using wgrib2 for #{product} extraction")
Wgrib2.extract_grid(grib_binary, match_pattern, grid_spec)
else
Logger.info("HRRR grid using Elixir decoder for #{product} extraction (slow)")
points = Grid.conus_points()
Extractor.extract_grid(grib_binary, points)
end
end
defp wgrib2_match_pattern(wanted) do
vars = wanted |> Enum.map(& &1.var) |> Enum.uniq() |> Enum.join("|")
":(#{vars}):"
end
defp maybe_fetch_pressure_grid(date, hour, points, opts) do
if Keyword.get(opts, :pressure, true) do
fetch_product_grid(date, hour, :pressure, points)
@ -262,18 +282,54 @@ defmodule Microwaveprop.Weather.HrrrClient do
defp download_grib_ranges(_url, []), do: {:ok, <<>>}
defp download_grib_ranges(url, ranges) do
Enum.reduce_while(ranges, {:ok, <<>>}, fn {start, stop}, {:ok, acc} ->
case Req.get(url, [{:headers, [{"Range", "bytes=#{start}-#{stop}"}]} | req_options()]) do
{:ok, %{status: 206, body: body}} ->
{:cont, {:ok, acc <> body}}
# Merge adjacent/overlapping ranges to reduce HTTP requests
merged = merge_ranges(ranges)
{:ok, %{status: status}} ->
{:halt, {:error, "HRRR grib HTTP #{status}"}}
# Download ranges in parallel
results =
merged
|> Task.async_stream(
fn {start, stop} ->
case Req.get(url, [{:headers, [{"Range", "bytes=#{start}-#{stop}"}]} | req_options()]) do
{:ok, %{status: 206, body: body}} -> {:ok, start, body}
{:ok, %{status: status}} -> {:error, "HRRR grib HTTP #{status}"}
{:error, reason} -> {:error, reason}
end
end,
max_concurrency: 8,
timeout: 60_000
)
|> Enum.map(fn
{:ok, result} -> result
{:exit, reason} -> {:error, reason}
end)
{:error, reason} ->
{:halt, {:error, reason}}
# Sort by offset and concatenate
case Enum.find(results, &match?({:error, _}, &1)) do
{:error, reason} ->
{:error, reason}
nil ->
binary =
results
|> Enum.sort_by(fn {:ok, offset, _} -> offset end)
|> Enum.map(fn {:ok, _, body} -> body end)
|> IO.iodata_to_binary()
{:ok, binary}
end
end
defp merge_ranges(ranges) do
ranges
|> Enum.sort_by(&elem(&1, 0))
|> Enum.reduce([], fn {s, e}, acc ->
case acc do
[{ps, pe} | rest] when s <= pe + 1 -> [{ps, max(pe, e)} | rest]
_ -> [{s, e} | acc]
end
end)
|> Enum.reverse()
end
defp req_options do