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.
This commit is contained in:
Graham McIntire 2026-05-25 14:45:55 -05:00
parent 580b9e781b
commit daafa5a02a
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
10 changed files with 1297 additions and 1201 deletions

View file

@ -136,6 +136,12 @@ config :microwaveprop, Oban,
]}
]
# Per-band scoring weights file produced by `scripts/recalibrate.py`.
# Read once at app start by `Microwaveprop.Propagation.BandWeights` and
# cached in `:persistent_term`. Set to `false` to ignore the file and
# fall back to in-source `:weights` overrides + `@weights` defaults.
config :microwaveprop, :band_weights_json, "priv/algo/band_weights.json"
config :microwaveprop, :scopes,
user: [
default: true,

View file

@ -64,6 +64,10 @@ config :microwaveprop, MicrowavepropWeb.Endpoint,
# Run Oban jobs inline during tests
config :microwaveprop, Oban, testing: :inline
# Pin the scorer to in-source weights in test. Individual tests that
# need the JSON loader (`BandWeightsTest`) override this per-case.
config :microwaveprop, :band_weights_json, false
# Skip the registration-time QRZ lookup in tests — it needs a Mox stub
# that isn't set up across the test suite, and the worker is exercised
# directly in its own dedicated test.

View file

@ -0,0 +1,56 @@
# Full Recalibration Report — 2026-05-24
> Auto-generated by `scripts/recalibrate.py`. The companion JSON file at `priv/algo/band_weights.json` is the machine-readable output that Elixir loads at runtime. This report is the human-readable audit trail.
Connection: `postgres://prop:***@10.0.15.30:5432/prop`
Generated: `2026-05-24T18:53:32+00:00`
## Data sources
- **PSKR** (VHF/UHF, label = `spot_count`): bands [50, 144, 432, 1296, 2304, 10000], 386,782 samples
- **Contacts ↔ HRRR** (microwave, label = `distance_km`): 12 bands, 81,571 matched contacts
## Per-band evidence
| Band | Source | n | label avg | ρ_tc | ρ_dpc | ρ_pr | ρ_pwat | ρ_grad |
|------|--------|---:|----------:|-----:|------:|-----:|-------:|-------:|
| 50 MHz | pskr | 372,468 | 1.80 | -0.013 | +0.014 | +0.025 | +0.023 | +0.000 |
| 144 MHz | pskr | 12,927 | 4.41 | +0.184 | +0.223 | +0.163 | +0.209 | -0.073 |
| 222 MHz | contacts | 7,518 | 229.14 | -0.081 | +0.077 | -0.014 | +0.068 | +0.039 |
| 432 MHz | pskr | 975 | 1.54 | +0.260 | -0.047 | +0.085 | -0.052 | +0.141 |
| 902 MHz | contacts | 1,754 | 172.14 | -0.070 | +0.097 | +0.080 | +0.051 | -0.038 |
| 1296 MHz | contacts | 2,951 | 160.95 | -0.041 | +0.102 | +0.025 | +0.053 | -0.080 |
| 2304 MHz | contacts | 719 | 154.51 | -0.060 | +0.162 | +0.076 | +0.130 | -0.114 |
| 3400 MHz | contacts | 335 | 153.71 | -0.062 | +0.155 | -0.027 | +0.150 | +0.063 |
| 5760 MHz | contacts | 321 | 127.55 | -0.065 | +0.075 | +0.153 | +0.099 | -0.012 |
| 10000 MHz | contacts | 54,161 | 209.29 | -0.004 | -0.021 | -0.085 | -0.029 | +0.030 |
| 24000 MHz | contacts | 3,808 | 95.19 | -0.210 | -0.334 | -0.217 | -0.360 | +0.018 |
| 47000 MHz | contacts | 759 | 62.72 | -0.114 | -0.216 | -0.253 | -0.233 | -0.023 |
| 75000 MHz | contacts | 109 | 47.53 | -0.680 | -0.699 | -0.768 | -0.620 | +0.434 |
| 122000 MHz | contacts | 63 | 16.26 | +0.039 | -0.129 | -0.041 | -0.127 | +0.355 |
## Derived per-band weights
| Band | n | source | humidity | time_of_day | td_depression | refractivity | sky | season | wind | rain | pwat | pressure |
|------|---:|--------|----:|----:|----:|----:|----:|----:|----:|----:|----:|----:|
| 50 MHz | 372,468 | pskr | 0.1352 | 0.0271 | 0.1415 | 0.0736 | 0.0999 | 0.2156 | 0.0999 | 0.0170 | 0.1263 | 0.0639 |
| 144 MHz | 12,927 | pskr | 0.1862 | 0.0196 | 0.1491 | 0.1118 | 0.0621 | 0.1172 | 0.0621 | 0.0106 | 0.1693 | 0.1121 |
| 222 MHz | 7,518 | contacts | 0.2012 | 0.0272 | 0.1806 | 0.0986 | 0.0752 | 0.1318 | 0.0752 | 0.0128 | 0.1541 | 0.0432 |
| 432 MHz | 975 | pskr | 0.1472 | 0.0261 | 0.1735 | 0.1694 | 0.0722 | 0.1169 | 0.0722 | 0.0123 | 0.1272 | 0.0830 |
| 902 MHz | 1,754 | contacts | 0.2206 | 0.0299 | 0.1765 | 0.0955 | 0.0735 | 0.1090 | 0.0735 | 0.0125 | 0.1279 | 0.0810 |
| 1296 MHz | 2,951 | contacts | 0.2255 | 0.0339 | 0.1519 | 0.1433 | 0.0751 | 0.1013 | 0.0751 | 0.0128 | 0.1334 | 0.0476 |
| 2304 MHz | 719 | contacts | 0.1890 | 0.0285 | 0.1512 | 0.1477 | 0.0630 | 0.0849 | 0.0630 | 0.0339 | 0.1718 | 0.0672 |
| 3400 MHz | 335 | contacts | 0.2000 | 0.0301 | 0.1601 | 0.1107 | 0.0666 | 0.0899 | 0.0666 | 0.0507 | 0.1818 | 0.0434 |
| 5760 MHz | 321 | contacts | 0.1719 | 0.0297 | 0.1577 | 0.0599 | 0.0656 | 0.0885 | 0.0656 | 0.0790 | 0.1689 | 0.1133 |
| 10000 MHz | 54,161 | contacts | 0.1262 | 0.0380 | 0.1010 | 0.0986 | 0.0841 | 0.1134 | 0.0841 | 0.1431 | 0.1147 | 0.0967 |
| 24000 MHz | 3,808 | contacts | 0.1485 | 0.0403 | 0.1189 | 0.0498 | 0.0495 | 0.0734 | 0.0495 | 0.2228 | 0.1350 | 0.1123 |
| 47000 MHz | 759 | contacts | 0.1406 | 0.0529 | 0.1125 | 0.0500 | 0.0468 | 0.0758 | 0.0468 | 0.2391 | 0.1278 | 0.1077 |
## Changes vs prior `band_weights.json`
_No prior file, or no factor moved by ≥0.005. First-run values can be compared to the in-tree `@band_configs` weights in `lib/microwaveprop/propagation/band_config.ex`._
## Applying these weights
This script writes `priv/algo/band_weights.json` and stops there — it never edits `band_config.ex`. To make the weights live, `BandConfig.weights(band_mhz)` needs a one-time edit to read this JSON at app start (cache in `:persistent_term`) and merge `band_overrides[band].weights` over the in-source defaults. Until then, the JSON is documentation only.

View file

@ -7,6 +7,8 @@ defmodule Microwaveprop.Propagation.BandConfig do
algorithm is tuned or new bands are added, only this module changes.
"""
alias Microwaveprop.Propagation.BandWeights
# Default weight vector. Originally fit by gradient descent on 5,000 QSOs
# 2026-04-11 (loss 0.42 → 0.12) and retained as the 10 GHz prior. Bands
# with ≥200 HRRR-matched contacts in the 2026-04-18 full-corpus
@ -873,16 +875,32 @@ defmodule Microwaveprop.Propagation.BandConfig do
@doc """
Returns the scoring weights for a specific band configuration.
Bands with enough contacts to support a stable gradient-descent fit
carry a `:weights` override map. Bands without it fall back to the
global defaults. `nil` is accepted so callers that don't know the band
can ask for defaults without an extra guard.
Resolution order:
1. `priv/algo/band_weights.json` via `BandWeights.lookup/1`
authoritative per-band fits produced by `scripts/recalibrate.py`.
2. In-source `:weights` override on the band config map legacy
snapshot of the 2026-04-18 fit, retained as a fallback.
3. Global `@weights` default.
`nil` is accepted so callers that don't know the band can ask for
defaults without an extra guard.
"""
@spec weights(map() | nil) :: map()
def weights(nil), do: @weights
def weights(%{freq_mhz: freq_mhz} = band_config) when is_integer(freq_mhz) do
case BandWeights.lookup(freq_mhz) do
nil -> in_source_or_default(band_config)
json_override -> json_override
end
end
def weights(%{weights: override}) when is_map(override), do: override
def weights(%{}), do: @weights
defp in_source_or_default(%{weights: override}) when is_map(override), do: override
defp in_source_or_default(_), do: @weights
@doc "Returns the 12-element sunrise hour table (Jan-Dec, local time)."
@spec sunrise_table() :: [float()]
def sunrise_table, do: @sunrise_table

View file

@ -0,0 +1,174 @@
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

241
priv/algo/band_weights.json Normal file
View file

@ -0,0 +1,241 @@
{
"generated_at": "2026-05-25T19:44:49+00:00",
"generated_by": "scripts/recalibrate.py",
"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
},
"data_sources": {
"pskr": {
"bands": [
50,
144,
432,
1296,
2304,
10000
],
"total_samples": 411619
},
"contacts": {
"bands": [
222,
432,
902,
1296,
2304,
3400,
5760,
10000,
24000,
47000,
75000,
122000
],
"total_samples": 81571
}
},
"band_overrides": {
"50": {
"weights": {
"humidity": 0.1252,
"time_of_day": 0.0273,
"td_depression": 0.1511,
"refractivity": 0.0764,
"sky": 0.1006,
"season": 0.2171,
"wind": 0.1006,
"rain": 0.0171,
"pwat": 0.1197,
"pressure": 0.0649
},
"n_samples": 396474,
"source": "pskr"
},
"144": {
"weights": {
"humidity": 0.1865,
"time_of_day": 0.0197,
"td_depression": 0.1492,
"refractivity": 0.1106,
"sky": 0.0621,
"season": 0.1173,
"wind": 0.0621,
"rain": 0.0106,
"pwat": 0.1695,
"pressure": 0.1125
},
"n_samples": 13710,
"source": "pskr"
},
"222": {
"weights": {
"humidity": 0.2002,
"time_of_day": 0.0271,
"td_depression": 0.1804,
"refractivity": 0.101,
"sky": 0.0751,
"season": 0.1316,
"wind": 0.0751,
"rain": 0.0128,
"pwat": 0.1535,
"pressure": 0.0432
},
"n_samples": 7518,
"source": "contacts"
},
"432": {
"weights": {
"humidity": 0.153,
"time_of_day": 0.0256,
"td_depression": 0.17,
"refractivity": 0.1659,
"sky": 0.0708,
"season": 0.1145,
"wind": 0.0708,
"rain": 0.012,
"pwat": 0.1376,
"pressure": 0.0799
},
"n_samples": 1013,
"source": "pskr"
},
"902": {
"weights": {
"humidity": 0.2204,
"time_of_day": 0.0299,
"td_depression": 0.1764,
"refractivity": 0.0967,
"sky": 0.0734,
"season": 0.1089,
"wind": 0.0734,
"rain": 0.0125,
"pwat": 0.1273,
"pressure": 0.081
},
"n_samples": 1754,
"source": "contacts"
},
"1296": {
"weights": {
"humidity": 0.2259,
"time_of_day": 0.034,
"td_depression": 0.1528,
"refractivity": 0.1429,
"sky": 0.0753,
"season": 0.1015,
"wind": 0.0753,
"rain": 0.0128,
"pwat": 0.1327,
"pressure": 0.0468
},
"n_samples": 2951,
"source": "contacts"
},
"2304": {
"weights": {
"humidity": 0.1886,
"time_of_day": 0.0284,
"td_depression": 0.1503,
"refractivity": 0.1474,
"sky": 0.0629,
"season": 0.0848,
"wind": 0.0629,
"rain": 0.0338,
"pwat": 0.1714,
"pressure": 0.0695
},
"n_samples": 719,
"source": "contacts"
},
"3400": {
"weights": {
"humidity": 0.2001,
"time_of_day": 0.0301,
"td_depression": 0.1601,
"refractivity": 0.1102,
"sky": 0.0667,
"season": 0.0899,
"wind": 0.0667,
"rain": 0.0507,
"pwat": 0.1818,
"pressure": 0.0437
},
"n_samples": 335,
"source": "contacts"
},
"5760": {
"weights": {
"humidity": 0.1753,
"time_of_day": 0.0299,
"td_depression": 0.1589,
"refractivity": 0.0491,
"sky": 0.0661,
"season": 0.0892,
"wind": 0.0661,
"rain": 0.0796,
"pwat": 0.1708,
"pressure": 0.1151
},
"n_samples": 321,
"source": "contacts"
},
"10000": {
"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
},
"n_samples": 54161,
"source": "contacts"
},
"24000": {
"weights": {
"humidity": 0.1483,
"time_of_day": 0.0402,
"td_depression": 0.1187,
"refractivity": 0.0517,
"sky": 0.0494,
"season": 0.0733,
"wind": 0.0494,
"rain": 0.2224,
"pwat": 0.1348,
"pressure": 0.112
},
"n_samples": 3808,
"source": "contacts"
},
"47000": {
"weights": {
"humidity": 0.1403,
"time_of_day": 0.0528,
"td_depression": 0.1123,
"refractivity": 0.0517,
"sky": 0.0468,
"season": 0.0757,
"wind": 0.0468,
"rain": 0.2387,
"pwat": 0.1275,
"pressure": 0.1075
},
"n_samples": 759,
"source": "contacts"
}
}
}

View file

@ -1,281 +0,0 @@
#!/usr/bin/env python3
"""
Per-band weight derivation from the full-corpus correlation analysis.
Reads Pearson correlations that `recalibrate_algo.py` wrote into the DB
and emits a `@band_weight_overrides` Elixir map ready to drop into
`lib/microwaveprop/propagation/band_config.ex`.
The derivation rule is deliberately simple and legible, because the
correlations are noisy (|r| typically 0.050.25) and we'd rather preserve
the globally-fit default weights as a prior than over-fit per-band.
Rule:
1. Start from the default weight vector (gradient-descent fit, global).
2. Compute a per-factor "relative signal strength" ratio
s_band,factor = |r_band,factor| / |r_10GHz,factor|
with a small ε floor so zero-correlation factors don't collapse.
3. Clamp the ratio to [0.4, 2.5] noisy per-band correlations shouldn't
move a factor more than 2.5× or less than 0.4× the prior.
4. Factors we don't have direct correlations for (sky, wind, rain,
season, time_of_day) carry a physics-informed multiplier per band:
* rain scales with `rain_k` (ITU-R P.838 specific attenuation
coefficient) normalized to 10 GHz's rain_k.
* season scales up at VHF/low-UHF where Es-like physics amplifies
monthly variation (we don't model Es directly).
* time_of_day scales with frequency (Finding 8 in algo.md).
* sky/wind stay flat no per-band evidence either direction.
5. Multiply default weights by the multipliers, then normalize to 1.0.
The output goes to stdout as Elixir source ready to paste into BandConfig.
"""
from __future__ import annotations
import argparse
import os
import sys
from dataclasses import dataclass
try:
import psycopg
from psycopg.rows import dict_row
except ImportError:
sys.exit("psycopg is required: pip install 'psycopg[binary]>=3.1'")
# Default weights (must match `@weights` in band_config.ex). These are the
# globally-fit gradient-descent priors — the per-band deltas modulate them.
DEFAULT_WEIGHTS = {
"rain": 0.1362,
"humidity": 0.1243,
"pwat": 0.1128,
"season": 0.1112,
"refractivity": 0.1049,
"pressure": 0.1032,
"td_depression": 0.0978,
"sky": 0.08,
"wind": 0.08,
"time_of_day": 0.0496,
}
# Correlation-fed factor → source-field mapping. These factors get data-
# driven per-band multipliers; the rest get physics-informed multipliers.
FACTOR_SOURCE = {
"humidity": "dpc", # dewpoint carries the moisture signal
"td_depression": "tc", # temperature dominates T-Td variance
"refractivity": "grad", # refractivity gradient
"pressure": "pr",
"pwat": "pwat",
}
# ITU-R P.838-3 rain_k values (per BandConfig). Used for `rain` weight
# scaling — rain attenuation grows by ~4 orders of magnitude from VHF
# to sub-mm, so `rain` weight should track that.
BAND_RAIN_K = {
50: 0.0, 144: 0.0, 222: 0.0, 432: 0.0,
902: 0.0, 1_296: 0.0,
2_304: 0.001, 3_400: 0.002, 5_760: 0.005,
10_000: 0.010, 24_000: 0.070, 47_000: 0.187,
68_000: 0.310, 75_000: 0.345, 122_000: 0.498,
134_000: 0.520, 142_000: 0.530, 145_000: 0.535,
241_000: 0.550, 288_000: 0.560, 322_000: 0.570,
403_000: 0.580, 411_000: 0.580,
}
# Season weight multiplier by band. VHF has large seasonal swings driven
# by tropo-mixing physics (summer peak). Microwave seasonal swings are
# contest-schedule artefacts but the physics (summer convective mixing
# hurting 24+ GHz) still applies. No per-band evidence in the correlation
# report so we use physics priors.
BAND_SEASON_MULT = {
50: 1.6, 144: 1.4, 222: 1.3, 432: 1.2,
902: 1.1, 1_296: 1.0,
2_304: 1.0, 3_400: 1.0, 5_760: 1.0,
10_000: 1.0, 24_000: 1.1, 47_000: 1.2,
68_000: 1.3, 75_000: 1.4, 122_000: 1.5, 134_000: 1.5,
142_000: 1.5, 145_000: 1.5, 241_000: 1.6, 288_000: 1.6,
322_000: 1.7, 403_000: 1.7, 411_000: 1.7,
}
# Time-of-day multiplier by band (Finding 8 in algo.md — effect scales
# with frequency). 10 GHz is the baseline.
BAND_TOD_MULT = {
50: 0.6, 144: 0.7, 222: 0.8, 432: 0.8,
902: 0.9, 1_296: 1.0,
2_304: 1.0, 3_400: 1.0, 5_760: 1.0,
10_000: 1.0, 24_000: 1.8, 47_000: 2.5,
68_000: 3.5, 75_000: 4.0,
122_000: 5.0, 134_000: 5.0, 142_000: 5.0, 145_000: 5.0,
241_000: 5.0, 288_000: 5.0, 322_000: 5.0, 403_000: 5.0,
411_000: 5.0,
}
# EPS sets an absolute floor on |r| so two near-zero correlations don't
# produce a giant ratio. Set at the noise floor we can reliably distinguish
# from zero given typical n. Tightened along with the clamp so small
# corpora don't drag the weights around.
EPS = 0.05
CLAMP_LO = 0.5
CLAMP_HI = 2.0
MIN_N_FOR_FIT = 200 # bands with fewer matched contacts use physics only
@dataclass
class BandCorr:
band_mhz: int
n: int
rho: dict[str, float]
PER_BAND_SQL = """
WITH joined AS (
SELECT DISTINCT ON (c.id)
c.id, c.band::int AS band, c.distance_km::float AS dist,
h.surface_temp_c AS tc, h.surface_dewpoint_c AS dpc,
h.surface_pressure_mb AS pr, h.pwat_mm AS pwat,
h.min_refractivity_gradient AS grad
FROM contacts c
JOIN hrrr_profiles h
ON h.lat BETWEEN (c.pos1->>'lat')::float - 0.07
AND (c.pos1->>'lat')::float + 0.07
AND h.lon BETWEEN (c.pos1->>'lon')::float - 0.07
AND (c.pos1->>'lon')::float + 0.07
AND h.valid_time BETWEEN c.qso_timestamp - INTERVAL '1 hour'
AND c.qso_timestamp + INTERVAL '1 hour'
WHERE c.pos1 IS NOT NULL AND c.distance_km < 3000
AND c.flagged_invalid = false
ORDER BY c.id, ABS(EXTRACT(EPOCH FROM h.valid_time - c.qso_timestamp))
)
SELECT band,
count(*) AS n,
CORR(dist, tc) AS rho_tc,
CORR(dist, dpc) AS rho_dpc,
CORR(dist, pr) AS rho_pr,
CORR(dist, pwat) AS rho_pwat,
CORR(dist, grad) AS rho_grad
FROM joined
WHERE band >= 50
GROUP BY band
HAVING count(*) >= 50
ORDER BY band;
"""
def load_correlations(dsn: str) -> dict[int, BandCorr]:
with psycopg.connect(dsn) as conn:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute("SET statement_timeout = '30min'")
cur.execute(PER_BAND_SQL)
rows = cur.fetchall()
out = {}
for r in rows:
out[int(r["band"])] = BandCorr(
band_mhz=int(r["band"]),
n=int(r["n"]),
rho={
"tc": r["rho_tc"] or 0.0,
"dpc": r["rho_dpc"] or 0.0,
"pr": r["rho_pr"] or 0.0,
"pwat": r["rho_pwat"] or 0.0,
"grad": r["rho_grad"] or 0.0,
},
)
return out
def derive_weights(
band_mhz: int, corrs: dict[int, BandCorr]
) -> dict[str, float] | None:
"""Returns a normalized weight map or None if the band should use defaults."""
band_corr = corrs.get(band_mhz)
ref_corr = corrs.get(10_000)
if band_corr is None or band_corr.n < MIN_N_FOR_FIT or ref_corr is None:
return None
multipliers = {}
# Data-driven multipliers for correlation-backed factors.
for factor, field in FACTOR_SOURCE.items():
band_r = abs(band_corr.rho[field]) + EPS
ref_r = abs(ref_corr.rho[field]) + EPS
ratio = band_r / ref_r
multipliers[factor] = max(CLAMP_LO, min(CLAMP_HI, ratio))
# Physics-driven multipliers for the rest.
# Rain: weight tracks rain_k but with sqrt-dampening. A 7× rain_k jump
# (10→24 GHz) should not become a 7× weight jump — the score itself
# already scales with rain_k via ITU-R P.838 in `score_rain`, so the
# weight only needs to capture how often rain drives the outcome.
rain_k = BAND_RAIN_K.get(band_mhz, 0.010)
rain_k_10g = BAND_RAIN_K[10_000]
if rain_k > 0:
ratio = rain_k / rain_k_10g
rain_mult = max(0.2, min(3.0, ratio**0.5))
else:
rain_mult = 0.1
multipliers["rain"] = rain_mult
multipliers["season"] = BAND_SEASON_MULT.get(band_mhz, 1.0)
multipliers["time_of_day"] = BAND_TOD_MULT.get(band_mhz, 1.0)
multipliers["sky"] = 1.0
multipliers["wind"] = 1.0
# Apply and normalize.
raw = {k: DEFAULT_WEIGHTS[k] * multipliers[k] for k in DEFAULT_WEIGHTS}
total = sum(raw.values())
return {k: round(v / total, 4) for k, v in raw.items()}
def format_elixir(band_mhz: int, weights: dict[str, float]) -> str:
# Preserve the factor ordering used in band_config.ex comments.
order = [
"humidity", "time_of_day", "td_depression", "refractivity",
"sky", "season", "wind", "rain", "pwat", "pressure",
]
lines = [f" {k}: {weights[k]:.4f}" for k in order]
inner = ",\n".join(lines)
return f" weights: %{{\n{inner}\n }}"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dsn", default=os.environ.get("PROP_DB_URL"))
args = parser.parse_args()
if not args.dsn:
print("error: PROP_DB_URL not set and --dsn not given", file=sys.stderr)
return 2
print(f"loading correlations from {args.dsn}", file=sys.stderr)
corrs = load_correlations(args.dsn)
print(
f"correlations available for {len(corrs)} bands: "
f"{sorted(corrs.keys())}",
file=sys.stderr,
)
# Every band in BandConfig — we emit an entry for each so the reader
# sees at a glance which bands got fit and which inherited defaults.
all_bands = sorted(set(BAND_RAIN_K.keys()) | set(corrs.keys()))
sections = []
for band in all_bands:
weights = derive_weights(band, corrs)
band_corr = corrs.get(band)
n = band_corr.n if band_corr else 0
if weights is None:
sections.append(
f"# {band} MHz — n={n}, uses default @weights (insufficient signal)"
)
else:
sections.append(
f"# {band} MHz — n={n}, derived from corpus correlations\n"
f"{format_elixir(band, weights)}"
)
print("\n\n".join(sections))
return 0
if __name__ == "__main__":
raise SystemExit(main())

629
scripts/recalibrate.py Executable file
View file

@ -0,0 +1,629 @@
#!/usr/bin/env python3
"""
Full algorithm recalibration unified Python pipeline.
Recomputes per-band weight overrides for the 10-factor scorer from real
propagation evidence in the prod DB:
* VHF / UHF (50, 144, 432 MHz): Pearson correlations between PSKR
`spot_count` density and HRRR features in `pskr_calibration_samples`
(~520k samples since 2026-05-04 in prod).
* Microwave (>=902 MHz): Pearson correlations between QSO `distance_km`
and HRRR features in `contacts` joined to `hrrr_profiles`.
Weight derivation (per band):
1. Start from the globally-fit default weight vector (`DEFAULT_WEIGHTS`).
2. Per correlation-backed factor, build a "relative signal strength"
multiplier = (|band_r| + EPS) / (|ref_r| + EPS), clamped to
[CLAMP_LO, CLAMP_HI]. The reference is the 10 GHz contacts fit
because it has the densest evidence in the corpus.
3. Factors without per-band correlations (rain, season, time_of_day,
sky, wind) get physics-informed multipliers same priors used by
the legacy `derive_band_weights.py`.
4. Multiply defaults by multipliers, normalize to sum = 1.0.
Outputs (no Elixir source ever modified):
* priv/algo/band_weights.json machine-readable per-band overrides
that BandConfig can load at runtime via Jason.
* docs/algo-reports/{today}-full-recalibration.md human report
with per-band correlations, sample counts, derived weights, and
a "what moved" diff against the previous JSON file (if present).
Usage:
direnv allow # picks up PROP_PROD_DB_URL
python3 scripts/recalibrate.py # writes JSON + report
python3 scripts/recalibrate.py --dry-run # prints JSON to stdout
The script is self-contained: psycopg + stdlib only (no pandas, no
numpy). Run it whenever the contact or PSKR corpus grows materially.
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import sys
from dataclasses import dataclass, field
from pathlib import Path
try:
import psycopg
from psycopg.rows import dict_row
except ImportError:
sys.exit("psycopg is required: pip install 'psycopg[binary]>=3.1'")
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_JSON_OUT = REPO_ROOT / "priv" / "algo" / "band_weights.json"
DEFAULT_REPORT_DIR = REPO_ROOT / "docs" / "algo-reports"
# ── Constants ────────────────────────────────────────────────────────────────
# Must match `@weights` in lib/microwaveprop/propagation/band_config.ex.
# These are the globally-fit gradient-descent priors (2026-04-11) that
# every per-band fit modulates.
DEFAULT_WEIGHTS: dict[str, float] = {
"humidity": 0.1262,
"time_of_day": 0.0380,
"td_depression": 0.1010,
"refractivity": 0.0986,
"sky": 0.0841,
"season": 0.1134,
"wind": 0.0841,
"rain": 0.1431,
"pwat": 0.1147,
"pressure": 0.0967,
}
# Correlation-fed factor → HRRR field. These factors get data-driven
# multipliers per band. The rest get physics priors.
FACTOR_SOURCE: dict[str, str] = {
"humidity": "dpc", # dewpoint carries the moisture signal
"td_depression": "tc", # temperature dominates T-Td variance
"refractivity": "grad", # refractivity gradient
"pressure": "pr",
"pwat": "pwat",
}
# ITU-R P.838-3 rain_k (per BandConfig). Used to scale the `rain` weight
# by a sqrt-dampened ratio against 10 GHz (the score itself already
# scales by rain_k internally).
BAND_RAIN_K: dict[int, float] = {
50: 0.0, 144: 0.0, 222: 0.0, 432: 0.0,
902: 0.0, 1_296: 0.0,
2_304: 0.001, 3_400: 0.002, 5_760: 0.005,
10_000: 0.010, 24_000: 0.070, 47_000: 0.187,
68_000: 0.310, 75_000: 0.345,
122_000: 0.498, 134_000: 0.520, 142_000: 0.530, 145_000: 0.535,
241_000: 0.550, 288_000: 0.560, 322_000: 0.570,
403_000: 0.580, 411_000: 0.580,
}
# Season weight multiplier (physics prior — see algo.md and the legacy
# derive_band_weights.py). VHF has large seasonal swings (Es / tropo
# mixing); 24+ GHz peaks in winter due to dry-air absorption.
BAND_SEASON_MULT: dict[int, float] = {
50: 1.6, 144: 1.4, 222: 1.3, 432: 1.2,
902: 1.1, 1_296: 1.0,
2_304: 1.0, 3_400: 1.0, 5_760: 1.0,
10_000: 1.0, 24_000: 1.1, 47_000: 1.2,
68_000: 1.3, 75_000: 1.4,
122_000: 1.5, 134_000: 1.5, 142_000: 1.5, 145_000: 1.5,
241_000: 1.6, 288_000: 1.6, 322_000: 1.7, 403_000: 1.7, 411_000: 1.7,
}
# Time-of-day multiplier — Finding 8 in algo.md (effect scales with
# frequency). 10 GHz is the baseline at 1.0.
BAND_TOD_MULT: dict[int, float] = {
50: 0.6, 144: 0.7, 222: 0.8, 432: 0.8,
902: 0.9, 1_296: 1.0,
2_304: 1.0, 3_400: 1.0, 5_760: 1.0,
10_000: 1.0, 24_000: 1.8, 47_000: 2.5,
68_000: 3.5, 75_000: 4.0,
122_000: 5.0, 134_000: 5.0, 142_000: 5.0, 145_000: 5.0,
241_000: 5.0, 288_000: 5.0, 322_000: 5.0, 403_000: 5.0, 411_000: 5.0,
}
# PSKR `band` strings → MHz (PSKR scraper stores ham band labels).
PSKR_BAND_TO_MHZ: dict[str, int] = {
"6m": 50,
"2m": 144,
"70cm": 432,
"23cm": 1_296,
"13cm": 2_304,
"3cm": 10_000,
}
# Per-band data source routing. PSKR for VHF/UHF where spot density is
# rich and contacts are sparse; contacts for microwave where PSKR
# activity is rare and the contact corpus is dense.
PSKR_BANDS = {50, 144, 432}
# Floor on |r| (noise level we can distinguish from zero given typical n).
# Same as legacy derive_band_weights.py for continuity.
EPS = 0.05
CLAMP_LO = 0.5
CLAMP_HI = 2.0
MIN_N_FOR_FIT = 200
# Factor display order in JSON + Markdown. Matches band_config.ex
# comment ordering for diffability.
FACTOR_ORDER = [
"humidity", "time_of_day", "td_depression", "refractivity",
"sky", "season", "wind", "rain", "pwat", "pressure",
]
# ── Data structures ─────────────────────────────────────────────────────────
@dataclass
class BandCorr:
"""Per-band correlation snapshot, source-agnostic."""
band_mhz: int
source: str # "pskr" or "contacts"
n: int
rho: dict[str, float] = field(default_factory=dict)
# Optional per-band diagnostics for the report.
total_label: float | None = None
avg_label: float | None = None
# ── SQL ─────────────────────────────────────────────────────────────────────
# Contacts ↔ HRRR (microwave): per-band Pearson(distance_km, feature).
# Same nearest-neighbour join as the legacy recalibrate_algo.py.
CONTACTS_CORR_SQL = """
WITH joined AS (
SELECT DISTINCT ON (c.id)
c.id, c.band::int AS band, c.distance_km::float AS dist,
h.surface_temp_c AS tc, h.surface_dewpoint_c AS dpc,
h.surface_pressure_mb AS pr, h.pwat_mm AS pwat,
h.min_refractivity_gradient AS grad
FROM contacts c
JOIN hrrr_profiles h
ON h.lat BETWEEN (c.pos1->>'lat')::float - 0.07
AND (c.pos1->>'lat')::float + 0.07
AND h.lon BETWEEN (c.pos1->>'lon')::float - 0.07
AND (c.pos1->>'lon')::float + 0.07
AND h.valid_time BETWEEN c.qso_timestamp - INTERVAL '1 hour'
AND c.qso_timestamp + INTERVAL '1 hour'
WHERE c.pos1 IS NOT NULL AND c.distance_km < 3000
AND c.flagged_invalid = false
ORDER BY c.id, ABS(EXTRACT(EPOCH FROM h.valid_time - c.qso_timestamp))
)
SELECT band,
count(*)::int AS n,
CORR(dist, tc) AS rho_tc,
CORR(dist, dpc) AS rho_dpc,
CORR(dist, pr) AS rho_pr,
CORR(dist, pwat) AS rho_pwat,
CORR(dist, grad) AS rho_grad,
avg(dist)::float AS avg_dist
FROM joined
WHERE band >= 50
GROUP BY band
HAVING count(*) >= 50
ORDER BY band;
"""
# PSKR samples already carry HRRR fields (the PSKR sampler joins them at
# ingestion). Label is `spot_count` per (hour, midpoint, band) cell.
# Filter to HRRR-complete rows so all five correlations are computed
# from the same denominator.
PSKR_CORR_SQL = """
SELECT band,
count(*)::int AS n,
CORR(spot_count, surface_temp_c) AS rho_tc,
CORR(spot_count, surface_dewpoint_c) AS rho_dpc,
CORR(spot_count, surface_pressure_mb) AS rho_pr,
CORR(spot_count, pwat_mm) AS rho_pwat,
CORR(spot_count, min_refractivity_gradient) AS rho_grad,
sum(spot_count)::bigint AS total_spots,
avg(spot_count)::float AS avg_spots
FROM pskr_calibration_samples
WHERE surface_pressure_mb IS NOT NULL
AND min_refractivity_gradient IS NOT NULL
GROUP BY band
ORDER BY band;
"""
# ── Loaders ─────────────────────────────────────────────────────────────────
def _row_to_rho(row: dict) -> dict[str, float]:
return {
"tc": row["rho_tc"] or 0.0,
"dpc": row["rho_dpc"] or 0.0,
"pr": row["rho_pr"] or 0.0,
"pwat": row["rho_pwat"] or 0.0,
"grad": row["rho_grad"] or 0.0,
}
def load_contacts_correlations(conn) -> dict[int, BandCorr]:
print("• loading contacts ↔ HRRR correlations", file=sys.stderr)
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(CONTACTS_CORR_SQL)
rows = cur.fetchall()
out: dict[int, BandCorr] = {}
for r in rows:
band = int(r["band"])
out[band] = BandCorr(
band_mhz=band,
source="contacts",
n=int(r["n"]),
rho=_row_to_rho(r),
avg_label=r.get("avg_dist"),
)
return out
def load_pskr_correlations(conn) -> dict[int, BandCorr]:
print("• loading PSKR ↔ HRRR correlations", file=sys.stderr)
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(PSKR_CORR_SQL)
rows = cur.fetchall()
out: dict[int, BandCorr] = {}
for r in rows:
band_label = r["band"]
mhz = PSKR_BAND_TO_MHZ.get(band_label)
if mhz is None:
# Unknown PSKR band string — log and skip rather than guess.
print(f" (skipping unknown PSKR band {band_label!r})", file=sys.stderr)
continue
out[mhz] = BandCorr(
band_mhz=mhz,
source="pskr",
n=int(r["n"]),
rho=_row_to_rho(r),
total_label=float(r["total_spots"]) if r["total_spots"] is not None else None,
avg_label=r.get("avg_spots"),
)
return out
def merge_correlations(
pskr: dict[int, BandCorr],
contacts: dict[int, BandCorr],
) -> dict[int, BandCorr]:
"""Per-band routing: PSKR for VHF/UHF, contacts for everything else."""
out: dict[int, BandCorr] = {}
for band in set(pskr) | set(contacts):
if band in PSKR_BANDS and band in pskr:
out[band] = pskr[band]
elif band in contacts:
out[band] = contacts[band]
elif band in pskr:
# No contacts but PSKR has it (e.g. 23cm/13cm if we expand routing).
out[band] = pskr[band]
return out
# ── Weight derivation ──────────────────────────────────────────────────────
def derive_weights(
band_mhz: int,
corrs: dict[int, BandCorr],
) -> tuple[dict[str, float] | None, dict[str, float]]:
"""Returns (weights, multipliers). weights=None means inherit defaults.
`multipliers` is always populated so the report can show why a band
moved (or why it didn't qualify for a fit).
"""
band_corr = corrs.get(band_mhz)
# Reference is always the 10 GHz contacts fit — densest, most trusted.
ref_corr = corrs.get(10_000)
multipliers: dict[str, float] = {}
# Correlation-fed factors first (skipped if either side missing).
if band_corr is not None and ref_corr is not None:
for factor, field_name in FACTOR_SOURCE.items():
band_r = abs(band_corr.rho[field_name]) + EPS
ref_r = abs(ref_corr.rho[field_name]) + EPS
ratio = band_r / ref_r
multipliers[factor] = max(CLAMP_LO, min(CLAMP_HI, ratio))
else:
for factor in FACTOR_SOURCE:
multipliers[factor] = 1.0
# Physics priors for the rest.
rain_k = BAND_RAIN_K.get(band_mhz, 0.010)
rain_k_ref = BAND_RAIN_K[10_000]
if rain_k > 0:
multipliers["rain"] = max(0.2, min(3.0, (rain_k / rain_k_ref) ** 0.5))
else:
multipliers["rain"] = 0.1
multipliers["season"] = BAND_SEASON_MULT.get(band_mhz, 1.0)
multipliers["time_of_day"] = BAND_TOD_MULT.get(band_mhz, 1.0)
multipliers["sky"] = 1.0
multipliers["wind"] = 1.0
# No fit if we don't have enough data on this band.
if band_corr is None or band_corr.n < MIN_N_FOR_FIT:
return None, multipliers
raw = {k: DEFAULT_WEIGHTS[k] * multipliers[k] for k in DEFAULT_WEIGHTS}
total = sum(raw.values())
weights = {k: round(v / total, 4) for k, v in raw.items()}
return weights, multipliers
# ── Output ─────────────────────────────────────────────────────────────────
def build_json(
corrs: dict[int, BandCorr],
pskr_only: dict[int, BandCorr],
contacts_only: dict[int, BandCorr],
generated_at: dt.datetime,
) -> dict:
"""Stable JSON shape — keys sorted, numbers rounded for diffability."""
all_bands = sorted(set(BAND_RAIN_K.keys()) | set(corrs.keys()))
overrides: dict[str, dict] = {}
for band in all_bands:
weights, _mults = derive_weights(band, corrs)
if weights is None:
continue
bc = corrs.get(band)
overrides[str(band)] = {
"weights": {k: weights[k] for k in FACTOR_ORDER},
"n_samples": bc.n if bc else 0,
"source": bc.source if bc else "default",
}
return {
"generated_at": generated_at.isoformat(timespec="seconds"),
"generated_by": "scripts/recalibrate.py",
"schema_version": 1,
"global_weights": {k: DEFAULT_WEIGHTS[k] for k in FACTOR_ORDER},
"data_sources": {
"pskr": {
"bands": sorted(pskr_only.keys()),
"total_samples": sum(bc.n for bc in pskr_only.values()),
},
"contacts": {
"bands": sorted(contacts_only.keys()),
"total_samples": sum(bc.n for bc in contacts_only.values()),
},
},
"band_overrides": overrides,
}
def load_prior_json(path: Path) -> dict | None:
if not path.exists():
return None
try:
return json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
print(f" (could not read prior JSON at {path}: {exc})", file=sys.stderr)
return None
def diff_weights(
prior: dict | None, current: dict,
) -> list[tuple[str, str, float, float, float]]:
"""Returns rows (band, factor, prior_w, new_w, delta) sorted by |delta|."""
if not prior:
return []
prior_overrides = prior.get("band_overrides", {})
rows: list[tuple[str, str, float, float, float]] = []
for band, entry in current.get("band_overrides", {}).items():
prior_entry = prior_overrides.get(band, {}).get("weights", {})
new_entry = entry["weights"]
for factor in FACTOR_ORDER:
old = float(prior_entry.get(factor, DEFAULT_WEIGHTS[factor]))
new = float(new_entry[factor])
delta = new - old
if abs(delta) >= 0.005: # 0.5pp threshold to keep noise out
rows.append((band, factor, old, new, delta))
rows.sort(key=lambda r: abs(r[4]), reverse=True)
return rows
def render_report(
payload: dict,
corrs: dict[int, BandCorr],
diffs: list[tuple[str, str, float, float, float]],
dsn_redacted: str,
) -> str:
today = payload["generated_at"][:10]
lines: list[str] = []
lines.append(f"# Full Recalibration Report — {today}\n")
lines.append(
"> Auto-generated by `scripts/recalibrate.py`. The companion "
"JSON file at `priv/algo/band_weights.json` is the machine-"
"readable output that Elixir loads at runtime. This report is "
"the human-readable audit trail.\n"
)
lines.append(f"Connection: `{dsn_redacted}`")
lines.append(f"Generated: `{payload['generated_at']}`\n")
# ── Data sources
lines.append("## Data sources\n")
src = payload["data_sources"]
lines.append(
f"- **PSKR** (VHF/UHF, label = `spot_count`): bands "
f"{src['pskr']['bands']}, {src['pskr']['total_samples']:,} samples"
)
lines.append(
f"- **Contacts ↔ HRRR** (microwave, label = `distance_km`): "
f"{len(src['contacts']['bands'])} bands, "
f"{src['contacts']['total_samples']:,} matched contacts"
)
lines.append("")
# ── Per-band evidence summary
lines.append("## Per-band evidence\n")
lines.append("| Band | Source | n | label avg | ρ_tc | ρ_dpc | ρ_pr | ρ_pwat | ρ_grad |")
lines.append("|------|--------|---:|----------:|-----:|------:|-----:|-------:|-------:|")
for band in sorted(corrs):
bc = corrs[band]
avg = f"{bc.avg_label:.2f}" if bc.avg_label is not None else ""
lines.append(
f"| {band} MHz | {bc.source} | {bc.n:,} | {avg} | "
f"{bc.rho['tc']:+.3f} | {bc.rho['dpc']:+.3f} | {bc.rho['pr']:+.3f} | "
f"{bc.rho['pwat']:+.3f} | {bc.rho['grad']:+.3f} |"
)
lines.append("")
# ── Derived overrides
lines.append("## Derived per-band weights\n")
overrides = payload["band_overrides"]
if not overrides:
lines.append("_No band had ≥{} samples — nothing to override._\n".format(MIN_N_FOR_FIT))
else:
header = "| Band | n | source | " + " | ".join(FACTOR_ORDER) + " |"
sep = "|------|---:|--------|" + "|".join(["----:"] * len(FACTOR_ORDER)) + "|"
lines.append(header)
lines.append(sep)
for band_str in sorted(overrides, key=int):
entry = overrides[band_str]
ws = entry["weights"]
cells = " | ".join(f"{ws[f]:.4f}" for f in FACTOR_ORDER)
lines.append(
f"| {band_str} MHz | {entry['n_samples']:,} | "
f"{entry['source']} | {cells} |"
)
lines.append("")
# ── Diff against prior
lines.append("## Changes vs prior `band_weights.json`\n")
if not diffs:
lines.append(
"_No prior file, or no factor moved by ≥0.005. First-run "
"values can be compared to the in-tree `@band_configs` "
"weights in `lib/microwaveprop/propagation/band_config.ex`._\n"
)
else:
lines.append("Largest weight moves (≥0.5 pp), sorted by magnitude:\n")
lines.append("| Band | Factor | Prior | New | Δ |")
lines.append("|------|--------|------:|----:|--:|")
for band, factor, old, new, delta in diffs[:30]:
lines.append(
f"| {band} MHz | {factor} | {old:.4f} | {new:.4f} | {delta:+.4f} |"
)
if len(diffs) > 30:
lines.append(f"\n_(+{len(diffs) - 30} smaller moves omitted)_")
lines.append("")
# ── How to apply
lines.append("## Applying these weights\n")
lines.append(
"This script writes `priv/algo/band_weights.json` and stops "
"there — it never edits `band_config.ex`. To make the weights "
"live, `BandConfig.weights(band_mhz)` needs a one-time edit to "
"read this JSON at app start (cache in `:persistent_term`) and "
"merge `band_overrides[band].weights` over the in-source "
"defaults. Until then, the JSON is documentation only."
)
return "\n".join(lines)
# ── Plumbing ───────────────────────────────────────────────────────────────
def redact(dsn: str) -> str:
if "@" not in dsn:
return dsn
head, tail = dsn.split("@", 1)
if "://" in head and ":" in head.split("://", 1)[1]:
scheme, rest = head.split("://", 1)
user = rest.split(":", 1)[0]
return f"{scheme}://{user}:***@{tail}"
return dsn
def main() -> int:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--dsn",
default=os.environ.get("PROP_PROD_DB_URL"),
help="Postgres connection string (defaults to $PROP_PROD_DB_URL)",
)
parser.add_argument(
"--out-json",
type=Path,
default=DEFAULT_JSON_OUT,
help=f"JSON output path (default: {DEFAULT_JSON_OUT.relative_to(REPO_ROOT)})",
)
parser.add_argument(
"--out-report",
type=Path,
default=None,
help="Markdown report path (default: docs/algo-reports/{today}-full-recalibration.md)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print JSON to stdout, do not write files",
)
parser.add_argument(
"--statement-timeout",
default="20min",
help="Postgres statement_timeout (default: 20min)",
)
args = parser.parse_args()
if not args.dsn:
print(
"error: PROP_PROD_DB_URL not set and --dsn not given",
file=sys.stderr,
)
return 2
today = dt.date.today().isoformat()
out_report = args.out_report or (DEFAULT_REPORT_DIR / f"{today}-full-recalibration.md")
print(f"connecting to {redact(args.dsn)}", file=sys.stderr)
with psycopg.connect(args.dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute(f"SET statement_timeout = '{args.statement_timeout}'")
pskr = load_pskr_correlations(conn)
contacts = load_contacts_correlations(conn)
corrs = merge_correlations(pskr, contacts)
print(
f"• merged: {len(corrs)} bands "
f"({sum(1 for b in corrs if corrs[b].source == 'pskr')} PSKR, "
f"{sum(1 for b in corrs if corrs[b].source == 'contacts')} contacts)",
file=sys.stderr,
)
generated_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
payload = build_json(corrs, pskr, contacts, generated_at)
prior = load_prior_json(args.out_json)
diffs = diff_weights(prior, payload)
if args.dry_run:
print(json.dumps(payload, indent=2, sort_keys=False))
print(f"\n(dry-run: would write {len(payload['band_overrides'])} overrides to {args.out_json})", file=sys.stderr)
return 0
args.out_json.parent.mkdir(parents=True, exist_ok=True)
args.out_json.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n")
print(f"• wrote {args.out_json} ({len(payload['band_overrides'])} overrides)", file=sys.stderr)
out_report.parent.mkdir(parents=True, exist_ok=True)
out_report.write_text(render_report(payload, corrs, diffs, redact(args.dsn)) + "\n")
print(f"• wrote {out_report}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,916 +0,0 @@
#!/usr/bin/env python3
"""
Algo recalibration analysis runner.
Connects to the prod (or any) Postgres, joins contacts to the nearest HRRR
grid profile within ±0.07° / ±1h (matching `Weather.find_nearest_hrrr/3`),
falls back to `narr_profiles` (NCEP NARR, 32 km / 3-hourly) for pre-2014
contacts where HRRR doesn't reach, and produces a Markdown report covering:
* row counts and date ranges for the key tables (HRRR + NARR + ...)
* per-band contact distribution from 50 MHz up (VHF/UHF/microwave)
* monthly sounding ducting refresh
* native HRRR profile duct stats
* per-band Pearson correlations of contact distance vs HRRR fields
* per-band Pearson correlations vs NARR fields (historical sanity check)
* per-band binned distance distributions for HPBL, pressure, NEXRAD,
and native-profile best duct band every band with 50 matched
contacts gets its own bin table so we're not hard-coding the model
to 10/24 GHz behaviour
* NEXRAD composite reflectivity vs contact distance (rain-scoring check)
* hrrr_native_profiles.best_duct_band_ghz as a distance discriminator
* commercial-link rx_power degradation vs contemporaneous DFW contacts
Output is saved to docs/algo-reports/YYYY-MM-DD-recalibration.md so it can
be diffed against algo.md or committed alongside it.
Connection string is read from PROP_PROD_DB_URL (set in .envrc) or the
positional --dsn argument. Dependency-light: psycopg + pandas only.
Usage:
direnv allow # picks up PROP_PROD_DB_URL from .envrc
python3 scripts/recalibrate_algo.py
# or override:
python3 scripts/recalibrate_algo.py --dsn 'postgres://user:pw@host/db' --out path.md
"""
from __future__ import annotations
import argparse
import datetime as dt
import os
import sys
from pathlib import Path
try:
import psycopg
from psycopg.rows import dict_row
except ImportError:
sys.exit(
"psycopg is required: pip install 'psycopg[binary]>=3.1' pandas"
)
try:
import pandas as pd
except ImportError:
sys.exit("pandas is required: pip install pandas")
REPORT_HEADER = """\
# Recalibration Analysis Report — {today}
> Auto-generated by `scripts/recalibrate_algo.py`. This report is the
> input for `algo.md` updates diff against the prior dated section to
> see what's actually moved.
Connection: `{dsn_redacted}`
Statement timeout: {stmt_timeout}
"""
# ─── SQL fragments ──────────────────────────────────────────────────────────
# (table, time_column). `time_column = None` means just count rows without
# min/max range. Tables not present in the target DB are silently skipped —
# local dev often trails prod (e.g. `propagation_scores` moved to binary files).
ROW_COUNT_SOURCES = [
("contacts", "qso_timestamp"),
("surface_observations", "observed_at"),
("soundings", "observed_at"),
("hrrr_profiles", "valid_time"),
("hrrr_native_profiles", "valid_time"),
("narr_profiles", "valid_time"),
("iemre_observations", None),
("nexrad_observations", "observed_at"),
("terrain_profiles", None),
("propagation_scores", "valid_time"),
]
CONTACTS_BY_BAND_SQL = """
SELECT band::int AS band_mhz, count(*) AS contacts,
ROUND(AVG(distance_km::numeric)) AS avg_km,
MAX(distance_km::numeric) AS max_km
FROM contacts
WHERE pos1 IS NOT NULL AND distance_km IS NOT NULL
AND distance_km < 3000 AND flagged_invalid = false AND band >= 50
GROUP BY 1 ORDER BY 1;
"""
SOUNDING_MONTHLY_SQL = """
SELECT EXTRACT(MONTH FROM observed_at)::int AS month,
count(*) AS soundings,
ROUND(100.0 * count(*) FILTER (WHERE ducting_detected) / count(*), 1) AS ducting_pct,
ROUND(AVG(min_refractivity_gradient)::numeric, 1) AS avg_min_grad,
ROUND(AVG(precipitable_water_mm)::numeric, 1) AS avg_pwat
FROM soundings
WHERE min_refractivity_gradient IS NOT NULL
GROUP BY 1 ORDER BY 1;
"""
NATIVE_DUCT_SQL = """
SELECT
CASE WHEN best_duct_band_ghz IS NULL THEN 'none'
WHEN best_duct_band_ghz < 5 THEN '<5 GHz'
WHEN best_duct_band_ghz < 15 THEN '5-15 GHz'
WHEN best_duct_band_ghz < 30 THEN '15-30 GHz'
WHEN best_duct_band_ghz < 75 THEN '30-75 GHz'
ELSE '75+ GHz' END AS band_bin,
count(*) AS profiles,
ROUND(AVG(inversion_top_m)::numeric) AS avg_inv_top_m,
ROUND(AVG(theta_e_jump_k)::numeric, 1) AS avg_theta_e_jump,
ROUND(AVG(bulk_richardson)::numeric, 2) AS avg_richardson
FROM hrrr_native_profiles GROUP BY 1 ORDER BY 1;
"""
# Per-band contact ↔ HRRR Pearson correlations (2014-10 onward).
PER_BAND_JOIN_SQL = """
WITH joined AS (
SELECT DISTINCT ON (c.id)
c.id, c.band::int AS band, c.distance_km::float AS dist,
h.surface_temp_c AS tc, h.surface_dewpoint_c AS dpc,
h.surface_pressure_mb AS pr, h.pwat_mm AS pwat,
h.min_refractivity_gradient AS grad,
h.surface_refractivity AS sref, h.hpbl_m AS hpbl
FROM contacts c
JOIN hrrr_profiles h
ON h.lat BETWEEN (c.pos1->>'lat')::float - 0.07
AND (c.pos1->>'lat')::float + 0.07
AND h.lon BETWEEN (c.pos1->>'lon')::float - 0.07
AND (c.pos1->>'lon')::float + 0.07
AND h.valid_time BETWEEN c.qso_timestamp - INTERVAL '1 hour'
AND c.qso_timestamp + INTERVAL '1 hour'
WHERE c.pos1 IS NOT NULL AND c.distance_km < 3000 AND c.flagged_invalid = false
ORDER BY c.id, ABS(EXTRACT(EPOCH FROM h.valid_time - c.qso_timestamp))
)
SELECT * FROM joined;
"""
# Per-band contact ↔ NARR Pearson correlations (pre-2014-10).
# NARR is 32 km Lambert conformal / 3-hourly, so both tolerances are wider
# than the HRRR join above. Matches `NarrClient.in_coverage?/1` — valid up
# through but excluding 2014-10-02 (HRRR takes over after that).
PER_BAND_NARR_JOIN_SQL = """
WITH joined AS (
SELECT DISTINCT ON (c.id)
c.id, c.band::int AS band, c.distance_km::float AS dist,
n.surface_temp_c AS tc, n.surface_dewpoint_c AS dpc,
n.surface_pressure_mb AS pr, n.pwat_mm AS pwat,
n.min_refractivity_gradient AS grad,
n.surface_refractivity AS sref, n.hpbl_m AS hpbl
FROM contacts c
JOIN narr_profiles n
ON n.lat BETWEEN (c.pos1->>'lat')::float - 0.25
AND (c.pos1->>'lat')::float + 0.25
AND n.lon BETWEEN (c.pos1->>'lon')::float - 0.25
AND (c.pos1->>'lon')::float + 0.25
AND n.valid_time BETWEEN c.qso_timestamp - INTERVAL '2 hours'
AND c.qso_timestamp + INTERVAL '2 hours'
WHERE c.pos1 IS NOT NULL AND c.distance_km < 3000
AND c.flagged_invalid = false
AND c.qso_timestamp < TIMESTAMP '2014-10-02'
ORDER BY c.id, ABS(EXTRACT(EPOCH FROM n.valid_time - c.qso_timestamp))
)
SELECT * FROM joined;
"""
# NEXRAD ↔ contact join. Very tight spatial tolerance because nexrad_observations
# is a per-contact enrichment table, not a grid sample.
NEXRAD_JOIN_SQL = """
WITH joined AS (
SELECT DISTINCT ON (c.id)
c.id, c.band::int AS band, c.distance_km::float AS dist,
n.max_reflectivity_dbz AS max_dbz,
n.mean_reflectivity_dbz AS mean_dbz
FROM contacts c
JOIN nexrad_observations n
ON n.lat BETWEEN (c.pos1->>'lat')::float - 0.1
AND (c.pos1->>'lat')::float + 0.1
AND n.lon BETWEEN (c.pos1->>'lon')::float - 0.1
AND (c.pos1->>'lon')::float + 0.1
AND n.observed_at BETWEEN c.qso_timestamp - INTERVAL '15 min'
AND c.qso_timestamp + INTERVAL '15 min'
WHERE c.pos1 IS NOT NULL AND c.distance_km < 3000 AND c.flagged_invalid = false
ORDER BY c.id, ABS(EXTRACT(EPOCH FROM n.observed_at - c.qso_timestamp))
)
SELECT * FROM joined;
"""
# Native HRRR profile ↔ contact join — gives us best_duct_band_ghz per contact.
NATIVE_DUCT_JOIN_SQL = """
WITH joined AS (
SELECT DISTINCT ON (c.id)
c.id, c.band::int AS band, c.distance_km::float AS dist,
n.best_duct_band_ghz AS duct_ghz,
n.inversion_top_m AS inv_top_m,
n.bulk_richardson AS richardson
FROM contacts c
JOIN hrrr_native_profiles n
ON n.lat BETWEEN (c.pos1->>'lat')::float - 0.07
AND (c.pos1->>'lat')::float + 0.07
AND n.lon BETWEEN (c.pos1->>'lon')::float - 0.07
AND (c.pos1->>'lon')::float + 0.07
AND n.valid_time BETWEEN c.qso_timestamp - INTERVAL '1 hour'
AND c.qso_timestamp + INTERVAL '1 hour'
WHERE c.pos1 IS NOT NULL AND c.distance_km < 3000 AND c.flagged_invalid = false
ORDER BY c.id, ABS(EXTRACT(EPOCH FROM n.valid_time - c.qso_timestamp))
)
SELECT * FROM joined;
"""
# Commercial-link degradation ↔ contact join. For each contact in the DFW zone
# (within ~150 km of the Princeton TX cluster at 33.2N, -96.5W), find the
# nearest commercial sample within ±15 min and compute the dB delta from the
# 7-day trailing baseline. The boost factor in Scorer.commercial_link_boost/2
# expects the same quantity, so this query answers "does our commercial-link
# signal actually correlate with longer QSO distances when we have both?"
DFW_CLUSTER_LAT = 33.2
DFW_CLUSTER_LON = -96.5
DFW_RADIUS_DEG = 1.5 # roughly 150 km at 33° latitude
COMMERCIAL_JOIN_SQL = f"""
WITH contact_zone AS (
SELECT c.id, c.qso_timestamp, c.band::int AS band, c.distance_km::float AS dist
FROM contacts c
WHERE c.pos1 IS NOT NULL AND c.distance_km < 3000 AND c.flagged_invalid = false
AND (c.pos1->>'lat')::float BETWEEN {DFW_CLUSTER_LAT - DFW_RADIUS_DEG}
AND {DFW_CLUSTER_LAT + DFW_RADIUS_DEG}
AND (c.pos1->>'lon')::float BETWEEN {DFW_CLUSTER_LON - DFW_RADIUS_DEG}
AND {DFW_CLUSTER_LON + DFW_RADIUS_DEG}
),
current_sample AS (
SELECT DISTINCT ON (cz.id)
cz.id, cz.qso_timestamp, cz.band, cz.dist,
cs.link_id, cs.rx_power_0 AS current_rx, cs.sampled_at
FROM contact_zone cz
JOIN commercial_samples cs
ON cs.sampled_at BETWEEN cz.qso_timestamp - INTERVAL '15 min'
AND cz.qso_timestamp + INTERVAL '15 min'
AND cs.link_state = 1 AND cs.rx_power_0 IS NOT NULL
ORDER BY cz.id, ABS(EXTRACT(EPOCH FROM cs.sampled_at - cz.qso_timestamp))
),
baseline AS (
SELECT cs.id, cs.qso_timestamp, cs.band, cs.dist, cs.link_id, cs.current_rx,
(
SELECT AVG(bs.rx_power_0)
FROM commercial_samples bs
WHERE bs.link_id = cs.link_id
AND bs.link_state = 1
AND bs.rx_power_0 IS NOT NULL
AND bs.sampled_at BETWEEN cs.sampled_at - INTERVAL '7 days'
AND cs.sampled_at - INTERVAL '15 min'
) AS baseline_rx
FROM current_sample cs
)
SELECT id, band, dist,
(baseline_rx - current_rx)::float AS degradation_db,
current_rx::float AS current_dbm,
baseline_rx::float AS baseline_dbm
FROM baseline
WHERE baseline_rx IS NOT NULL;
"""
# Empty-table / data-quality smoke checks. Each metric is classified in
# DATA_GAP_RULES below so the report can flag BROKEN vs WARN vs OK with
# concrete remediation hints instead of dumping a flat key/value list.
DATA_GAP_SQL = """
SELECT
(SELECT count(*) FROM narr_profiles) AS narr_rows,
(SELECT count(*) FROM narr_profiles WHERE valid_time < '2014-10-02') AS narr_pre2014_rows,
(SELECT count(*) FROM hrrr_climatology) AS hrrr_climatology_rows,
(SELECT count(*) FROM contacts WHERE hrrr_status = 'complete') AS hrrr_complete_contacts,
(SELECT count(*) FROM contacts WHERE hrrr_status != 'complete') AS hrrr_pending_contacts,
(SELECT count(*) FROM contacts
WHERE qso_timestamp < '2014-10-02'
AND pos1 IS NOT NULL AND distance_km < 3000
AND flagged_invalid = false) AS pre2014_contacts;
"""
# Coverage breakdown for tables whose total row count alone hides the
# real gap — e.g. narr_profiles with 358 rows could be one good year or
# 358 stragglers spread across 30. Year-bucketed counts let the report
# flag whether the backfill ever made meaningful progress.
NARR_COVERAGE_BY_YEAR_SQL = """
SELECT EXTRACT(YEAR FROM valid_time)::int AS year, count(*)::bigint AS profiles
FROM narr_profiles
GROUP BY 1 ORDER BY 1;
"""
# Where is the HRRR enrichment backlog? A 36k pending count could mean
# "the backfill stalled in 2018" or "every contact submitted in the last
# week is queued". The breakdown drives a different remediation: rerun
# HrrrFetchWorker.enqueue_for_qso/1 over a year range vs. unstick the
# Oban queue.
HRRR_STATUS_BY_YEAR_SQL = """
SELECT EXTRACT(YEAR FROM qso_timestamp)::int AS year,
count(*) FILTER (WHERE hrrr_status = 'complete')::bigint AS complete,
count(*) FILTER (WHERE hrrr_status = 'queued')::bigint AS queued,
count(*) FILTER (WHERE hrrr_status = 'failed')::bigint AS failed,
count(*) FILTER (WHERE hrrr_status IS NULL)::bigint AS unset
FROM contacts
WHERE pos1 IS NOT NULL AND distance_km < 3000 AND flagged_invalid = false
AND qso_timestamp >= '2014-10-02'
GROUP BY 1 ORDER BY 1;
"""
# Severity rules for each metric in DATA_GAP_SQL.
# key metric name returned by DATA_GAP_SQL
# broken_when lambda(value) → True means BROKEN (red)
# warn_when lambda(value) → True means WARN (yellow). Skipped if BROKEN.
# remediation one-line action the operator should take
# A metric not flagged by either lambda is OK (green).
DATA_GAP_RULES: list[dict] = [
{
"key": "narr_rows",
# NARR feeds pre-2014 enrichment via BackfillEnqueueWorker's
# 30-min cron. NarrFetchWorker dedupes by 0.13° grid, so the
# equilibrium count is ~3 path points × pre2014_contacts after
# spatial collapse — not the contact count itself. Threshold
# follows: BROKEN at zero, WARN below half the expected
# post-dedup ceiling.
"broken_when": lambda v: v == 0,
"warn_when": lambda _v: False,
"remediation": (
"BackfillEnqueueWorker dispatches narr jobs every 30 min; "
"if rows aren't climbing, check NarrFetchWorker for upstream "
"NCEI errors in the Oban dashboard."
),
},
{
"key": "narr_pre2014_rows",
"broken_when": lambda v: v == 0,
"warn_when": lambda _v: False,
"remediation": (
"NARR's purpose is filling the pre-2014-10 gap; without rows here "
"the historical-sanity correlation table is empty. Same self-heal "
"as narr_rows."
),
},
{
"key": "hrrr_climatology_rows",
"broken_when": lambda v: v == 0,
"warn_when": lambda _v: False,
"remediation": (
"Should self-heal within 24h via the daily 02:30Z cron that "
"runs AdminTaskWorker(task=climatology). If still 0 after a "
"day, check Oban dashboard for failed admin jobs."
),
},
{
"key": "hrrr_complete_contacts",
# Healthy is the fraction complete, not the absolute number — handled
# separately via _check_hrrr_completeness below.
"broken_when": lambda _v: False,
"warn_when": lambda _v: False,
"remediation": "",
},
{
"key": "hrrr_pending_contacts",
"broken_when": lambda _v: False,
# Anything over 5k pending is a real backlog worth chasing.
"warn_when": lambda v: v > 5_000,
"remediation": (
"See `HRRR enrichment status by year` table below to locate the "
"backlog, then `mix hrrr.backfill --year YYYY` (or rerun "
"HrrrFetchWorker.enqueue_for_qso/1 over the affected ids)."
),
},
{
"key": "pre2014_contacts",
# pre-2014 contacts existing is informational, not a gap on its own.
"broken_when": lambda _v: False,
"warn_when": lambda _v: False,
"remediation": "",
},
]
# ─── Reporting helpers ──────────────────────────────────────────────────────
def fetch_df(conn, sql: str) -> pd.DataFrame:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(sql)
rows = cur.fetchall()
return pd.DataFrame(rows)
def md_table(df: pd.DataFrame) -> str:
if df.empty:
return "_(no rows)_\n"
return df.to_markdown(index=False, floatfmt=".3f") + "\n"
def fetch_row_counts(conn) -> pd.DataFrame:
"""Per-table counts + date ranges, tolerant of missing tables."""
rows = []
for tbl, time_col in ROW_COUNT_SOURCES:
with conn.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT to_regclass(%s) AS oid", (f"public.{tbl}",))
present = cur.fetchone()["oid"] is not None
if not present:
rows.append({"tbl": tbl, "n": None, "lo": None, "hi": None})
continue
if time_col:
sql = (
f"SELECT '{tbl}' tbl, count(*)::bigint n, "
f"min({time_col})::date lo, max({time_col})::date hi FROM {tbl}"
)
else:
sql = (
f"SELECT '{tbl}' tbl, count(*)::bigint n, "
f"NULL::date lo, NULL::date hi FROM {tbl}"
)
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(sql)
rows.append(cur.fetchone())
return pd.DataFrame(rows).sort_values("tbl")
def band_label(band_mhz: int) -> str:
"""Human-readable band label, matching `BandConfig` conventions."""
if band_mhz < 1_000:
return f"{band_mhz} MHz"
ghz = band_mhz / 1_000
if ghz == int(ghz):
return f"{int(ghz)} GHz"
return f"{ghz:g} GHz"
def bands_with_samples(df: pd.DataFrame, min_samples: int = 50) -> list[int]:
"""Every band in `df` with at least `min_samples` rows, sorted ascending."""
if df.empty or "band" not in df.columns:
return []
counts = df.groupby("band").size()
return sorted(int(b) for b, n in counts.items() if n >= min_samples)
def correlations_per_band(df: pd.DataFrame, min_samples: int = 30) -> pd.DataFrame:
fields = ["pr", "dpc", "pwat", "sref", "grad", "tc", "hpbl"]
rows = []
for band, group in df.groupby("band"):
if len(group) < min_samples:
continue
row = {"band_mhz": int(band), "n": len(group)}
for f in fields:
try:
row[f"rho_{f}"] = round(group["dist"].corr(group[f]), 3)
except Exception:
row[f"rho_{f}"] = None
rows.append(row)
return pd.DataFrame(rows).sort_values("band_mhz")
def hpbl_bins(df: pd.DataFrame, band_mhz: int = 10000) -> pd.DataFrame:
sub = df[(df["band"] == band_mhz) & df["hpbl"].notna()].copy()
if sub.empty:
return pd.DataFrame()
bins = [0, 200, 500, 1000, 1500, 2000, 1e9]
labels = ["<200", "200-500", "500-1000", "1000-1500", "1500-2000", ">=2000"]
sub["bin"] = pd.cut(sub["hpbl"], bins=bins, labels=labels, right=False)
out = (
sub.groupby("bin", observed=True)["dist"]
.agg(["count", "mean", "median"])
.round(1)
.rename(columns={"count": "n", "mean": "avg_km", "median": "p50_km"})
.reset_index()
)
return out
def pressure_bins(df: pd.DataFrame, band_mhz: int = 10000) -> pd.DataFrame:
sub = df[(df["band"] == band_mhz) & df["pr"].notna()].copy()
if sub.empty:
return pd.DataFrame()
bins = [0, 990, 1000, 1010, 1020, 9999]
labels = ["<990", "990-1000", "1000-1010", "1010-1020", ">=1020"]
sub["bin"] = pd.cut(sub["pr"], bins=bins, labels=labels, right=False)
out = (
sub.groupby("bin", observed=True)["dist"]
.agg(["count", "mean", "median"])
.round(1)
.rename(columns={"count": "n", "mean": "avg_km", "median": "p50_km"})
.reset_index()
)
return out
def nexrad_bins(df: pd.DataFrame, band_mhz: int = 10000) -> pd.DataFrame:
"""Distance distribution by NEXRAD max reflectivity bin, per band.
Confirms the rain-attenuation direction of the scoring: as max_dbz rises,
distance should fall at rain-sensitive bands (24+ GHz). Below-rain dBZ
(<5) is the no-precip baseline.
"""
sub = df[(df["band"] == band_mhz) & df["max_dbz"].notna()].copy()
if sub.empty:
return pd.DataFrame()
bins = [-1e9, 5, 20, 30, 45, 1e9]
labels = ["none (<5)", "drizzle (5-20)", "light (20-30)", "moderate (30-45)", "heavy (>=45)"]
sub["bin"] = pd.cut(sub["max_dbz"], bins=bins, labels=labels, right=False)
out = (
sub.groupby("bin", observed=True)["dist"]
.agg(["count", "mean", "median"])
.round(1)
.rename(columns={"count": "n", "mean": "avg_km", "median": "p50_km"})
.reset_index()
)
return out
def native_duct_bins(df: pd.DataFrame, band_mhz: int = 10000) -> pd.DataFrame:
"""Distance distribution by native-profile best duct band, per band.
Checks whether hrrr_native_profiles.best_duct_band_ghz actually predicts
longer QSO distances at the target band the assumption behind
Scorer.score_refractivity/4's 1.15× native-duct boost.
"""
sub = df[df["band"] == band_mhz].copy()
if sub.empty:
return pd.DataFrame()
def label(v: object) -> str:
if pd.isna(v):
return "none"
v = float(v)
if v < 5:
return "<5 GHz"
if v < 10:
return "5-10 GHz"
if v < 24:
return "10-24 GHz"
if v < 47:
return "24-47 GHz"
return ">=47 GHz"
sub["bin"] = sub["duct_ghz"].map(label)
order = ["none", "<5 GHz", "5-10 GHz", "10-24 GHz", "24-47 GHz", ">=47 GHz"]
out = (
sub.groupby("bin")["dist"]
.agg(["count", "mean", "median"])
.round(1)
.rename(columns={"count": "n", "mean": "avg_km", "median": "p50_km"})
.reindex(order)
.dropna(subset=["n"])
.reset_index()
)
return out
def commercial_degradation_summary(df: pd.DataFrame) -> pd.DataFrame:
"""Does commercial-link rx_power degradation correlate with longer contacts?
Returns a per-band bin table. Empty today because commercial_samples only
covers ~2 weeks and contacts only cluster in Aug/Sep contests but the
query is here so the moment contest-season samples land the signal shows up.
"""
if df.empty:
return pd.DataFrame()
bins = [-1e9, 0, 3, 8, 1e9]
labels = ["negative/0 (no fade)", "3 dB noise floor", "3-8 dB mild", ">=8 dB strong"]
df = df.copy()
df["bin"] = pd.cut(df["degradation_db"], bins=bins, labels=labels, right=False)
out = (
df.groupby("bin", observed=True)["dist"]
.agg(["count", "mean", "median"])
.round(1)
.rename(columns={"count": "n", "mean": "avg_km", "median": "p50_km"})
.reset_index()
)
return out
def classify_data_gaps(gaps: dict) -> pd.DataFrame:
"""Apply DATA_GAP_RULES to the raw gap counts.
Returns a dataframe with columns: metric, value, severity, remediation.
`severity` is one of "BROKEN", "WARN", "OK" (informational metrics with
no rule are also "OK"). The dataframe order matches DATA_GAP_RULES so
related metrics group naturally in the rendered table.
"""
rows = []
for rule in DATA_GAP_RULES:
key = rule["key"]
if key not in gaps:
continue
value = gaps[key]
if rule["broken_when"](value):
severity = "BROKEN"
elif rule["warn_when"](value):
severity = "WARN"
else:
severity = "OK"
rows.append(
{
"metric": key,
"value": int(value) if value is not None else None,
"severity": severity,
"remediation": rule["remediation"] if severity != "OK" else "",
}
)
# Append any metric DATA_GAP_SQL returned that we don't have a rule for,
# so the report stays exhaustive even if SQL changes ahead of the rules.
handled = {r["key"] for r in DATA_GAP_RULES}
for key, value in gaps.items():
if key in handled:
continue
rows.append(
{
"metric": key,
"value": int(value) if value is not None else None,
"severity": "OK",
"remediation": "",
}
)
return pd.DataFrame(rows)
def hrrr_completeness_pct(gaps: dict) -> float | None:
"""Fraction of eligible contacts with hrrr_status = 'complete'."""
complete = gaps.get("hrrr_complete_contacts") or 0
pending = gaps.get("hrrr_pending_contacts") or 0
total = complete + pending
if total == 0:
return None
return round(100.0 * complete / total, 1)
def commercial_correlation(df: pd.DataFrame) -> float | None:
"""Pearson correlation of degradation_db with contact distance. None if n<5."""
if df.empty or len(df) < 5:
return None
return round(df["dist"].corr(df["degradation_db"]), 3)
def redact(dsn: str) -> str:
if "@" not in dsn:
return dsn
head, tail = dsn.split("@", 1)
if "://" in head and ":" in head.split("://", 1)[1]:
scheme, rest = head.split("://", 1)
user = rest.split(":", 1)[0]
return f"{scheme}://{user}:***@{tail}"
return dsn
# ─── Main ───────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dsn",
default=os.environ.get("PROP_PROD_DB_URL"),
help="Postgres connection string (defaults to $PROP_PROD_DB_URL)",
)
parser.add_argument(
"--out",
default=None,
help="Output Markdown path (defaults to docs/algo-reports/YYYY-MM-DD-recalibration.md)",
)
parser.add_argument(
"--statement-timeout",
default="20min",
help="Postgres statement_timeout (default: 20min)",
)
args = parser.parse_args()
if not args.dsn:
print(
"error: PROP_PROD_DB_URL not set and --dsn not given; populate "
"via .envrc",
file=sys.stderr,
)
return 2
today = dt.date.today().isoformat()
out_path = Path(
args.out
or Path(__file__).resolve().parent.parent
/ "docs"
/ "algo-reports"
/ f"{today}-recalibration.md"
)
out_path.parent.mkdir(parents=True, exist_ok=True)
print(f"connecting to {redact(args.dsn)}", file=sys.stderr)
sections: list[str] = []
sections.append(
REPORT_HEADER.format(
today=today,
dsn_redacted=redact(args.dsn),
stmt_timeout=args.statement_timeout,
)
)
with psycopg.connect(args.dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute(f"SET statement_timeout = '{args.statement_timeout}';")
print("• row counts", file=sys.stderr)
sections.append("\n## Row counts and date ranges\n")
sections.append(md_table(fetch_row_counts(conn)))
print("• data gaps", file=sys.stderr)
gaps = fetch_df(conn, DATA_GAP_SQL).iloc[0].to_dict()
gap_table = classify_data_gaps(gaps)
completeness = hrrr_completeness_pct(gaps)
broken = gap_table[gap_table["severity"] == "BROKEN"]
for _, row in broken.iterrows():
print(
f" ⚠ BROKEN: {row['metric']}={row['value']}{row['remediation']}",
file=sys.stderr,
)
sections.append("\n## Data gaps and ingestion health\n")
sections.append(
"Severity legend: **BROKEN** = table is empty and feeds an "
"active scoring path; **WARN** = populated but coverage is "
"low enough to skew per-band statistics; **OK** = within "
"spec or informational. Each non-OK row carries a one-line "
"remediation hint.\n"
)
sections.append(md_table(gap_table))
if completeness is not None:
sections.append(
f"\nHRRR enrichment completeness: **{completeness}%** "
f"({gaps.get('hrrr_complete_contacts', 0):,} complete / "
f"{gaps.get('hrrr_pending_contacts', 0):,} pending).\n"
)
print("• narr coverage by year", file=sys.stderr)
narr_year_df = fetch_df(conn, NARR_COVERAGE_BY_YEAR_SQL)
sections.append("\n### NARR coverage by year\n")
if narr_year_df.empty:
sections.append(
"_narr_profiles is empty. Run `mix narr.backfill` to "
"populate the pre-2014 corpus._\n"
)
else:
sections.append(
"Year-bucketed counts. NARR is supposed to span 1979 → "
"2014-10 at 3-hourly cadence (~2,920 timestamps per year "
"per grid cell). Years far below the others are partial "
"backfills the worker hasn't finished.\n"
)
sections.append(md_table(narr_year_df))
print("• hrrr status by year", file=sys.stderr)
hrrr_year_df = fetch_df(conn, HRRR_STATUS_BY_YEAR_SQL)
sections.append("\n### HRRR enrichment status by year\n")
if hrrr_year_df.empty:
sections.append("_(no contacts with `pos1` set after 2014-10-02)_\n")
else:
sections.append(
"Locate the pending backlog: a year with high `queued`/"
"`failed`/`unset` counts is where HrrrFetchWorker needs "
"to be rerun. Recent years dominating `queued` usually "
"means the live Oban queue is stuck rather than a "
"historical backfill gap.\n"
)
sections.append(md_table(hrrr_year_df))
print("• contacts by band", file=sys.stderr)
sections.append("\n## Contacts by band (≥50 MHz)\n")
sections.append(md_table(fetch_df(conn, CONTACTS_BY_BAND_SQL)))
print("• sounding monthly", file=sys.stderr)
sections.append("\n## Monthly sounding ducting\n")
sections.append(md_table(fetch_df(conn, SOUNDING_MONTHLY_SQL)))
print("• native HRRR ducts", file=sys.stderr)
sections.append(
"\n## HRRR native-profile duct distribution (best supportable band)\n"
)
sections.append(md_table(fetch_df(conn, NATIVE_DUCT_SQL)))
print("• per-band correlations — HRRR (this is the slow one)", file=sys.stderr)
joined = fetch_df(conn, PER_BAND_JOIN_SQL)
print("• per-band correlations — NARR (pre-2014)", file=sys.stderr)
joined_narr = fetch_df(conn, PER_BAND_NARR_JOIN_SQL)
print("• NEXRAD ↔ contacts", file=sys.stderr)
nexrad = fetch_df(conn, NEXRAD_JOIN_SQL)
print("• native-duct ↔ contacts", file=sys.stderr)
native = fetch_df(conn, NATIVE_DUCT_JOIN_SQL)
print("• commercial-link ↔ contacts (DFW zone only)", file=sys.stderr)
commercial = fetch_df(conn, COMMERCIAL_JOIN_SQL)
if joined.empty:
sections.append("\n_No contacts ↔ HRRR matches were found._\n")
else:
sections.append(
f"\n## Contact ↔ HRRR per-band Pearson correlations "
f"(matched n={len(joined)})\n"
)
sections.append(md_table(correlations_per_band(joined)))
sections.append(
"\n## Per-band HPBL bin distance distribution\n\n"
"One table per band with ≥50 matched contacts. A band-specific "
"effect here means HPBL belongs in the scorer for that band.\n"
)
for band in bands_with_samples(joined):
table = hpbl_bins(joined, band)
if table.empty:
continue
sections.append(f"\n**{band_label(band)}:**\n")
sections.append(md_table(table))
sections.append(
"\n## Per-band pressure bin distance distribution\n\n"
"Surface pressure tends to proxy synoptic-scale stagnation, so we "
"want to see longer distances under the 1015-1025 mb ridge at every "
"band that ducts.\n"
)
for band in bands_with_samples(joined):
table = pressure_bins(joined, band)
if table.empty:
continue
sections.append(f"\n**{band_label(band)}:**\n")
sections.append(md_table(table))
if joined_narr.empty:
sections.append(
"\n## Contact ↔ NARR per-band Pearson correlations\n\n"
"_No pre-2014 contacts ↔ NARR matches were found. Either the NARR "
"backfill has not run for this corpus yet or there are no "
"pre-2014 contacts with `pos1` set._\n"
)
else:
sections.append(
f"\n## Contact ↔ NARR per-band Pearson correlations "
f"(pre-2014 only, matched n={len(joined_narr)})\n\n"
"Historical sanity check: if the HRRR-era signs and magnitudes "
"survive on 30+ years of NARR reanalysis, the scoring factors are "
"physical rather than HRRR-artefact. Diverging signs = flag it.\n"
)
sections.append(md_table(correlations_per_band(joined_narr)))
sections.append(
f"\n## NEXRAD composite reflectivity vs distance "
f"(matched n={len(nexrad)})\n"
)
if nexrad.empty:
sections.append("_No contacts ↔ NEXRAD matches were found._\n")
else:
sections.append(
"Rain-attenuation sanity check — one table per band with ≥50 "
"matched contacts. At rain-sensitive bands (24+ GHz) higher "
"`max_dbz` should map to shorter contacts; at 10 GHz and below "
"the effect should be barely detectable.\n"
)
for band in bands_with_samples(nexrad):
table = nexrad_bins(nexrad, band)
if table.empty:
continue
sections.append(f"\n**{band_label(band)}:**\n")
sections.append(md_table(table))
sections.append(
f"\n## hrrr_native_profiles.best_duct_band_ghz vs distance "
f"(matched n={len(native)})\n"
)
if native.empty:
sections.append("_No contacts ↔ native-profile matches were found._\n")
else:
sections.append(
"Validates the 1.15× boost in `Scorer.score_refractivity/4`: "
"contacts where the native duct supports the target band should "
"run longer than those where it does not. One table per band with "
"≥50 matched contacts.\n"
)
for band in bands_with_samples(native):
table = native_duct_bins(native, band)
if table.empty:
continue
sections.append(f"\n**{band_label(band)}:**\n")
sections.append(md_table(table))
sections.append(
f"\n## Commercial-link rx_power degradation vs contemporaneous DFW contacts "
f"(matched n={len(commercial)})\n"
)
if commercial.empty:
sections.append(
"_No DFW-zone contacts fall inside the commercial_samples date "
"window yet. Expected to stay empty until the Aug/Sep contest "
"season produces contacts that overlap with live SNMP polling._\n"
)
else:
rho = commercial_correlation(commercial)
if rho is not None:
sections.append(f"Pearson(degradation_db, distance_km) = **{rho}**\n\n")
sections.append(md_table(commercial_degradation_summary(commercial)))
out_path.write_text("\n".join(sections))
print(f"wrote {out_path}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,165 @@
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