prop/lib/microwaveprop/propagation/score_cache.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

166 lines
5.2 KiB
Elixir

defmodule Microwaveprop.Propagation.ScoreCache do
@moduledoc """
Node-local ETS cache of propagation scores keyed by `{band_mhz, valid_time}`.
Populated eagerly by `Microwaveprop.Workers.PropagationGridWorker` after each
hourly compute (via `broadcast_put/3`) and lazily by `Microwaveprop.Propagation.scores_at/3`
on a cache miss. `broadcast_put/3` fans the payload out across the cluster on
the `"propagation:cache"` PubSub topic so every BEAM node stays in sync with
a single compute.
Internally each cache entry stores a `%{{lat, lon} => score}` map so that
per-point lookups (used by `point_forecast/3` and `point_detail/4`) are O(1).
"""
use GenServer
alias Phoenix.PubSub
@table :propagation_score_cache
@topic "propagation:cache"
@pubsub Microwaveprop.PubSub
@type score :: %{lat: float(), lon: float(), score: non_neg_integer()}
@type bounds :: %{optional(String.t()) => float()}
# ---------- Public API ----------
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@spec fetch(non_neg_integer(), DateTime.t()) :: {:ok, [score()]} | :miss
def fetch(band_mhz, valid_time) do
case :ets.lookup(@table, {band_mhz, valid_time}) do
[{_, grid}] -> {:ok, grid_to_list(grid)}
[] -> :miss
end
end
@spec fetch_bounds(non_neg_integer(), DateTime.t(), bounds() | nil) ::
{:ok, [score()]} | :miss
def fetch_bounds(band_mhz, valid_time, bounds) do
case :ets.lookup(@table, {band_mhz, valid_time}) do
[{_, grid}] -> {:ok, grid_to_filtered_list(grid, bounds)}
[] -> :miss
end
end
@doc """
Look up the score for a single `{lat, lon}` point at `{band_mhz, valid_time}`.
Returns `{:ok, score}` on hit or `:miss` on cache miss. The coordinates are
used as-is (no snap-to-grid) — callers that need the nearest grid cell should
snap first.
"""
@spec fetch_point(non_neg_integer(), DateTime.t(), float(), float()) ::
{:ok, non_neg_integer()} | :miss
def fetch_point(band_mhz, valid_time, lat, lon) do
case :ets.lookup(@table, {band_mhz, valid_time}) do
[{_, grid}] ->
case Map.get(grid, {lat, lon}) do
nil -> :miss
score -> {:ok, score}
end
[] ->
:miss
end
end
@spec put(non_neg_integer(), DateTime.t(), [score()]) :: :ok
def put(band_mhz, valid_time, scores) do
grid = list_to_grid(scores)
:ets.insert(@table, {{band_mhz, valid_time}, grid})
:ok
end
@doc """
Insert locally AND broadcast to peer nodes via PubSub.
Call this from the single pod that computed the scores; every cluster member
(including the caller) will receive the PubSub message and populate its local
ETS. Use `put/3` directly if you already have a cluster-wide fan-out.
"""
@spec broadcast_put(non_neg_integer(), DateTime.t(), [score()]) :: :ok
def broadcast_put(band_mhz, valid_time, scores) do
_ = PubSub.broadcast(@pubsub, @topic, {:cache_refresh, band_mhz, valid_time, scores})
:ok
end
@doc "Returns the sorted list of valid_times cached for `band_mhz`."
@spec valid_times(non_neg_integer()) :: [DateTime.t()]
def valid_times(band_mhz) do
match_spec = [{{{band_mhz, :"$1"}, :_}, [], [:"$1"]}]
@table
|> :ets.select(match_spec)
|> Enum.sort({:asc, DateTime})
end
@spec prune_older_than(DateTime.t()) :: non_neg_integer()
def prune_older_than(cutoff) do
match_spec = [
{{{:_, :"$1"}, :_}, [{:<, :"$1", {:const, cutoff}}], [true]}
]
:ets.select_delete(@table, match_spec)
end
@spec clear() :: :ok
def clear do
:ets.delete_all_objects(@table)
:ok
end
@doc "Flush the GenServer mailbox so any in-flight cache_refresh messages are applied."
@spec sync() :: :ok
def sync do
GenServer.call(__MODULE__, :sync)
end
# ---------- GenServer callbacks ----------
@impl true
def init(_opts) do
_ = :ets.new(@table, [:set, :named_table, :public, :compressed, read_concurrency: true])
:ok = PubSub.subscribe(@pubsub, @topic)
{:ok, %{}}
end
@impl true
def handle_call(:sync, _from, state), do: {:reply, :ok, state}
@impl true
def handle_info({:cache_refresh, band_mhz, valid_time, scores}, state) do
put(band_mhz, valid_time, scores)
{:noreply, state}
end
def handle_info(_msg, state), do: {:noreply, state}
# ---------- Internal ----------
defp list_to_grid(scores) do
Map.new(scores, fn %{lat: lat, lon: lon, score: score} -> {{lat, lon}, score} end)
end
defp grid_to_list(grid) do
Enum.map(grid, fn {{lat, lon}, score} -> %{lat: lat, lon: lon, score: score} end)
end
defp grid_to_filtered_list(grid, nil), do: grid_to_list(grid)
defp grid_to_filtered_list(grid, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
# Single pass over 92k cells: the former Enum.filter |> Enum.map
# walked the map twice and allocated an intermediate list of
# {key, value} tuples between them. Enum.reduce emits only the
# in-bounds result maps directly.
Enum.reduce(grid, [], fn {{lat, lon}, score}, acc ->
if lat >= s and lat <= n and lon >= w and lon <= e do
[%{lat: lat, lon: lon, score: score} | acc]
else
acc
end
end)
end
end