Ran recalibrate_algo.py against the full local prop_dev (81,994 contacts, 18.6M HRRR rows) and derived per-band composite weights for the nine bands with >=200 matched contacts. Moisture (dewpoint/PWAT/surface N) is consistently beneficial through 5.76 GHz, reverses at 24 GHz; rain scales sqrt(rain_k) not linearly so 24 GHz gets 0.215 rain weight instead of the linear-ratio 0.95. 10 GHz stays as the reference band (defaults); 47+ GHz inherits defaults (n<200). - BandConfig.weights/1 returns per-band override or default fallback - @band_configs carries :weights on 222/432/902/1296/2304/3400/5760/24G - Recalibrator.compute_factors/3 + fit(band_mhz:) band-aware fitting - Scorer + ContactLive.Show pass band_config to weights/1 - algo.md Part 2d documents the 2026-04-18 analysis + derivation rule - scripts/derive_band_weights.py turns correlations into weight maps - report preserved at docs/algo-reports/2026-04-18-recalibration.md
281 lines
9.8 KiB
Python
281 lines
9.8 KiB
Python
#!/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.05–0.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())
|