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