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)
This commit is contained in:
Graham McIntire 2026-04-12 12:11:25 -05:00
parent 6fd0958574
commit d880201713
7 changed files with 474 additions and 77 deletions

View file

@ -127,8 +127,7 @@ interface PropagationMapHook extends ViewHook {
scoreOverlay: L.GridLayer | null
scores: ScorePoint[]
colorScale: ColorScaleEntry[]
gridLookup: Map<string, number>
gridData: Map<string, ScorePoint>
scoreGrid: ScoreGrid
bandInfo: BandInfo
rangeCircles: L.LayerGroup
gridLayer: L.LayerGroup
@ -155,6 +154,62 @@ interface PropagationMapHook extends ViewHook {
// --- Constants ---
// CONUS propagation grid (must match Microwaveprop.Propagation.Grid in Elixir).
const GRID_LAT_MIN = 25.0
const GRID_LON_MIN = -125.0
const GRID_STEP = 0.125
const GRID_LAT_COUNT = 201 // (50 - 25) / 0.125 + 1
const GRID_LON_COUNT = 473 // (-66 - -125) / 0.125 + 1
const GRID_MISSING = -1 // Int8Array sentinel for empty cell
/**
* Flat typed-array grid storing propagation scores at 0.125° resolution across
* CONUS. Replaces a string-keyed Map zero allocations on per-pixel reads,
* which matters because `interpolateScore` is called thousands of times per
* rendered canvas tile.
*/
class ScoreGrid {
private readonly data: Int8Array
constructor() {
this.data = new Int8Array(GRID_LAT_COUNT * GRID_LON_COUNT)
this.data.fill(GRID_MISSING)
}
reset(): void {
this.data.fill(GRID_MISSING)
}
put(lat: number, lon: number, score: number): void {
const latIdx = Math.round((lat - GRID_LAT_MIN) / GRID_STEP)
const lonIdx = Math.round((lon - GRID_LON_MIN) / GRID_STEP)
if (latIdx < 0 || latIdx >= GRID_LAT_COUNT) return
if (lonIdx < 0 || lonIdx >= GRID_LON_COUNT) return
this.data[latIdx * GRID_LON_COUNT + lonIdx] = score
}
get(lat: number, lon: number): number | null {
const latIdx = Math.round((lat - GRID_LAT_MIN) / GRID_STEP)
const lonIdx = Math.round((lon - GRID_LON_MIN) / GRID_STEP)
return this.getByIdx(latIdx, lonIdx)
}
getByIdx(latIdx: number, lonIdx: number): number | null {
if (latIdx < 0 || latIdx >= GRID_LAT_COUNT) return null
if (lonIdx < 0 || lonIdx >= GRID_LON_COUNT) return null
const v = this.data[latIdx * GRID_LON_COUNT + lonIdx]
return v === GRID_MISSING ? null : v
}
indexOf(lat: number, lon: number): number {
const latIdx = Math.round((lat - GRID_LAT_MIN) / GRID_STEP)
const lonIdx = Math.round((lon - GRID_LON_MIN) / GRID_STEP)
if (latIdx < 0 || latIdx >= GRID_LAT_COUNT) return -1
if (lonIdx < 0 || lonIdx >= GRID_LON_COUNT) return -1
return latIdx * GRID_LON_COUNT + lonIdx
}
}
// Weights must match BandConfig.weights() in band_config.ex
// Recalibrated 2026-04-11 via gradient descent (loss 0.42 → 0.12)
const FACTOR_META: Record<string, FactorMeta> = {
@ -280,30 +335,27 @@ function rangeEstimate(score: number, detail: PointDetail): string {
* with score >= minScore. Returns array of {lat, lon} boundary points
* as a convex hull for drawing a polygon.
*/
function propagationReach(gridLookup: Map<string, number>, startLat: number, startLon: number, minScore: number): LatLon[] {
const step = 0.125
function propagationReach(grid: ScoreGrid, startLat: number, startLon: number, minScore: number): LatLon[] {
const maxKm = 300
const snap = (v: number): string => (Math.round(v / step) * step).toFixed(3)
const snap = (v: number): number => Math.round(v / GRID_STEP) * GRID_STEP
const startKey = `${snap(startLat)},${snap(startLon)}`
const startScore = gridLookup.get(startKey)
const snapLat = snap(startLat)
const snapLon = snap(startLon)
const startScore = grid.get(snapLat, snapLon)
if (startScore == null || startScore < minScore) return []
const cosLat = Math.cos(startLat * Math.PI / 180)
const kmPerDegLat = 111.0
const kmPerDegLon = 111.0 * cosLat
const visited = new Set<string>()
const visited = new Set<number>()
const reachable: LatLon[] = []
const queue: string[] = [startKey]
visited.add(startKey)
const queue: [number, number][] = [[snapLat, snapLon]]
visited.add(grid.indexOf(snapLat, snapLon))
// BFS flood fill through grid, capped at maxKm from origin
while (queue.length > 0) {
const key = queue.shift()!
const [latStr, lonStr] = key.split(",")
const lat = parseFloat(latStr)
const lon = parseFloat(lonStr)
const [lat, lon] = queue.shift()!
const dLat = (lat - startLat) * kmPerDegLat
const dLon = (lon - startLon) * kmPerDegLon
@ -311,27 +363,25 @@ function propagationReach(gridLookup: Map<string, number>, startLat: number, sta
reachable.push({ lat, lon })
// Check 4 neighbors
const neighbors: [number, number][] = [
[lat + step, lon],
[lat - step, lon],
[lat, lon + step],
[lat, lon - step]
[lat + GRID_STEP, lon],
[lat - GRID_STEP, lon],
[lat, lon + GRID_STEP],
[lat, lon - GRID_STEP]
]
for (const [nlat, nlon] of neighbors) {
const nkey = `${nlat.toFixed(3)},${nlon.toFixed(3)}`
if (visited.has(nkey)) continue
visited.add(nkey)
const score = gridLookup.get(nkey)
const nidx = grid.indexOf(nlat, nlon)
if (nidx < 0 || visited.has(nidx)) continue
visited.add(nidx)
const score = grid.get(nlat, nlon)
if (score != null && score >= minScore) {
queue.push(nkey)
queue.push([nlat, nlon])
}
}
}
if (reachable.length < 3) return reachable
// Compute convex hull for polygon boundary
return convexHull(reachable)
}
@ -753,11 +803,11 @@ export const PropagationMap: Record<string, unknown> & {
}
// Draw propagation reach polygon based on contiguous good cells
if (this.gridLookup && this.clickedLatLng) {
if (this.scoreGrid && this.clickedLatLng) {
// Use MARGINAL threshold (50) as minimum for propagation reach
const minScore = 50
const hull = propagationReach(
this.gridLookup, this.clickedLatLng[0], this.clickedLatLng[1], minScore
this.scoreGrid, this.clickedLatLng[0], this.clickedLatLng[1], minScore
)
if (hull.length >= 3) {
// Remove any previous reach polygon
@ -913,13 +963,12 @@ export const PropagationMap: Record<string, unknown> & {
}
if (scores.length === 0) return
this.gridLookup = new Map()
this.gridData = new Map()
scores.forEach((s) => {
const key = `${s.lat.toFixed(3)},${s.lon.toFixed(3)}`
this.gridLookup.set(key, s.score)
this.gridData.set(key, s)
})
if (!this.scoreGrid) {
this.scoreGrid = new ScoreGrid()
} else {
this.scoreGrid.reset()
}
scores.forEach((s) => this.scoreGrid.put(s.lat, s.lon, s.score))
// Pre-calculate RGBA strings for scores 0-100
const colorCache = new Array<string>(101)
@ -971,33 +1020,40 @@ export const PropagationMap: Record<string, unknown> & {
this.scoreOverlay.addTo(this.map)
},
interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null {
const rlat = (Math.round(lat / step) * step).toFixed(3)
const rlon = (Math.round(lon / step) * step).toFixed(3)
if (this.gridLookup.has(`${rlat},${rlon}`)) return this.gridLookup.get(`${rlat},${rlon}`)!
interpolateScore(this: PropagationMapHook, lat: number, lon: number, _step: number): number | null {
// Snapped cell — hot path, zero allocations
const latIdxR = Math.round((lat - GRID_LAT_MIN) / GRID_STEP)
const lonIdxR = Math.round((lon - GRID_LON_MIN) / GRID_STEP)
const snapped = this.scoreGrid.getByIdx(latIdxR, lonIdxR)
if (snapped !== null) return snapped
const lat0 = Math.floor(lat / step) * step
const lat1 = lat0 + step
const lon0 = Math.floor(lon / step) * step
const lon1 = lon0 + step
// Bilinear interpolation across 4 neighbors
const latFloor = Math.floor((lat - GRID_LAT_MIN) / GRID_STEP)
const lonFloor = Math.floor((lon - GRID_LON_MIN) / GRID_STEP)
const tLat = ((lat - GRID_LAT_MIN) / GRID_STEP) - latFloor
const tLon = ((lon - GRID_LON_MIN) / GRID_STEP) - lonFloor
const s00 = this.gridLookup.get(`${lat0.toFixed(3)},${lon0.toFixed(3)}`)
const s10 = this.gridLookup.get(`${lat1.toFixed(3)},${lon0.toFixed(3)}`)
const s01 = this.gridLookup.get(`${lat0.toFixed(3)},${lon1.toFixed(3)}`)
const s11 = this.gridLookup.get(`${lat1.toFixed(3)},${lon1.toFixed(3)}`)
const s00 = this.scoreGrid.getByIdx(latFloor, lonFloor)
const s10 = this.scoreGrid.getByIdx(latFloor + 1, lonFloor)
const s01 = this.scoreGrid.getByIdx(latFloor, lonFloor + 1)
const s11 = this.scoreGrid.getByIdx(latFloor + 1, lonFloor + 1)
const corners = [s00, s10, s01, s11].filter((s): s is number => s !== undefined)
if (corners.length < 2) return null
if (corners.length === 4) {
const tLat = (lat - lat0) / step
const tLon = (lon - lon0) / step
if (s00 !== null && s10 !== null && s01 !== null && s11 !== null) {
return Math.round(
s00! * (1 - tLat) * (1 - tLon) + s10! * tLat * (1 - tLon) +
s01! * (1 - tLat) * tLon + s11! * tLat * tLon
s00 * (1 - tLat) * (1 - tLon) + s10 * tLat * (1 - tLon) +
s01 * (1 - tLat) * tLon + s11 * tLat * tLon
)
}
return Math.round(corners.reduce((a, b) => a + b, 0) / corners.length)
// Partial corners — average whatever we have (minimum 2 for a reasonable value)
let sum = 0
let count = 0
if (s00 !== null) { sum += s00; count++ }
if (s10 !== null) { sum += s10; count++ }
if (s01 !== null) { sum += s01; count++ }
if (s11 !== null) { sum += s11; count++ }
if (count < 2) return null
return Math.round(sum / count)
},
scoreColorRGB(this: PropagationMapHook, score: number): RGB {
@ -1019,13 +1075,12 @@ export const PropagationMap: Record<string, unknown> & {
},
lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null {
if (!this.gridData) return null
const step = 0.125
const rlat = (Math.round(lat / step) * step).toFixed(3)
const rlon = (Math.round(lon / step) * step).toFixed(3)
const data = this.gridData.get(`${rlat},${rlon}`)
if (!data) return null
return { ...data, ...this.bandInfo }
if (!this.scoreGrid) return null
const rlat = Math.round(lat / GRID_STEP) * GRID_STEP
const rlon = Math.round(lon / GRID_STEP) * GRID_STEP
const score = this.scoreGrid.get(rlat, rlon)
if (score == null) return null
return { lat: rlat, lon: rlon, score, ...this.bandInfo }
},
renderTimeline(this: PropagationMapHook) {

View file

@ -14,6 +14,7 @@ defmodule Microwaveprop.Application do
Microwaveprop.Repo,
{Cluster.Supervisor, [topologies, [name: Microwaveprop.ClusterSupervisor]]},
{Phoenix.PubSub, name: Microwaveprop.PubSub},
Microwaveprop.Propagation.ScoreCache,
{Oban, Application.fetch_env!(:microwaveprop, Oban)},
Microwaveprop.RepoListener,
# Start to serve requests, typically the last entry

View file

@ -6,6 +6,7 @@ defmodule Microwaveprop.Propagation do
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Propagation.GridScore
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Repo
alias Microwaveprop.Weather.SoundingParams
@ -237,15 +238,55 @@ defmodule Microwaveprop.Propagation do
[]
_ ->
from(gs in GridScore,
where: gs.band_mhz == ^band_mhz and gs.valid_time == ^time,
select: %{lat: gs.lat, lon: gs.lon, score: gs.score, valid_time: gs.valid_time}
)
|> maybe_filter_bounds(bounds)
|> Repo.all()
scores_at_fetch(band_mhz, time, bounds)
end
end
defp scores_at_fetch(band_mhz, time, bounds) do
case ScoreCache.fetch_bounds(band_mhz, time, bounds) do
{:ok, scores} ->
Enum.map(scores, &Map.put(&1, :valid_time, time))
:miss ->
full = load_scores_from_db(band_mhz, time)
ScoreCache.put(band_mhz, time, full)
full
|> filter_bounds(bounds)
|> Enum.map(&Map.put(&1, :valid_time, time))
end
end
defp load_scores_from_db(band_mhz, time) do
Repo.all(
from(gs in GridScore,
where: gs.band_mhz == ^band_mhz and gs.valid_time == ^time,
select: %{lat: gs.lat, lon: gs.lon, score: gs.score}
)
)
end
@doc """
Load the full CONUS score set for `{band_mhz, valid_time}` from the DB and
broadcast it to every `ScoreCache` in the cluster. Intended to be called from
`PropagationGridWorker` after each upsert so all pods have a warm cache by
the time clients begin requesting the new hour.
"""
@spec warm_cache_and_broadcast(non_neg_integer(), DateTime.t()) :: :ok
def warm_cache_and_broadcast(band_mhz, valid_time) do
scores = load_scores_from_db(band_mhz, valid_time)
ScoreCache.broadcast_put(band_mhz, valid_time, scores)
:ok
end
defp filter_bounds(scores, nil), do: scores
defp filter_bounds(scores, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
Enum.filter(scores, fn %{lat: lat, lon: lon} ->
lat >= s and lat <= n and lon >= w and lon <= e
end)
end
@doc "Get the latest scores for a band (alias for scores_at with earliest valid_time)."
@spec latest_scores(non_neg_integer(), map() | nil) ::
[%{lat: float(), lon: float(), score: non_neg_integer(), valid_time: DateTime.t()}]
@ -320,14 +361,6 @@ defmodule Microwaveprop.Propagation do
end
end
defp maybe_filter_bounds(query, nil), do: query
defp maybe_filter_bounds(query, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
from(gs in query,
where: gs.lat >= ^s and gs.lat <= ^n and gs.lon >= ^w and gs.lon <= ^e
)
end
@doc "Get the latest valid_time across all scores."
@spec latest_valid_time() :: DateTime.t() | nil
def latest_valid_time do

View file

@ -0,0 +1,115 @@
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.
"""
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
[{_, scores}] -> {:ok, scores}
[] -> :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 fetch(band_mhz, valid_time) do
{:ok, scores} -> {:ok, filter_bounds(scores, bounds)}
:miss -> :miss
end
end
@spec put(non_neg_integer(), DateTime.t(), [score()]) :: :ok
def put(band_mhz, valid_time, scores) do
:ets.insert(@table, {{band_mhz, valid_time}, scores})
: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
@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, read_concurrency: true])
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 filter_bounds(scores, nil), do: scores
defp filter_bounds(scores, %{"south" => s, "north" => n, "west" => w, "east" => e}) do
Enum.filter(scores, fn %{lat: lat, lon: lon} ->
lat >= s and lat <= n and lon >= w and lon <= e
end)
end
end

View file

@ -11,7 +11,9 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
unique: [period: 3600, states: [:available, :scheduled, :executing, :retryable]]
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.HrrrNativeClient
@ -94,6 +96,7 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
case Propagation.upsert_scores(scores, prune: false) do
{:ok, count} ->
Logger.info("PropagationGrid: #{label}#{count} scores for #{valid_time}")
warm_cache(valid_time)
:ok
error ->
@ -107,6 +110,14 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
end
end
defp warm_cache(valid_time) do
Enum.each(BandConfig.all_bands(), fn band ->
Propagation.warm_cache_and_broadcast(band.freq_mhz, valid_time)
end)
ScoreCache.prune_older_than(DateTime.add(DateTime.utc_now(), -2, :hour))
end
defp timed(label, fun) do
t0 = System.monotonic_time(:millisecond)
result = fun.()

View file

@ -0,0 +1,105 @@
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

View file

@ -1,8 +1,14 @@
defmodule Microwaveprop.PropagationTest do
use Microwaveprop.DataCase
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.GridScore
alias Microwaveprop.Propagation.ScoreCache
setup do
ScoreCache.clear()
:ok
end
describe "score_grid_point/4" do
test "scores a single point for all bands" do
@ -81,6 +87,77 @@ defmodule Microwaveprop.PropagationTest do
end
end
describe "scores_at/3 with cache" do
test "populates the cache on a DB miss" do
valid_time = ~U[2026-07-15 13:00:00Z]
Propagation.upsert_scores([
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: %{}}
])
assert ScoreCache.fetch(10_000, valid_time) == :miss
_ = Propagation.scores_at(10_000, valid_time)
assert {:ok, [%{lat: 35.0, lon: -97.0, score: 75}]} = ScoreCache.fetch(10_000, valid_time)
end
test "returns cached bounds-filtered results without hitting the DB" do
valid_time = ~U[2026-07-15 13:00:00Z]
# Seed the cache directly — DB is empty, so if the result matches the
# cached payload we know the DB was not consulted.
ScoreCache.put(10_000, valid_time, [
%{lat: 32.0, lon: -97.0, score: 75},
%{lat: 40.0, lon: -74.0, score: 50}
])
assert Repo.aggregate(GridScore, :count) == 0
bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -95.0}
result = Propagation.scores_at(10_000, valid_time, bounds)
assert length(result) == 1
assert [%{lat: 32.0, lon: -97.0, score: 75}] = result
end
test "returns empty list when no data in cache or DB" do
assert Propagation.scores_at(10_000, ~U[2026-07-15 13:00:00Z]) == []
end
end
describe "warm_cache_and_broadcast/2" do
test "loads scores from DB into the cache" do
valid_time = ~U[2026-07-15 13:00:00Z]
Propagation.upsert_scores([
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: %{}},
%{lat: 36.0, lon: -96.0, valid_time: valid_time, band_mhz: 10_000, score: 80, factors: %{}}
])
assert ScoreCache.fetch(10_000, valid_time) == :miss
Propagation.warm_cache_and_broadcast(10_000, valid_time)
ScoreCache.sync()
assert {:ok, scores} = ScoreCache.fetch(10_000, valid_time)
assert length(scores) == 2
end
test "only warms the requested band" do
valid_time = ~U[2026-07-15 13:00:00Z]
Propagation.upsert_scores([
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 75, factors: %{}},
%{lat: 35.0, lon: -97.0, valid_time: valid_time, band_mhz: 24_000, score: 30, factors: %{}}
])
Propagation.warm_cache_and_broadcast(10_000, valid_time)
ScoreCache.sync()
assert {:ok, [_]} = ScoreCache.fetch(10_000, valid_time)
assert ScoreCache.fetch(24_000, valid_time) == :miss
end
end
describe "latest_valid_time/0" do
test "returns nil when empty" do
assert Propagation.latest_valid_time() == nil