prop/test/microwaveprop/propagation/score_cache_test.exs
Graham McIntire d880201713 Cache propagation scores in ETS, broadcast across cluster
- Add ScoreCache GenServer with node-local ETS table keyed by
  {band, valid_time}, subscribed to "propagation:cache" PubSub topic so
  every pod stays in sync with a single hourly compute
- scores_at/3 checks cache first, falls back to DB and populates on miss
- PropagationGridWorker warms and broadcasts the cache for each band
  after every forecast hour upsert; prunes >2h old entries
- Replace per-pixel string-keyed Map with flat Int8Array over the CONUS
  grid in propagation_map_hook.ts to eliminate allocations in the tile
  rasterization hot loop (interpolateScore / propagationReach)
2026-04-12 12:11:26 -05:00

105 lines
3.9 KiB
Elixir

defmodule Microwaveprop.Propagation.ScoreCacheTest do
use ExUnit.Case, async: false
alias Microwaveprop.Propagation.ScoreCache
setup do
ScoreCache.clear()
:ok
end
describe "fetch/2" do
test "returns :miss when nothing is cached" do
assert ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z]) == :miss
end
test "returns cached scores after put" do
scores = [%{lat: 32.0, lon: -97.0, score: 75}]
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], scores)
assert {:ok, ^scores} = ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
end
test "isolates entries by band" do
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [%{lat: 1.0, lon: 1.0, score: 10}])
ScoreCache.put(24_000, ~U[2026-04-12 12:00:00Z], [%{lat: 2.0, lon: 2.0, score: 20}])
assert {:ok, [%{score: 10}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
assert {:ok, [%{score: 20}]} = ScoreCache.fetch(24_000, ~U[2026-04-12 12:00:00Z])
end
test "isolates entries by valid_time" do
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [%{lat: 1.0, lon: 1.0, score: 10}])
ScoreCache.put(10_000, ~U[2026-04-12 13:00:00Z], [%{lat: 2.0, lon: 2.0, score: 20}])
assert {:ok, [%{score: 10}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
assert {:ok, [%{score: 20}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 13:00:00Z])
end
end
describe "fetch_bounds/3" do
setup do
scores = [
%{lat: 32.0, lon: -97.0, score: 75},
%{lat: 40.0, lon: -74.0, score: 50},
%{lat: 34.0, lon: -98.0, score: 60}
]
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], scores)
:ok
end
test "returns :miss when no entry for band/time" do
bounds = %{"south" => 30.0, "north" => 50.0, "west" => -100.0, "east" => -70.0}
assert ScoreCache.fetch_bounds(24_000, ~U[2026-04-12 12:00:00Z], bounds) == :miss
end
test "returns all scores when bounds is nil" do
assert {:ok, all} = ScoreCache.fetch_bounds(10_000, ~U[2026-04-12 12:00:00Z], nil)
assert length(all) == 3
end
test "returns only scores inside the bounds" do
bounds = %{"south" => 31.0, "north" => 35.0, "west" => -100.0, "east" => -95.0}
assert {:ok, filtered} =
ScoreCache.fetch_bounds(10_000, ~U[2026-04-12 12:00:00Z], bounds)
assert length(filtered) == 2
assert Enum.all?(filtered, fn %{lat: lat} -> lat >= 31.0 and lat <= 35.0 end)
end
test "includes scores exactly on the boundary" do
bounds = %{"south" => 32.0, "north" => 32.0, "west" => -97.0, "east" => -97.0}
assert {:ok, [%{lat: 32.0, lon: -97.0, score: 75}]} =
ScoreCache.fetch_bounds(10_000, ~U[2026-04-12 12:00:00Z], bounds)
end
end
describe "prune_older_than/1" do
test "removes entries whose valid_time is strictly before the cutoff" do
ScoreCache.put(10_000, ~U[2026-04-12 10:00:00Z], [%{lat: 1.0, lon: 1.0, score: 1}])
ScoreCache.put(10_000, ~U[2026-04-12 14:00:00Z], [%{lat: 2.0, lon: 2.0, score: 2}])
ScoreCache.prune_older_than(~U[2026-04-12 12:00:00Z])
assert ScoreCache.fetch(10_000, ~U[2026-04-12 10:00:00Z]) == :miss
assert {:ok, [%{score: 2}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 14:00:00Z])
end
test "keeps entries whose valid_time equals the cutoff" do
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [%{lat: 1.0, lon: 1.0, score: 1}])
ScoreCache.prune_older_than(~U[2026-04-12 12:00:00Z])
assert {:ok, [%{score: 1}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
end
end
describe "broadcast_put/3" do
test "populates the cache on the local node" do
scores = [%{lat: 32.0, lon: -97.0, score: 80}]
ScoreCache.broadcast_put(10_000, ~U[2026-04-12 12:00:00Z], scores)
ScoreCache.sync()
assert {:ok, ^scores} = ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
end
end
end