prop/test/microwaveprop/propagation/score_cache_test.exs
Graham McIntire c318c3a932
Nudge HRRR scores with live ASOS obs between runs
Adds Microwaveprop.Propagation.AsosNudge: a pure IDW bias-field module
that takes ASOS observations + HRRR profiles and returns re-scored grid
rows for every cell within 250km of a reporting station. Upper-air
fields (min_refractivity_gradient, pwat_mm, hpbl_m, profile, duct
metadata) pass through unchanged so HRRR's signal isn't clobbered.

The old AsosAdjustmentWorker was unwired and buggy — nil'd out ~22% of
the scoring weight and wrote orphan timestamps. Replaced with a slim
worker that queries the latest HRRR valid_time, fetches live ASOS
currents, calls AsosNudge.compute/3, and upserts onto
(lat, lon, valid_time, band_mhz) so nudged values overwrite the HRRR
hour cleanly instead of polluting available_valid_times. After each
upsert it warms ScoreCache and broadcasts propagation:updated so live
/map clients refresh.

Cron hooked up every 10 minutes in config.exs and dev.exs. Also cleaned
up the stale "dev has propagation disabled" note in CLAUDE.md.

13 new AsosNudge unit tests cover: residual computation (co-located,
out-of-grid, nil fields), IDW weighting (single station, far station,
two equidistant stations, nil component handling), upper-air
preservation, and the compute/3 entry point's shape and radius filter.

Drive-by Styler formatting touched a handful of unrelated files from
`mix format`.
2026-04-12 14:27:27 -05:00

159 lines
5.7 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, [%{lat: 32.0, lon: -97.0, score: 75}]} =
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 "fetch_point/4" do
test "returns :miss when nothing cached" do
assert ScoreCache.fetch_point(10_000, ~U[2026-04-12 12:00:00Z], 32.0, -97.0) == :miss
end
test "returns :miss when the point is not in the cached grid" do
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [
%{lat: 32.0, lon: -97.0, score: 75}
])
assert ScoreCache.fetch_point(10_000, ~U[2026-04-12 12:00:00Z], 40.0, -74.0) == :miss
end
test "returns the score for a cached point" do
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [
%{lat: 32.0, lon: -97.0, score: 75},
%{lat: 33.0, lon: -97.0, score: 80}
])
assert {:ok, 75} = ScoreCache.fetch_point(10_000, ~U[2026-04-12 12:00:00Z], 32.0, -97.0)
assert {:ok, 80} = ScoreCache.fetch_point(10_000, ~U[2026-04-12 12:00:00Z], 33.0, -97.0)
end
end
describe "valid_times/1" do
test "returns empty list when nothing cached" do
assert ScoreCache.valid_times(10_000) == []
end
test "returns sorted valid_times for a band" do
ScoreCache.put(10_000, ~U[2026-04-12 14:00:00Z], [])
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [])
ScoreCache.put(10_000, ~U[2026-04-12 13:00:00Z], [])
assert ScoreCache.valid_times(10_000) == [
~U[2026-04-12 12:00:00Z],
~U[2026-04-12 13:00:00Z],
~U[2026-04-12 14:00:00Z]
]
end
test "only returns times for the requested band" do
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [])
ScoreCache.put(24_000, ~U[2026-04-12 13:00:00Z], [])
assert ScoreCache.valid_times(10_000) == [~U[2026-04-12 12:00:00Z]]
assert ScoreCache.valid_times(24_000) == [~U[2026-04-12 13: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, [%{lat: 32.0, lon: -97.0, score: 80}]} =
ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
end
end
end