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.
165 lines
4.9 KiB
Elixir
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
|