prop/lib/microwaveprop/propagation/band_weights.ex
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

174 lines
5.1 KiB
Elixir

defmodule Microwaveprop.Propagation.BandWeights do
@moduledoc """
Loads per-band scoring-weight overrides from `priv/algo/band_weights.json`.
The JSON file is the authoritative output of `scripts/recalibrate.py`,
a Python pipeline that fits per-band weights from PSKR spot density
(VHF/UHF) and `contacts` ↔ `hrrr_profiles` correlations (microwave).
Keeping the fit results in a data file means recalibration runs never
have to modify Elixir source — the script writes JSON, this module
reads it, and `BandConfig.weights/1` consults this lookup before
falling back to in-source `:weights` overrides or the global default.
## Configuration
config :microwaveprop, :band_weights_json, "priv/algo/band_weights.json"
Set the value to `false` (or `nil`) to disable the loader entirely —
every `lookup/1` then returns `nil`. Useful for tests that want to
pin the scorer to the in-source defaults.
## Caching
Reads happen once per file path and cache in `:persistent_term`. A
missing file, malformed JSON, or disabled config all cache an empty
override map so a single failure doesn't cause repeated I/O.
## File schema (v1)
{
"schema_version": 1,
"band_overrides": {
"10000": {
"weights": {
"humidity": 0.20, "time_of_day": 0.04, ...
},
"n_samples": 54161,
"source": "contacts"
}
}
}
Keys in the inner `weights` map are stringified atoms — the loader
converts them with `String.to_existing_atom/1`, which is safe because
`BandConfig` already references every factor at compile time.
"""
require Logger
@cache_key __MODULE__
@factor_keys ~w(humidity time_of_day td_depression refractivity sky season wind rain pwat pressure)a
@doc """
Returns the override weights map for the given band in MHz, or `nil`
if the band has no override (or the loader is disabled / failed).
"""
@spec lookup(pos_integer()) :: map() | nil
def lookup(freq_mhz) when is_integer(freq_mhz) do
Map.get(ensure_loaded(), freq_mhz)
end
@doc """
Returns every override the file defined, keyed by integer freq_mhz.
Mostly for diagnostics — `BandConfig.weights/1` uses `lookup/1`.
"""
@spec all_overrides() :: %{pos_integer() => map()}
def all_overrides, do: ensure_loaded()
@doc """
Clears the persistent_term cache. Tests call this in setup; production
code should never need it because the JSON file is read-only at runtime.
"""
@spec reset() :: :ok
def reset do
_ = :persistent_term.erase(@cache_key)
:ok
end
# ── Lazy loader ─────────────────────────────────────────────────
defp ensure_loaded do
case :persistent_term.get(@cache_key, :not_loaded) do
:not_loaded ->
overrides = load_from_disk()
:persistent_term.put(@cache_key, overrides)
overrides
cached ->
cached
end
end
defp load_from_disk do
case configured_path() do
nil ->
%{}
path when is_binary(path) ->
read_and_parse(path)
end
end
defp configured_path do
case Application.get_env(:microwaveprop, :band_weights_json, default_path()) do
false -> nil
nil -> nil
path when is_binary(path) -> expand(path)
end
end
defp default_path, do: "priv/algo/band_weights.json"
# Resolve a relative path against the app's priv_dir so the file ships
# with a release. Absolute paths pass through unchanged so tests can
# point at a tmp file.
defp expand(path) do
cond do
Path.type(path) == :absolute ->
path
String.starts_with?(path, "priv/") ->
Path.join(:code.priv_dir(:microwaveprop), String.replace_prefix(path, "priv/", ""))
true ->
path
end
end
defp read_and_parse(path) do
with {:ok, body} <- File.read(path),
{:ok, %{} = json} <- Jason.decode(body) do
parse_overrides(json)
else
{:error, :enoent} ->
Logger.info("BandWeights: #{path} not present, using in-source defaults")
%{}
{:error, reason} ->
Logger.warning("BandWeights: failed to load #{path}: #{inspect(reason)}")
%{}
end
end
defp parse_overrides(%{"band_overrides" => overrides}) when is_map(overrides) do
for {band_str, %{"weights" => weights}} <- overrides,
{freq, ""} <- [Integer.parse(band_str)],
parsed = parse_weights(weights),
not is_nil(parsed),
into: %{} do
{freq, parsed}
end
end
defp parse_overrides(_), do: %{}
defp parse_weights(weights) when is_map(weights) do
parsed =
for k <- @factor_keys, into: %{} do
case Map.fetch(weights, Atom.to_string(k)) do
{:ok, v} when is_number(v) -> {k, v / 1}
_ -> {k, nil}
end
end
if Enum.any?(parsed, fn {_, v} -> is_nil(v) end) do
Logger.warning("BandWeights: skipping override missing factors: #{inspect(Map.keys(weights))}")
nil
else
parsed
end
end
defp parse_weights(_), do: nil
end