prop/test/microwaveprop/propagation/band_weights_test.exs
Graham McIntire daafa5a02a
perf(algo): unify recalibration into single Python pipeline with JSON output
Replace the two-script Python pipeline (analysis report + Elixir-source
emitter) with a single `scripts/recalibrate.py` that fits per-band
weights from PSKR spot density (VHF/UHF) and contacts↔HRRR correlations
(microwave), writing `priv/algo/band_weights.json` as a machine-readable
artifact. A new Elixir BandWeights module loads this JSON once via
`:persistent_term` cache; BandConfig.weights/1 consults it before falling
back to in-source overrides or global defaults. The script never touches
Elixir source — recalibration is now `python3 scripts/recalibrate.py`
followed by an app restart.
2026-05-25 14:45:55 -05:00

165 lines
4.9 KiB
Elixir

defmodule Microwaveprop.Propagation.BandWeightsTest do
# async: false because BandWeights caches in :persistent_term, which is
# global to the BEAM. Each test resets the cache in setup so ordering
# is irrelevant, but concurrent runs would still see each other.
use ExUnit.Case, async: false
alias Microwaveprop.Propagation.BandWeights
@valid_json """
{
"generated_at": "2026-05-24T18:53:32+00:00",
"schema_version": 1,
"global_weights": {
"humidity": 0.1262, "time_of_day": 0.038, "td_depression": 0.101,
"refractivity": 0.0986, "sky": 0.0841, "season": 0.1134,
"wind": 0.0841, "rain": 0.1431, "pwat": 0.1147, "pressure": 0.0967
},
"band_overrides": {
"10000": {
"weights": {
"humidity": 0.20, "time_of_day": 0.04, "td_depression": 0.10,
"refractivity": 0.10, "sky": 0.08, "season": 0.11,
"wind": 0.08, "rain": 0.14, "pwat": 0.11, "pressure": 0.04
},
"n_samples": 54161,
"source": "contacts"
},
"144": {
"weights": {
"humidity": 0.18, "time_of_day": 0.02, "td_depression": 0.15,
"refractivity": 0.11, "sky": 0.06, "season": 0.12,
"wind": 0.06, "rain": 0.01, "pwat": 0.17, "pressure": 0.12
},
"n_samples": 12927,
"source": "pskr"
}
}
}
"""
setup do
# Cache is global to the BEAM; reset before every test to avoid
# bleed between cases.
BandWeights.reset()
prior_env = Application.get_env(:microwaveprop, :band_weights_json)
on_exit(fn ->
BandWeights.reset()
if prior_env do
Application.put_env(:microwaveprop, :band_weights_json, prior_env)
else
Application.delete_env(:microwaveprop, :band_weights_json)
end
end)
:ok
end
describe "lookup/1 with a valid JSON file" do
setup do
path = Path.join(System.tmp_dir!(), "band_weights_#{System.unique_integer([:positive])}.json")
File.write!(path, @valid_json)
Application.put_env(:microwaveprop, :band_weights_json, path)
on_exit(fn -> File.rm(path) end)
%{path: path}
end
test "returns the override weights for a band present in the file" do
result = BandWeights.lookup(10_000)
assert is_map(result)
assert result.humidity == 0.20
assert result.pressure == 0.04
# All 10 keys present.
expected_keys =
MapSet.new([
:humidity,
:time_of_day,
:td_depression,
:refractivity,
:sky,
:season,
:wind,
:rain,
:pwat,
:pressure
])
assert MapSet.new(Map.keys(result)) == expected_keys
end
test "returns the override weights for a second band" do
result = BandWeights.lookup(144)
assert result.humidity == 0.18
end
test "returns nil for a band not present in the file" do
assert BandWeights.lookup(75_000) == nil
end
test "is cheap on repeat calls (cached)" do
first = BandWeights.lookup(10_000)
# Delete the source file — second call must still succeed from cache.
File.rm!(Application.fetch_env!(:microwaveprop, :band_weights_json))
second = BandWeights.lookup(10_000)
assert first == second
end
end
describe "lookup/1 when the file is missing" do
setup do
Application.put_env(:microwaveprop, :band_weights_json, "/nonexistent/path/band_weights.json")
:ok
end
test "returns nil for every band (does not raise)" do
assert BandWeights.lookup(10_000) == nil
assert BandWeights.lookup(50) == nil
end
end
describe "lookup/1 when the loader is disabled in config" do
setup do
Application.put_env(:microwaveprop, :band_weights_json, false)
:ok
end
test "returns nil for every band" do
assert BandWeights.lookup(10_000) == nil
assert BandWeights.lookup(144) == nil
end
end
describe "lookup/1 with malformed JSON" do
setup do
path = Path.join(System.tmp_dir!(), "band_weights_bad_#{System.unique_integer([:positive])}.json")
File.write!(path, "{ not valid json")
Application.put_env(:microwaveprop, :band_weights_json, path)
on_exit(fn -> File.rm(path) end)
%{path: path}
end
test "returns nil for every band (logs but does not raise)" do
assert BandWeights.lookup(10_000) == nil
end
end
describe "all_overrides/0" do
setup do
path = Path.join(System.tmp_dir!(), "band_weights_#{System.unique_integer([:positive])}.json")
File.write!(path, @valid_json)
Application.put_env(:microwaveprop, :band_weights_json, path)
on_exit(fn -> File.rm(path) end)
:ok
end
test "returns a map of all band overrides keyed by freq_mhz integer" do
result = BandWeights.all_overrides()
assert is_map(result)
assert Map.has_key?(result, 10_000)
assert Map.has_key?(result, 144)
assert result[10_000].humidity == 0.20
end
end
end