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.
174 lines
5.1 KiB
Elixir
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
|