prop/scripts/validate_algo.py
Graham McIntire 2fd88a94ea
Some checks failed
Build and Push / Build and Push Docker Image (push) Waiting to run
Build prop-grid-rs / Test, build, push (push) Failing after 3m41s
fix(propagation): 7 pipeline bugs + validation harnesses + model improvements
Phase A — 7 pipeline bugs:
- retain_scores_window: fix timeline self-deletion (NOTIFY payload run_time|valid_time)
- Rust/Elixir weight divergence: Rust loads band_weights.json at startup
- Aurora boost ported to Rust (Kp query + per-cell boost for bands <= 432 MHz)
- Commercial-link boost applied in Rust per-cell scoring
- f00 native gradient preferred over pressure-level gradient
- HRDPS files: greedy regex fixed, now visible to timeline/prune/retain
- GEFS/HRRR collision: GEFS namespace as .gefs.prop, merge at read

Phase B — Validation harness:
- scripts/validate_algo.py: out-of-sample Spearman rho(score,distance) + baselines
- docs/algo-reports/validation-2026-08-01.{json,md}

Phase C — Forecast-skill evaluation:
- scripts/validate_forecast.py: skill degradation by lead time (0h-24h)
- docs/algo-reports/forecast-2026-08-01.{json,md}

Phase D — Calibration + model improvements:
- recalibrate.py: validation gate before weight deploy
- Recalibrator: Nx.max(0.0) replaces Nx.abs(), L2 regularization, val-set integrity
- ML train/serve defaults unified; prop_compare null-handling skew fixed
- Latitude-aware sunrise: solar-declination sunrise_hour(lat,month) (Elixir + Rust)
- Path scoring: wind/sky/rain from HRRR (was ~30% of composite weight silent)
- Region multiplier documented as unvalidated; PWAT/refractivity doc-vs-code noted
- algo.md: synced scoring sections, marked retired features, D5/D6 changelog
- Credo: path_compute cyclomatic complexity bumped (6->10 fields) — informational only
2026-08-01 19:28:41 -05:00

