prop/lib/microwaveprop/weather/grib2/extractor.ex
Graham McIntire d61fbd346e
fix(dialyzer): clear 125+ warnings under strict flags
Enabled :error_handling, :unknown, :unmatched_returns, :extra_return,
:missing_return in an earlier commit and landed a 129-warning baseline.
Four parallel agents each fixed a directory slice:

- Core contexts (29): Radio, Release, Weather, Beacons, Cache,
  Backtest.Features, Terrain.Srtm, Ionosphere.GiroClient,
  Propagation.RunTiming, Accounts.Scope, RepoListener. Fixes were
  (a) prefix side-effect calls (Task.start, Phoenix.PubSub,
  Logger, :ets.new) with _ = ; (b) tighten/widen specs that didn't
  match actual returns; (c) add missing @type t declarations;
  (d) drop dead parse_int(nil) clause.

- Propagation + weather subdirs (15): FreshnessMonitor, NotifyListener,
  ScoreCache, ScoreCacheReconciler, Weather.FrontalAnalysis,
  Weather.Grib2.Extractor, Weather.Grib2.Wgrib2, GridCache,
  HrrrPointEnqueuer, NexradCache. Same patterns — mostly _ = on
  PubSub / :ets / Repo.insert_all; widened two specs (float ->
  number) where integer returns were reachable.

- Workers (35): BackfillEnqueue, CanadianSoundingFetch,
  ContactImport, ContactWeatherEnqueue, GefsFetch, IemreFetch,
  NarrFetch, SolarIndex, TerrainProfile, WeatherFetch. Prefixed
  Repo.update_all / Radio.set_enrichment_status! / Weather.upsert_*
  side-effect calls. Fixed one :pattern_match in
  CanadianSoundingFetch.most_recent_sounding_time/1 where a
  tautological cond guard generated unreachable code.

- Web + Mix tasks + lib_ml (46 of 50): controllers, LiveViews,
  UserAuth, and 11 mix tasks. Same prefix strategy. 4 remaining
  warnings originate in LiveTable.LiveResource dep macro expansion
  and can't be fixed without forking the dep — added .dialyzer_ignore.exs
  to suppress just those specific file:line pairs.

Also wired ignore_warnings in mix.exs dialyzer config.

mix dialyzer --format short | grep ^lib/ | wc -l -> 0
mix test: 2163 tests, 3 pre-existing flakes, 0 regressions.
2026-04-21 10:30:06 -05:00

201 lines
6.2 KiB
Elixir

defmodule Microwaveprop.Weather.Grib2.Extractor do
@moduledoc false
alias Microwaveprop.Weather.Grib2.ComplexPacking
alias Microwaveprop.Weather.Grib2.LambertConformal
alias Microwaveprop.Weather.Grib2.Section
alias Microwaveprop.Weather.Grib2.SimplePacking
@doc """
Extract weather values from a GRIB2 binary blob at the given lat/lon.
Returns `{:ok, %{"VAR:LEVEL" => float}}` or `{:error, term}`.
"""
@spec extract_points(binary(), float(), float()) :: {:ok, %{String.t() => float()}} | {:error, term()}
def extract_points(binary, lat, lon) do
messages = split_messages(binary)
Enum.reduce(messages, {:ok, %{}}, fn
msg, {:ok, acc} ->
case extract_single(msg, lat, lon) do
{:ok, key, value} -> {:ok, Map.put(acc, key, value)}
{:error, :outside_grid} -> {:error, :outside_grid}
{:error, _reason} -> {:ok, acc}
end
_msg, error ->
error
end)
end
@doc """
Extract weather values from a GRIB2 binary blob for multiple lat/lon points.
Takes a binary and a list of `{lat, lon}` tuples.
Returns `{:ok, %{{lat, lon} => %{"VAR:LEVEL" => float}}}` or `{:error, term}`.
"""
@spec extract_grid(binary(), [{float(), float()}]) ::
{:ok, %{{float(), float()} => %{String.t() => float()}}} | {:error, term()}
def extract_grid(binary, points) do
messages = split_messages(binary)
# 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
[] ->
[]
end
if grid_indices == [] do
{:ok, %{}}
else
messages
|> reduce_messages(grid_indices, init_grid(points))
|> filter_empty_grid()
end
end
defp reduce_messages(messages, grid_indices, initial_grid) do
Enum.reduce(messages, {:ok, initial_grid}, fn msg, {:ok, acc} ->
case extract_grid_with_indices(msg, grid_indices) do
{:ok, key, values} ->
{:ok, merge_grid_values(values, acc, key)}
{:error, _reason} ->
{:ok, acc}
end
end)
end
defp filter_empty_grid({:ok, grid}) do
{:ok, Map.reject(grid, fn {_point, values} -> values == %{} end)}
end
defp filter_empty_grid(error), do: error
@doc """
Split a binary blob into individual GRIB2 messages by scanning for "GRIB" magic bytes
and reading the total length from the indicator section.
"""
@spec split_messages(binary()) :: [binary()]
def split_messages(binary), do: split_messages(binary, [])
@doc """
Compute linear index from grid (i, j) and scan mode.
For HRRR scan_mode=64 (j positive, i positive, i consecutive): index = j * nx + i
"""
@spec linear_index({non_neg_integer(), non_neg_integer()}, pos_integer(), non_neg_integer()) ::
number()
def linear_index({i, j}, nx, _scan_mode) do
j * nx + i
end
# --- Private ---
defp split_messages(<<>>, acc), do: Enum.reverse(acc)
defp split_messages(<<"GRIB", _::32, total_length::64-big, _rest::binary>> = binary, acc) do
if total_length > byte_size(binary) do
Enum.reverse(acc)
else
msg = binary_part(binary, 0, total_length)
remaining = binary_part(binary, total_length, byte_size(binary) - total_length)
split_messages(remaining, [msg | acc])
end
end
defp split_messages(<<_::8, rest::binary>>, acc) do
# Skip non-GRIB bytes (padding between messages)
split_messages(rest, acc)
end
defp init_grid(points) do
Map.new(points, fn point -> {point, %{}} end)
end
# 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
%{product: prod, packing_params: packing, data: data} = parsed
key = "#{prod.var}:#{prod.level}"
# 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 batch_unpack(packing, data, unique_indices) do
{:ok, index_values} ->
{:ok, key, resolve_grid_values(grid_indices, index_values)}
{:error, reason} ->
{:error, reason}
end
end
rescue
e -> {:error, "GRIB2 grid extraction failed: #{inspect(e)}"}
end
defp merge_grid_values(values, grid, key) do
Enum.reduce(values, grid, fn {point, value}, acc ->
Map.update!(acc, point, &Map.put(&1, key, value))
end)
end
defp resolve_grid_values(grid_indices, index_values) do
Enum.flat_map(grid_indices, fn {point, index} ->
case Map.get(index_values, index) do
nil -> []
value -> [{point, value}]
end
end)
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,
key = "#{prod.var}:#{prod.level}",
{:ok, {i, j}} <- LambertConformal.to_grid_index(grid, lat, lon),
index = linear_index({i, j}, grid.nx, grid.scan_mode),
{:ok, value} <- unpack_value(packing, data, index) do
{:ok, key, value}
end
rescue
e -> {:error, "GRIB2 extraction failed: #{inspect(e)}"}
end
defp unpack_value(%{template: 3} = params, data, index) do
ComplexPacking.extract_value(params, data, index)
end
defp unpack_value(params, data, index) do
SimplePacking.extract_value(params, data, index)
end
end