#!/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())