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`.
159 lines
5.7 KiB
Elixir
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
|