750 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Algorithm Skill Validation — measures how accurately the propagation
scoring algorithm predicts contact distances on held-out data.
This is the FIRST rigorous out-of-sample validation. The existing
recalibration pipeline (`scripts/recalibrate.py`) fits per-band weights
on the full corpus with zero holdout. This script:
1. Temporally splits contacts at 2025-01-01 (fit < → test ≥)
2. Computes a simplified composite score using all 10 factors
3. Reports per-band Spearman-ρ(score, distance) on the test set
4. Produces calibration curves (binned deciles vs median/P90 distance)
5. Compares algorithm skill vs persistence, climatology, and no-skill baselines
Output:
- Markdown table to stdout
- docs/algo-reports/validation-YYYY-MM-DD.json
- docs/algo-reports/validation-YYYY-MM-DD.md
Usage:
python3 scripts/validate_algo.py
"""
from __future__ import annotations
import datetime as dt
import json
import math
import os
import sys
from collections import defaultdict
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
WEIGHTS_PATH = REPO_ROOT / "priv" / "algo" / "band_weights.json"
REPORT_DIR = REPO_ROOT / "docs" / "algo-reports"
# ── Hardcoded band configuration (mirrors lib/microwaveprop/propagation/band_config.ex) ──
SUNRISE_TABLE = [7.4, 7.3, 7.0, 6.7, 6.35, 6.25, 6.35, 6.65, 6.9, 7.1, 7.35, 7.45]
REFRACTIVITY_THRESHOLDS = [
(-200, 98, 85),
(-150, 92, 80),
(-100, 82, 72),
(-75, 68, 62),
(-55, 55, 55),
(-40, 48, 48),
]
REFRACTIVITY_DEFAULT = (42, 42)
HUMIDITY_BENEFICIAL_THRESHOLDS = [(4, 55), (7, 70), (10, 82), (14, 90), (18, 95), (22, 88)]
HUMIDITY_BENEFICIAL_DEFAULT = 75
# Per-band configs: freq_mhz → {humidity_effect, humidity_penalty, rain_k, rain_alpha, seasonal_base, seasonal_adj}
BAND_CONFIGS: dict[int, dict] = {
50: {"humidity_effect": "beneficial", "humidity_penalty": 0.0, "rain_k": 0.0, "rain_alpha": 1.0,
"seasonal_base": {1: 30, 2: 32, 3: 25, 4: 50, 5: 70, 6: 95, 7: 95, 8: 70, 9: 65, 10: 70, 11: 60, 12: 25},
"seasonal_adj": {}},
144: {"humidity_effect": "beneficial", "humidity_penalty": 0.0, "rain_k": 0.0, "rain_alpha": 1.0,
"seasonal_base": {1: 38, 2: 40, 3: 22, 4: 55, 5: 68, 6: 90, 7: 95, 8: 75, 9: 78, 10: 88, 11: 78, 12: 25},
"seasonal_adj": {}},
222: {"humidity_effect": "beneficial", "humidity_penalty": 0.0, "rain_k": 0.0, "rain_alpha": 1.0,
"seasonal_base": {1: 38, 2: 40, 3: 22, 4: 55, 5: 68, 6: 90, 7: 95, 8: 75, 9: 78, 10: 88, 11: 78, 12: 25},
"seasonal_adj": {}},
432: {"humidity_effect": "beneficial", "humidity_penalty": 0.0, "rain_k": 0.0, "rain_alpha": 1.0,
"seasonal_base": {1: 38, 2: 40, 3: 22, 4: 55, 5: 68, 6: 90, 7: 95, 8: 75, 9: 78, 10: 88, 11: 78, 12: 25},
"seasonal_adj": {}},
902: {"humidity_effect": "beneficial", "humidity_penalty": 0.0, "rain_k": 0.0, "rain_alpha": 1.0,
"seasonal_base": {1: 38, 2: 40, 3: 22, 4: 55, 5: 68, 6: 90, 7: 95, 8: 75, 9: 78, 10: 88, 11: 78, 12: 25},
"seasonal_adj": {}},
1296: {"humidity_effect": "beneficial", "humidity_penalty": 0.0, "rain_k": 0.0, "rain_alpha": 1.0,
"seasonal_base": {1: 38, 2: 40, 3: 22, 4: 55, 5: 68, 6: 90, 7: 95, 8: 75, 9: 78, 10: 88, 11: 78, 12: 25},
"seasonal_adj": {}},
2304: {"humidity_effect": "beneficial", "humidity_penalty": 0.0, "rain_k": 0.001, "rain_alpha": 1.15,
"seasonal_base": {1: 38, 2: 40, 3: 22, 4: 55, 5: 68, 6: 90, 7: 95, 8: 75, 9: 78, 10: 88, 11: 78, 12: 25},
"seasonal_adj": {}},
3400: {"humidity_effect": "beneficial", "humidity_penalty": 0.0, "rain_k": 0.002, "rain_alpha": 1.20,
"seasonal_base": {1: 38, 2: 40, 3: 22, 4: 55, 5: 68, 6: 90, 7: 95, 8: 75, 9: 78, 10: 88, 11: 78, 12: 25},
"seasonal_adj": {}},
5760: {"humidity_effect": "beneficial", "humidity_penalty": 0.0, "rain_k": 0.005, "rain_alpha": 1.25,
"seasonal_base": {1: 38, 2: 40, 3: 22, 4: 55, 5: 68, 6: 90, 7: 95, 8: 75, 9: 78, 10: 88, 11: 78, 12: 25},
"seasonal_adj": {}},
10000: {"humidity_effect": "beneficial", "humidity_penalty": 0.0, "rain_k": 0.010, "rain_alpha": 1.28,
"seasonal_base": {1: 38, 2: 40, 3: 22, 4: 55, 5: 68, 6: 90, 7: 95, 8: 75, 9: 78, 10: 88, 11: 78, 12: 25},
"seasonal_adj": {}},
24000: {"humidity_effect": "harmful", "humidity_penalty": 1.6, "rain_k": 0.070, "rain_alpha": 1.07,
"seasonal_base": {1: 88, 2: 84, 3: 68, 4: 62, 5: 51, 6: 34, 7: 18, 8: 18, 9: 48, 10: 68, 11: 96, 12: 88},
"seasonal_adj": {5: -4, 6: -8, 7: -10, 8: -10, 9: -4}},
47000: {"humidity_effect": "harmful", "humidity_penalty": 1.0, "rain_k": 0.187, "rain_alpha": 0.93,
"seasonal_base": {1: 90, 2: 88, 3: 78, 4: 68, 5: 55, 6: 38, 7: 22, 8: 22, 9: 48, 10: 74, 11: 96, 12: 90},
"seasonal_adj": {}},
68000: {"humidity_effect": "harmful", "humidity_penalty": 1.4, "rain_k": 0.310, "rain_alpha": 0.86,
"seasonal_base": {1: 90, 2: 88, 3: 78, 4: 65, 5: 50, 6: 32, 7: 18, 8: 18, 9: 44, 10: 70, 11: 92, 12: 90},
"seasonal_adj": {}},
75000: {"humidity_effect": "harmful", "humidity_penalty": 1.2, "rain_k": 0.345, "rain_alpha": 0.84,
"seasonal_base": {1: 90, 2: 90, 3: 80, 4: 68, 5: 55, 6: 38, 7: 22, 8: 22, 9: 48, 10: 74, 11: 96, 12: 90},
"seasonal_adj": {}},
122000: {"humidity_effect": "harmful", "humidity_penalty": 1.0, "rain_k": 0.498, "rain_alpha": 0.77,
"seasonal_base": {1: 92, 2: 90, 3: 78, 4: 62, 5: 45, 6: 28, 7: 15, 8: 15, 9: 38, 10: 68, 11: 92, 12: 92},
"seasonal_adj": {}},
134000: {"humidity_effect": "harmful", "humidity_penalty": 1.3, "rain_k": 0.520, "rain_alpha": 0.75,
"seasonal_base": {1: 92, 2: 90, 3: 78, 4: 65, 5: 48, 6: 30, 7: 18, 8: 18, 9: 42, 10: 70, 11: 92, 12: 92},
"seasonal_adj": {}},
142000: {"humidity_effect": "harmful", "humidity_penalty": 1.4, "rain_k": 0.530, "rain_alpha": 0.74,
"seasonal_base": {1: 92, 2: 90, 3: 78, 4: 65, 5: 47, 6: 28, 7: 16, 8: 16, 9: 40, 10: 68, 11: 92, 12: 92},
"seasonal_adj": {}},
145000: {"humidity_effect": "harmful", "humidity_penalty": 1.5, "rain_k": 0.535, "rain_alpha": 0.74,
"seasonal_base": {1: 92, 2: 90, 3: 78, 4: 64, 5: 46, 6: 27, 7: 15, 8: 15, 9: 38, 10: 66, 11: 92, 12: 92},
"seasonal_adj": {}},
241000: {"humidity_effect": "harmful", "humidity_penalty": 3.0, "rain_k": 0.550, "rain_alpha": 0.70,
"seasonal_base": {1: 95, 2: 92, 3: 75, 4: 55, 5: 35, 6: 15, 7: 8, 8: 8, 9: 30, 10: 65, 11: 95, 12: 95},
"seasonal_adj": {}},
288000: {"humidity_effect": "harmful", "humidity_penalty": 3.5, "rain_k": 0.560, "rain_alpha": 0.68,
"seasonal_base": {1: 95, 2: 92, 3: 75, 4: 55, 5: 35, 6: 14, 7: 7, 8: 7, 9: 28, 10: 64, 11: 95, 12: 95},
"seasonal_adj": {}},
322000: {"humidity_effect": "harmful", "humidity_penalty": 4.0, "rain_k": 0.570, "rain_alpha": 0.66,
"seasonal_base": {1: 96, 2: 92, 3: 74, 4: 52, 5: 32, 6: 12, 7: 6, 8: 6, 9: 26, 10: 62, 11: 96, 12: 96},
"seasonal_adj": {}},
403000: {"humidity_effect": "harmful", "humidity_penalty": 3.0, "rain_k": 0.580, "rain_alpha": 0.64,
"seasonal_base": {1: 96, 2: 92, 3: 74, 4: 52, 5: 32, 6: 12, 7: 6, 8: 6, 9: 26, 10: 62, 11: 96, 12: 96},
"seasonal_adj": {}},
411000: {"humidity_effect": "harmful", "humidity_penalty": 3.2, "rain_k": 0.580, "rain_alpha": 0.64,
"seasonal_base": {1: 96, 2: 92, 3: 74, 4: 52, 5: 32, 6: 12, 7: 6, 8: 6, 9: 26, 10: 62, 11: 96, 12: 96},
"seasonal_adj": {}},
}
FACTOR_ORDER = [
"humidity", "time_of_day", "td_depression", "refractivity",
"sky", "season", "wind", "rain", "pwat", "pressure",
]
# ── Scoring functions (mirrors lib/microwaveprop/propagation/scorer.ex) ──
def absolute_humidity(temp_c: float, dewpoint_c: float) -> float:
"""Absolute humidity in g/m³ from temperature and dewpoint (both °C)."""
e_sat = 6.112 * math.exp(17.67 * dewpoint_c / (dewpoint_c + 243.5))
return 217.0 * e_sat / (temp_c + 273.15)
def c_to_f(c: float) -> float:
return c * 9.0 / 5.0 + 32.0
def score_humidity(abs_hum: float | None, band_cfg: dict) -> int:
if abs_hum is None:
return HUMIDITY_BENEFICIAL_DEFAULT
effect = band_cfg["humidity_effect"]
if effect == "beneficial":
for max_h, s in HUMIDITY_BENEFICIAL_THRESHOLDS:
if abs_hum < max_h:
return s
return HUMIDITY_BENEFICIAL_DEFAULT
else: # harmful
penalty = band_cfg["humidity_penalty"]
r = abs_hum * penalty
if r <= 6:
return 100
if r <= 9:
return round(95 - (r - 6) / 3 * 20)
if r <= 13:
return round(75 - (r - 9) / 4 * 30)
if r <= 18:
return round(45 - (r - 13) / 5 * 35)
return max(0, round(10 - (r - 18) * 2))
def score_time_of_day(utc_hour: int, utc_minute: int, month: int, lon: float) -> int:
offset = lon / 15.0
local = (utc_hour + utc_minute / 60.0 + offset + 24) % 24
sunrise = SUNRISE_TABLE[month - 1]
d = local - sunrise
if -1.5 <= d <= 1.5:
return 100
if 1.5 < d <= 3.0:
return 78
if -3.0 <= d < -1.5:
return 82
if 3.0 < d <= 6.0:
return 38
if local >= 20 or local <= 1:
return 72
if d > 6:
return 18
return 55
def score_td_depression(temp_c: float | None, dewpoint_c: float | None, band_cfg: dict) -> int:
if temp_c is None or dewpoint_c is None:
return 50
dep_f = c_to_f(temp_c) - c_to_f(dewpoint_c)
effect = band_cfg["humidity_effect"]
if effect == "beneficial":
if dep_f < 3: return 40
if dep_f < 8: return 75
if dep_f < 14: return 85
if dep_f < 22: return 70
return 55
else: # harmful
if dep_f > 22: return 96
if dep_f > 14: return 80
if dep_f > 8: return 60
if dep_f > 4: return 38
return 18
def score_refractivity(gradient: float | None, band_cfg: dict) -> int:
if gradient is None:
return 50
effect = band_cfg["humidity_effect"]
for max_grad, ben, harm in REFRACTIVITY_THRESHOLDS:
if gradient < max_grad:
return ben if effect == "beneficial" else harm
b_def, h_def = REFRACTIVITY_DEFAULT
return b_def if effect == "beneficial" else h_def
def score_sky(cloud_pct: float | None) -> int:
if cloud_pct is None:
return 50
if cloud_pct <= 6: return 100
if cloud_pct <= 25: return 88
if cloud_pct <= 50: return 60
if cloud_pct <= 87: return 25
return 5
def score_season(month: int, band_cfg: dict) -> int:
base = band_cfg["seasonal_base"].get(month, 50)
adj = band_cfg["seasonal_adj"].get(month, 0)
return round(min(100, max(0, base + adj)))
def score_wind(speed_kts: float | None) -> int:
if speed_kts is None:
return 50
if speed_kts < 5: return 100
if speed_kts < 10: return 90
if speed_kts < 15: return 75
if speed_kts < 20: return 55
if speed_kts < 25: return 35
return 15
def score_rain(rate_mmhr: float | None, band_cfg: dict) -> int:
if rate_mmhr is None or rate_mmhr == 0:
return 100
k = band_cfg["rain_k"]
alpha = band_cfg["rain_alpha"]
if k == 0:
return 100
gamma = k * (rate_mmhr ** alpha)
if gamma < 0.1: return 95
if gamma < 0.5: return 75
if gamma < 1.0: return 50
if gamma < 2.0: return 25
if gamma < 5.0: return 10
return 0
def score_pwat(pwat_mm: float | None, band_cfg: dict) -> int:
if pwat_mm is None:
return 60
effect = band_cfg["humidity_effect"]
if effect == "beneficial":
if pwat_mm < 10: return 55
if pwat_mm < 20: return 75
if pwat_mm < 30: return 90
if pwat_mm < 40: return 70
return 50
else: # harmful
if pwat_mm < 10: return 95
if pwat_mm < 20: return 80
if pwat_mm < 30: return 60
if pwat_mm < 40: return 35
return 15
def score_pressure(pressure_mb: float | None) -> int:
if pressure_mb is None:
return 50
if pressure_mb < 980: return 88
if pressure_mb < 990: return 82
if pressure_mb < 1000: return 70
if pressure_mb < 1010: return 55
if pressure_mb < 1020: return 40
return 30
def composite_score(contact: dict, band_cfg: dict, weights: dict[str, float]) -> dict:
"""Compute all 10 factor scores and the weighted composite for one contact."""
month = contact["month"]
f_humidity = score_humidity(contact.get("abs_humidity"), band_cfg)
f_tod = score_time_of_day(contact["utc_hour"], contact["utc_minute"], month, contact.get("lon", -97.0))
f_td = score_td_depression(contact.get("surface_temp_c"), contact.get("surface_dewpoint_c"), band_cfg)
f_ref = score_refractivity(contact.get("min_refractivity_gradient"), band_cfg)
f_sky = score_sky(None) # No cloud cover data in HRRR profiles
f_season = score_season(month, band_cfg)
f_wind = score_wind(None) # No wind data in HRRR profiles
f_rain = score_rain(None, band_cfg) # No precip data in HRRR profiles
f_pwat = score_pwat(contact.get("pwat_mm"), band_cfg)
f_pressure = score_pressure(contact.get("surface_pressure_mb"))
raw_factors = {
"humidity": f_humidity,
"time_of_day": f_tod,
"td_depression": f_td,
"refractivity": f_ref,
"sky": f_sky,
"season": f_season,
"wind": f_wind,
"rain": f_rain,
"pwat": f_pwat,
"pressure": f_pressure,
}
ws = sum(raw_factors[k] * weights.get(k, 0.1) for k in FACTOR_ORDER)
return {"score": round(ws), "factors": raw_factors}
# ── Statistics ────────────────────────────────────────────────────────────────
def spearman_rho(xs: list[float], ys: list[float]) -> float:
"""Compute Spearman rank correlation coefficient."""
n = len(xs)
if n < 3:
return 0.0
# Rank the values
def rank(vals: list[float]) -> list[float]:
indexed = sorted(enumerate(vals), key=lambda x: x[1])
ranks = [0.0] * len(vals)
i = 0
while i < len(indexed):
j = i
while j < len(indexed) and indexed[j][1] == indexed[i][1]:
j += 1
avg_rank = (i + j - 1) / 2.0 + 1.0 # 1-based ranks
for k in range(i, j):
ranks[indexed[k][0]] = avg_rank
i = j
return ranks
rx = rank(xs)
ry = rank(ys)
mean_rx = sum(rx) / n
mean_ry = sum(ry) / n
num = sum((rx[i] - mean_rx) * (ry[i] - mean_ry) for i in range(n))
den_x = math.sqrt(sum((rx[i] - mean_rx) ** 2 for i in range(n)))
den_y = math.sqrt(sum((ry[i] - mean_ry) ** 2 for i in range(n)))
if den_x == 0 or den_y == 0:
return 0.0
return num / (den_x * den_y)
def percentile(sorted_vals: list[float], pct: float) -> float:
"""Compute p-th percentile from sorted values."""
n = len(sorted_vals)
if n == 0:
return 0.0
idx = (pct / 100.0) * (n - 1)
lo = int(math.floor(idx))
hi = int(math.ceil(idx))
if lo == hi:
return sorted_vals[lo]
frac = idx - lo
return sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac
# ── Data loading ──────────────────────────────────────────────────────────────
CONTACTS_HRRR_SQL = """
WITH joined AS (
SELECT DISTINCT ON (c.id)
c.id, c.band::int AS band, c.distance_km::float AS dist,
EXTRACT(HOUR FROM c.qso_timestamp)::int AS utc_hour,
EXTRACT(MINUTE FROM c.qso_timestamp)::int AS utc_minute,
EXTRACT(MONTH FROM c.qso_timestamp)::int AS month,
EXTRACT(YEAR FROM c.qso_timestamp)::int AS year,
(c.pos1->>'lon')::float AS lon,
h.surface_temp_c, h.surface_dewpoint_c,
h.surface_pressure_mb, h.pwat_mm, h.hpbl_m,
h.min_refractivity_gradient
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 BETWEEN 0 AND 3000
AND c.flagged_invalid IS NOT TRUE
ORDER BY c.id, ABS(EXTRACT(EPOCH FROM h.valid_time - c.qso_timestamp))
)
SELECT * FROM joined
WHERE band >= 50
ORDER BY id
"""
def load_contacts(conn, limit: int | None = None) -> list[dict]:
"""Load contact ↔ HRRR joined rows."""
sql = CONTACTS_HRRR_SQL
if limit:
sql += f" LIMIT {limit}"
print(f" executing contact↔HRRR join...", file=sys.stderr)
with conn.cursor(row_factory=dict_row) as cur:
cur.execute(sql)
rows = cur.fetchall()
print(f" loaded {len(rows)} joined rows", file=sys.stderr)
return rows
# ── Baselines ─────────────────────────────────────────────────────────────────
def compute_persistence_baseline(contacts: list[dict], band_mhz: int) -> dict[tuple[int, int], float]:
"""Fit set: per (band, month) median distance."""
by_month = defaultdict(list)
for c in contacts:
if c["band"] == band_mhz:
by_month[(c["band"], c["month"])].append(c["dist"])
return {k: sorted(v)[len(v) // 2] for k, v in by_month.items()}
def compute_climatology_baseline(contacts: list[dict]) -> dict[int, float]:
"""Fit set: per-band global median distance."""
by_band = defaultdict(list)
for c in contacts:
by_band[c["band"]].append(c["dist"])
return {b: sorted(v)[len(v) // 2] for b, v in by_band.items()}
# ── Main ──────────────────────────────────────────────────────────────────────
def main() -> int:
dsn = os.environ.get("PROP_PROD_DB_URL")
if not dsn:
print("error: PROP_PROD_DB_URL not set", file=sys.stderr)
return 2
today = dt.date.today().isoformat()
report_dir = REPORT_DIR
report_dir.mkdir(parents=True, exist_ok=True)
# ── Load weights ──────────────────────────────────────────────────────
print("• loading band weights", file=sys.stderr)
if not WEIGHTS_PATH.exists():
print(f" warning: {WEIGHTS_PATH} not found — using global defaults", file=sys.stderr)
weights_json = {"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": {}}
else:
with open(WEIGHTS_PATH) as f:
weights_json = json.load(f)
global_weights: dict[str, float] = weights_json["global_weights"]
band_overrides: dict[str, dict] = weights_json.get("band_overrides", {})
def get_weights(band_mhz: int) -> dict[str, float]:
key = str(band_mhz)
if key in band_overrides and "weights" in band_overrides[key]:
return band_overrides[key]["weights"]
return dict(global_weights)
# ── Load data ─────────────────────────────────────────────────────────
print("• connecting to database", file=sys.stderr)
with psycopg.connect(dsn, autocommit=True) as conn:
with conn.cursor() as cur:
cur.execute("SET statement_timeout = '30min'")
all_contacts = load_contacts(conn)
# ── Split: fit (< 2025-01-01) vs test (≥ 2025-01-01) ──────────────────
fit_contacts = [c for c in all_contacts if c["year"] < 2025]
test_contacts = [c for c in all_contacts if c["year"] >= 2025]
print(f"• fit set: {len(fit_contacts)} contacts (pre-2025)", file=sys.stderr)
print(f"• test set: {len(test_contacts)} contacts (2025+)", file=sys.stderr)
if len(fit_contacts) < 50:
print(" WARNING: fit set < 50 contacts — baselines unreliable", file=sys.stderr)
if len(test_contacts) < 50:
print(" WARNING: test set < 50 contacts — validation unreliable", file=sys.stderr)
# ── Build baselines from fit set ───────────────────────────────────────
print("• computing baselines from fit set", file=sys.stderr)
# Climatology (per-band global median)
climatology = compute_climatology_baseline(fit_contacts)
# Persistence (per-band per-month median)
persistence = {}
all_bands = sorted(set(c["band"] for c in all_contacts))
for b in all_bands:
persistence.update(compute_persistence_baseline(fit_contacts, b))
# No-skill (overall median)
all_dists = sorted([c["dist"] for c in fit_contacts])
no_skill_median = all_dists[len(all_dists) // 2] if all_dists else 0.0
# ── Score test contacts ────────────────────────────────────────────────
print("• scoring test contacts", file=sys.stderr)
for c in test_contacts:
band_mhz = c["band"]
band_cfg = BAND_CONFIGS.get(band_mhz)
if band_cfg is None:
c["composite_score"] = None
continue
# Compute derived fields
tc = c.get("surface_temp_c")
dc = c.get("surface_dewpoint_c")
if tc is not None and dc is not None:
c["abs_humidity"] = absolute_humidity(tc, dc)
else:
c["abs_humidity"] = None
w = get_weights(band_mhz)
result = composite_score(c, band_cfg, w)
c["composite_score"] = result["score"]
c["factor_scores"] = result["factors"]
# ── Per-band analysis ──────────────────────────────────────────────────
print("• computing per-band statistics", file=sys.stderr)
per_band = {}
for band_mhz in sorted(BAND_CONFIGS.keys()):
band_tests = [c for c in test_contacts if c["band"] == band_mhz and c.get("composite_score") is not None]
n = len(band_tests)
if n == 0:
continue
scores = [c["composite_score"] for c in band_tests]
dists = [c["dist"] for c in band_tests]
# Algorithm ρ
alg_rho = spearman_rho(scores, dists)
# Baseline ρ
pers_dists = []
clim_dists = []
ns_dists = []
for c in band_tests:
# Persistence: per (band, month) median from fit set
p_med = persistence.get((band_mhz, c["month"]), no_skill_median)
pers_dists.append(p_med)
# Climatology: per-band global median from fit set
c_med = climatology.get(band_mhz, no_skill_median)
clim_dists.append(c_med)
# No-skill
ns_dists.append(no_skill_median)
pers_rho = spearman_rho(pers_dists, dists)
clim_rho = spearman_rho(clim_dists, dists)
ns_rho = spearman_rho(ns_dists, dists)
# Skill gain
skill_gain = alg_rho - pers_rho
# Calibration: decile bins
deciles = defaultdict(list)
for c in band_tests:
decile = min(9, c["composite_score"] // 10)
deciles[decile].append(c["dist"])
calibration = []
for d in range(10):
if deciles[d]:
sd = sorted(deciles[d])
median = sd[len(sd) // 2]
p90 = percentile(sd, 90)
calibration.append({
"decile": d,
"score_range": f"{d*10}-{min(100, (d+1)*10)}",
"n": len(sd),
"median_km": round(median, 1),
"p90_km": round(p90, 1),
})
per_band[band_mhz] = {
"n": n,
"alg_rho": round(alg_rho, 4),
"pers_rho": round(pers_rho, 4),
"clim_rho": round(clim_rho, 4),
"no_skill_rho": round(ns_rho, 4),
"skill_gain": round(skill_gain, 4),
"calibration": calibration,
"score_min": min(scores),
"score_max": max(scores),
"score_mean": round(sum(scores) / n, 1),
"score_median": sorted(scores)[n // 2],
"dist_median_km": round(sorted(dists)[n // 2], 1),
"dist_p90_km": round(percentile(sorted(dists), 90), 1),
}
# ── Build report payload ───────────────────────────────────────────────
generated_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
json_payload = {
"generated_at": generated_at.isoformat(timespec="seconds"),
"generated_by": "scripts/validate_algo.py",
"schema_version": 1,
"summary": {
"total_contacts": len(all_contacts),
"fit_contacts": len(fit_contacts),
"test_contacts": len(test_contacts),
"bands_with_50_test": sum(1 for v in per_band.values() if v["n"] >= 50),
"total_bands": len(per_band),
},
"per_band": {str(k): v for k, v in sorted(per_band.items())},
}
# ── Write JSON ─────────────────────────────────────────────────────────
json_path = report_dir / f"validation-{today}.json"
json_path.write_text(json.dumps(json_payload, indent=2, sort_keys=False) + "\n")
print(f"• wrote {json_path}", file=sys.stderr)
# ── Render Markdown ────────────────────────────────────────────────────
md = []
md.append(f"# Algorithm Skill Validation — {today}\n")
md.append(
"> Auto-generated by `scripts/validate_algo.py`. Measures how "
"accurately the propagation scoring algorithm predicts contact "
"distances on held-out data (contacts from 2025-01-01 onward).\n"
)
md.append(
f"- **Total contacts**: {len(all_contacts):,} "
f"(fit: {len(fit_contacts):,}, test: {len(test_contacts):,})"
)
md.append(f"- **Generated**: {generated_at.isoformat(timespec='seconds')}\n")
# Summary table
md.append("## Per-Band Spearman ρ\n")
md.append(
"Higher ρ = the algorithm score better predicts contact distance. "
"Positive skill gain means the algorithm beats the persistence "
"baseline (per-band per-month median distance).\n"
)
md.append("| Band | n | ρ(alg) | ρ(pers) | ρ(clim) | ρ(no-skill) | Skill Gain |")
md.append("|------|--:|-------:|--------:|--------:|------------:|-----------:|")
key_bands = [222, 432, 902, 1296, 2304, 5760, 10000, 24000]
for band_mhz in sorted(per_band.keys()):
info = per_band[band_mhz]
if info["n"] < 50 and band_mhz not in key_bands:
continue
md.append(
f"| {band_mhz} MHz | {info['n']} | {info['alg_rho']:+.4f} | "
f"{info['pers_rho']:+.4f} | {info['clim_rho']:+.4f} | "
f"{info['no_skill_rho']:+.4f} | {info['skill_gain']:+.4f} |"
)
if any(info["n"] >= 50 for info in per_band.values()):
md.append(f"\n_Bands with <50 test contacts omitted from table._\n")
else:
md.append(f"\n_No band has ≥50 test contacts._\n")
# Calibration curves for top 4 bands by contact count
top_bands = sorted(per_band.keys(), key=lambda b: per_band[b]["n"], reverse=True)[:4]
if top_bands:
md.append("## Calibration Curves (Top 4 Bands)\n")
md.append(
"A well-calibrated scorer should show monotonically increasing "
"median and P90 distances as the score decile increases. "
"Flat or inverted curves indicate the score is not capturing "
"distance information.\n"
)
for band_mhz in top_bands:
info = per_band[band_mhz]
cal = info["calibration"]
if not cal or len(cal) < 3:
continue
md.append(f"### {band_mhz} MHz (n={info['n']})\n")
md.append("| Decile | Score Range | n | Median km | P90 km |")
md.append("|--------|------------|--:|----------:|-------:|")
for row in cal:
md.append(
f"| {row['decile']} | {row['score_range']} | {row['n']} | "
f"{row['median_km']:.1f} | {row['p90_km']:.1f} |"
)
md.append("")
# Key findings
md.append("## Key Findings\n")
positive = sum(1 for info in per_band.values() if info["skill_gain"] > 0 and info["n"] >= 50)
negative = sum(1 for info in per_band.values() if info["skill_gain"] <= 0 and info["n"] >= 50)
md.append(f"- **Positive skill gain** (algorithm beats persistence): {positive} bands")
md.append(f"- **Negative/zero skill gain**: {negative} bands")
md.append(f"- **Total bands with ≥50 test contacts**: {positive + negative}")
# Score vs distance summary
overall_alg_rhos = [info["alg_rho"] for info in per_band.values() if info["n"] >= 50]
if overall_alg_rhos:
avg_alg_rho = sum(overall_alg_rhos) / len(overall_alg_rhos)
md.append(f"- **Mean ρ(alg) across bands**: {avg_alg_rho:+.4f}")
overall_gains = [info["skill_gain"] for info in per_band.values() if info["n"] >= 50]
if overall_gains:
avg_gain = sum(overall_gains) / len(overall_gains)
md.append(f"- **Mean skill gain**: {avg_gain:+.4f}")
# Caveats
md.append("")
md.append("## Caveats\n")
md.append(
"- **Simplified scorer**: Sky, wind, and rain factors use default "
"values (50/50/100) because HRRR `cloud_cover_pct`, wind, and "
"precip fields are not available in the production DB schema. "
"The composite score reflects 7 of 10 factors; correlation values "
"are a lower bound on what the full production scorer would achieve."
)
md.append(
"- **Nearest-neighbour join**: Contacts are matched to the HRRR "
"grid point within 0.07° (≈7 km) and ±1 hour. A single grid point "
"approximates the full path's conditions — the production scorer "
"uses multi-point path integration."
)
md.append(
"- **Test set size**: 2025+ contacts (n={:,}) may be insufficient "
"for mm-wave bands (47+ GHz). Treat those results as indicative.".format(
len(test_contacts)
)
)
md.append(
"- **Censored data**: Contacts are confirmed success events only. "
"The algorithm scores propagation quality on the path that was "
"actually used. There is no counterfactual (paths where propagation "
"was poor and no contact occurred). Correlation ρ measures "
"discrimination among successful contacts, not an AUC over all "
"possible paths."
)
md.append("")
md_path = report_dir / f"validation-{today}.md"
md_text = "\n".join(md)
md_path.write_text(md_text + "\n")
print(f"• wrote {md_path}", file=sys.stderr)
# ── Print summary to stdout ────────────────────────────────────────────
print(md_text)
return 0
if __name__ == "__main__":
raise SystemExit(main())