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
902 lines
37 KiB
Python
902 lines
37 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Forecast Skill Evaluation — measures how well the propagation scoring algorithm
|
||
predicts contact distances as the weather profile ages.
|
||
|
||
For each contact, we look up HRRR profiles at 0h (analysis/ground truth),
|
||
1h, 3h, 6h, 12h, and 24h before the contact time. These lagged profiles
|
||
represent the weather data that would have been available N hours earlier.
|
||
We then compute how much the score↔distance correlation degrades with lead time.
|
||
|
||
Output:
|
||
- Markdown table to stdout
|
||
- docs/algo-reports/forecast-2026-08-01.json
|
||
- docs/algo-reports/forecast-2026-08-01.md
|
||
|
||
Usage:
|
||
python3 scripts/validate_forecast.py
|
||
|
||
Env vars:
|
||
PROP_PROD_DB_URL — PostgreSQL connection string
|
||
"""
|
||
|
||
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 validate_algo.py) ────────────────────
|
||
|
||
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
|
||
|
||
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",
|
||
]
|
||
|
||
# Lead times to evaluate (hours before contact)
|
||
LEAD_TIMES = [0, 1, 3, 6, 12, 24]
|
||
|
||
# ── Scoring functions (identical to validate_algo.py) ──────────────────────────
|
||
|
||
def absolute_humidity(temp_c: float, dewpoint_c: float) -> float:
|
||
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:
|
||
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:
|
||
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:
|
||
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 compute_composite_score(profile: dict | None, contact: dict, band_cfg: dict,
|
||
weights: dict[str, float]) -> int | None:
|
||
"""Compute the composite score for a contact using a specific weather profile.
|
||
|
||
If profile is None (missing), returns None.
|
||
The contact's own metadata (month, hour, lon) is used for time-of-day and
|
||
season factors; weather-driven factors use the profile.
|
||
"""
|
||
if profile is None:
|
||
return None
|
||
|
||
month = contact["month"]
|
||
abs_hum = None
|
||
tc = profile.get("surface_temp_c")
|
||
dc = profile.get("surface_dewpoint_c")
|
||
if tc is not None and dc is not None:
|
||
abs_hum = absolute_humidity(tc, dc)
|
||
|
||
f_humidity = score_humidity(abs_hum, 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(tc, dc, band_cfg)
|
||
f_ref = score_refractivity(profile.get("min_refractivity_gradient"), band_cfg)
|
||
f_sky = score_sky(None)
|
||
f_season = score_season(month, band_cfg)
|
||
f_wind = score_wind(None)
|
||
f_rain = score_rain(None, band_cfg)
|
||
f_pwat = score_pwat(profile.get("pwat_mm"), band_cfg)
|
||
f_pressure = score_pressure(profile.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 round(ws)
|
||
|
||
|
||
# ── 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
|
||
|
||
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
|
||
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)
|
||
|
||
|
||
# ── Data loading ───────────────────────────────────────────────────────────────
|
||
|
||
# Snap constants: HRRR grid is 0.25° resolution in practice, but let's round
|
||
# to 0.125° (1/8) for snapping — matches the "nearest grid point" approach.
|
||
SNAP_RESOLUTION = 0.125
|
||
|
||
CONTACTS_SQL = """
|
||
WITH joined AS (
|
||
SELECT DISTINCT ON (c.id)
|
||
c.id,
|
||
c.band::int AS band,
|
||
c.distance_km::float AS dist,
|
||
c.qso_timestamp,
|
||
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,
|
||
(c.pos1->>'lat')::float AS lat
|
||
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
|
||
AND c.qso_timestamp >= '2020-01-01'
|
||
ORDER BY c.id, ABS(EXTRACT(EPOCH FROM h.valid_time - c.qso_timestamp))
|
||
)
|
||
SELECT * FROM joined
|
||
WHERE band >= 50
|
||
ORDER BY id
|
||
"""
|
||
|
||
|
||
def snap_coord(val: float) -> float:
|
||
"""Round a coordinate to the nearest HRRR grid multiple."""
|
||
return round(val / SNAP_RESOLUTION) * SNAP_RESOLUTION
|
||
|
||
|
||
def load_contacts(conn) -> list[dict]:
|
||
"""Load contact → HRRR joined rows (same pattern as validate_algo.py)."""
|
||
print(" executing contact↔HRRR join…", file=sys.stderr)
|
||
with conn.cursor(row_factory=dict_row) as cur:
|
||
cur.execute(CONTACTS_SQL)
|
||
rows = cur.fetchall()
|
||
print(f" loaded {len(rows)} joined rows", file=sys.stderr)
|
||
return rows
|
||
|
||
|
||
def load_lagged_profiles(conn, contacts: list[dict]) -> dict[tuple[int, int], dict]:
|
||
"""For each lag, load one HRRR profile per contact at the snapped location.
|
||
|
||
Returns a dict keyed by (contact_id, lead_hours) → profile row.
|
||
Contacts are snapped to the nearest 0.125° grid point.
|
||
"""
|
||
# First, build the set of unique (lat, lon, valid_time) tuples we need
|
||
# Group by contact: we need one profile per (contact, lead) pair
|
||
by_snapped = defaultdict(list)
|
||
for c in contacts:
|
||
slat = snap_coord(c["lat"])
|
||
slon = snap_coord(c["lon"])
|
||
ts = c["qso_timestamp"]
|
||
for lead in LEAD_TIMES:
|
||
by_snapped[(slat, slon, lead)].append((c["id"], ts, lead))
|
||
|
||
print(f" fetching {len(by_snapped)} unique (lat, lon, lead) combinations…", file=sys.stderr)
|
||
|
||
# Batch fetch: one query per lead time for efficiency
|
||
result: dict[tuple[int, int], dict] = {} # (contact_id, lead_hours) → profile
|
||
|
||
for lead in LEAD_TIMES:
|
||
# Get all (lat, lon) pairs for this lead
|
||
lats = sorted({k[0] for k in by_snapped if k[2] == lead})
|
||
lons = sorted({k[1] for k in by_snapped if k[2] == lead})
|
||
if not lats:
|
||
continue
|
||
|
||
# Use ANY for efficient batch lookup when many points
|
||
sql = """
|
||
SELECT lat, lon, valid_time,
|
||
surface_temp_c, surface_dewpoint_c,
|
||
surface_pressure_mb, pwat_mm, hpbl_m,
|
||
min_refractivity_gradient
|
||
FROM hrrr_profiles
|
||
WHERE lat = ANY(%s)
|
||
AND lon = ANY(%s)
|
||
AND valid_time = ANY(
|
||
SELECT date_trunc('hour', qso_timestamp) - make_interval(hours => %s)
|
||
FROM contacts
|
||
WHERE id = ANY(%s)
|
||
AND qso_timestamp >= '2020-01-01'
|
||
)
|
||
"""
|
||
|
||
# Actually, the ANY subquery approach is complex. Let's do it differently:
|
||
# For each contact, compute valid_time and do an IN clause.
|
||
# Better: precompute all valid_times and batch-query by (lat, lon, valid_time) tuples.
|
||
# But we can't pass tuples to ANY easily. Let's use a VALUES join.
|
||
|
||
# Simpler approach: one query that joins contacts to hrrr_profiles for each lag
|
||
contact_ids = [c["id"] for c in contacts]
|
||
|
||
# Build a VALUES table approach for efficiency
|
||
lag_sql = f"""
|
||
SELECT p.surface_temp_c, p.surface_dewpoint_c,
|
||
p.surface_pressure_mb, p.pwat_mm, p.hpbl_m,
|
||
p.min_refractivity_gradient,
|
||
c.id AS contact_id, {lead} AS lead_hours
|
||
FROM contacts c
|
||
CROSS JOIN LATERAL (
|
||
SELECT *
|
||
FROM hrrr_profiles h
|
||
WHERE h.lat = {snap_coord_fn_sql('(c.pos1->>''lat'')::float')}
|
||
AND h.lon = {snap_coord_fn_sql('(c.pos1->>''lon'')::float')}
|
||
AND h.valid_time = date_trunc('hour', c.qso_timestamp) - make_interval(hours => {lead})
|
||
LIMIT 1
|
||
) p
|
||
WHERE c.id = ANY(%s)
|
||
AND c.qso_timestamp >= '2020-01-01'
|
||
"""
|
||
|
||
# Actually the CROSS JOIN LATERAL with the snap function in SQL is messy.
|
||
# Let me take a simpler but effective approach: use a VALUES clause to batch.
|
||
|
||
# Build the set of (lat, lon, valid_times) to query
|
||
# Since the number of contacts might be large, use a temp table or VALUES approach
|
||
|
||
# Alternative: For each unique (snapped_lat, snapped_lon), query all lags at once
|
||
pass # placeholder — see actual implementation below
|
||
|
||
return result
|
||
|
||
|
||
def snap_coord_fn_sql(formula: str) -> str:
|
||
"""Generate SQL for rounding a formula to the nearest SNAP_RESOLUTION multiple."""
|
||
r = SNAP_RESOLUTION
|
||
return f"(round(({formula}) / {r}) * {r})"
|
||
|
||
|
||
def fetch_all_lagged_profiles(conn, contacts: list[dict]) -> dict[tuple[str, int], dict]:
|
||
"""Fetch all lagged HRRR profiles efficiently.
|
||
|
||
Strategy: Uses a temp table with all (snapped_lat, snapped_lon, lead, base_ts)
|
||
tuples, then joins against hrrr_profiles in a single query.
|
||
|
||
Returns: {(contact_id_str, lead_hours): profile_row} where profile_row is a dict
|
||
with the weather fields.
|
||
"""
|
||
if not contacts:
|
||
return {}
|
||
|
||
print(f" fetching lagged profiles for {len(contacts)} contacts at leads {LEAD_TIMES}…", file=sys.stderr)
|
||
|
||
# Build lookup rows: (contact_id, snapped_lat, snapped_lon, lead_hours, base_ts)
|
||
# We generate this in Python and pass to PostgreSQL via a VALUES clause
|
||
|
||
rows = []
|
||
for c in contacts:
|
||
slat = snap_coord(c["lat"])
|
||
slon = snap_coord(c["lon"])
|
||
base_ts = c["qso_timestamp"]
|
||
for lead in LEAD_TIMES:
|
||
rows.append((c["id"], slat, slon, lead, base_ts))
|
||
|
||
if not rows:
|
||
return {}
|
||
|
||
# Use a temp table to batch the profile lookups efficiently.
|
||
# Without ON COMMIT DROP since autocommit=True.
|
||
with conn.cursor() as cur:
|
||
cur.execute("DROP TABLE IF EXISTS _forecast_lookups")
|
||
cur.execute("""
|
||
CREATE TEMP TABLE _forecast_lookups (
|
||
contact_id text,
|
||
snapped_lat double precision,
|
||
snapped_lon double precision,
|
||
lead_hours int,
|
||
base_ts timestamptz
|
||
)
|
||
""")
|
||
# Insert in batches of 5000 rows to stay under param limits
|
||
batch_size = 5000
|
||
for i in range(0, len(rows), batch_size):
|
||
batch = rows[i:i + batch_size]
|
||
values_clauses = []
|
||
params = []
|
||
for contact_id, slat, slon, lead, base_ts in batch:
|
||
cid_str = str(contact_id)
|
||
values_clauses.append("(%s::text, %s::double precision, %s::double precision, %s::int, %s::timestamptz)")
|
||
params.extend([cid_str, slat, slon, lead, base_ts])
|
||
sql = "INSERT INTO _forecast_lookups (contact_id, snapped_lat, snapped_lon, lead_hours, base_ts) VALUES " + ", ".join(values_clauses)
|
||
cur.execute(sql, params)
|
||
|
||
# Now query hrrr_profiles joining against the lookup table
|
||
print(f" querying hrrr_profiles with {len(rows)} lookup tuples…", file=sys.stderr)
|
||
with conn.cursor(row_factory=dict_row) as cur:
|
||
cur.execute("""
|
||
SELECT
|
||
fl.contact_id,
|
||
fl.lead_hours,
|
||
h.surface_temp_c,
|
||
h.surface_dewpoint_c,
|
||
h.surface_pressure_mb,
|
||
h.pwat_mm,
|
||
h.hpbl_m,
|
||
h.min_refractivity_gradient
|
||
FROM _forecast_lookups fl
|
||
JOIN hrrr_profiles h
|
||
ON h.lat = fl.snapped_lat
|
||
AND h.lon = fl.snapped_lon
|
||
AND h.valid_time = date_trunc('hour', fl.base_ts) - make_interval(hours => fl.lead_hours)
|
||
""")
|
||
result_rows = cur.fetchall()
|
||
|
||
# Cleanup
|
||
with conn.cursor() as cur:
|
||
cur.execute("DROP TABLE IF EXISTS _forecast_lookups")
|
||
|
||
result: dict[tuple[str, int], dict] = {}
|
||
for r in result_rows:
|
||
key = (r["contact_id"], r["lead_hours"])
|
||
result[key] = {
|
||
"surface_temp_c": r["surface_temp_c"],
|
||
"surface_dewpoint_c": r["surface_dewpoint_c"],
|
||
"surface_pressure_mb": r["surface_pressure_mb"],
|
||
"pwat_mm": r["pwat_mm"],
|
||
"hpbl_m": r["hpbl_m"],
|
||
"min_refractivity_gradient": r["min_refractivity_gradient"],
|
||
}
|
||
|
||
print(f" fetched {len(result)} profile rows", file=sys.stderr)
|
||
|
||
# Report coverage per lead
|
||
for lead in LEAD_TIMES:
|
||
found = sum(1 for (cid, lh) in result if lh == lead)
|
||
print(f" lead-{lead}h: {found}/{len(contacts)} profiles found", file=sys.stderr)
|
||
|
||
return result
|
||
|
||
|
||
# ── 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'")
|
||
contacts = load_contacts(conn)
|
||
|
||
if not contacts:
|
||
print("error: no contacts loaded — check DB connection and data", file=sys.stderr)
|
||
return 3
|
||
|
||
print(f"• loaded {len(contacts)} contacts (≥2020-01-01)", file=sys.stderr)
|
||
|
||
# ── Fetch lagged profiles ──────────────────────────────────────────
|
||
print("• fetching lagged HRRR profiles", file=sys.stderr)
|
||
all_profiles = fetch_all_lagged_profiles(conn, contacts)
|
||
|
||
# ── Score each contact at each lag ──────────────────────────────────────
|
||
print("• scoring contacts at each lead time", file=sys.stderr)
|
||
# Track which contacts have ALL required profiles (lag-0 through lag-24)
|
||
scored_by_band: dict[int, dict[int, list]] = defaultdict(lambda: defaultdict(list))
|
||
# {band_mhz: {lead_hours: [(score, distance), ...]}}
|
||
|
||
contacts_with_all = 0
|
||
for c in contacts:
|
||
band_mhz = c["band"]
|
||
band_cfg = BAND_CONFIGS.get(band_mhz)
|
||
if band_cfg is None:
|
||
continue
|
||
|
||
w = get_weights(band_mhz)
|
||
cid_str = str(c["id"])
|
||
|
||
# Check if all profiles exist
|
||
has_all = True
|
||
scores_at_lag = {}
|
||
for lead in LEAD_TIMES:
|
||
key = (cid_str, lead)
|
||
profile = all_profiles.get(key)
|
||
if profile is None:
|
||
has_all = False
|
||
break
|
||
score = compute_composite_score(profile, c, band_cfg, w)
|
||
if score is None:
|
||
has_all = False
|
||
break
|
||
scores_at_lag[lead] = (score, c["dist"])
|
||
|
||
if has_all:
|
||
contacts_with_all += 1
|
||
for lead, (score, dist) in scores_at_lag.items():
|
||
scored_by_band[band_mhz][lead].append((score, dist))
|
||
|
||
print(f"• contacts with all {len(LEAD_TIMES)} profiles: {contacts_with_all}/{len(contacts)}", file=sys.stderr)
|
||
|
||
# ── Per-band per-lag Spearman ρ ────────────────────────────────────────
|
||
print("• computing per-band per-lag Spearman ρ", file=sys.stderr)
|
||
per_band_results = {}
|
||
for band_mhz in sorted(scored_by_band.keys()):
|
||
by_lead = scored_by_band[band_mhz]
|
||
# All leads must have the same N (contacts with all profiles)
|
||
ns = [len(pairs) for pairs in by_lead.values()]
|
||
n = ns[0] if ns else 0
|
||
|
||
if n < 30:
|
||
print(f" band {band_mhz} MHz: {n} contacts — skipping (<30 threshold)", file=sys.stderr)
|
||
continue
|
||
if not all(x == n for x in ns):
|
||
print(f" band {band_mhz} MHz: inconsistent counts — skipping", file=sys.stderr)
|
||
continue
|
||
|
||
rhos = {}
|
||
for lead in LEAD_TIMES:
|
||
pairs = by_lead.get(lead, [])
|
||
scores = [s for s, d in pairs]
|
||
dists = [d for s, d in pairs]
|
||
rhos[lead] = spearman_rho(scores, dists)
|
||
|
||
rho_0 = rhos.get(0, 0.0)
|
||
per_band_results[band_mhz] = {
|
||
"n": n,
|
||
"rhos": rhos,
|
||
"delta_6h": round(rho_0 - rhos.get(6, rho_0), 4),
|
||
"delta_24h": round(rho_0 - rhos.get(24, rho_0), 4),
|
||
}
|
||
print(f" band {band_mhz} MHz: n={n}, ρ(0h)={rhos.get(0, 0):+.4f}, "
|
||
f"ρ(6h)={rhos.get(6, 0):+.4f}, ρ(24h)={rhos.get(24, 0):+.4f}", file=sys.stderr)
|
||
|
||
# ── Build report ────────────────────────────────────────────────────────
|
||
generated_at = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
|
||
|
||
json_payload = {
|
||
"generated_at": generated_at.isoformat(timespec="seconds"),
|
||
"generated_by": "scripts/validate_forecast.py",
|
||
"schema_version": 1,
|
||
"summary": {
|
||
"total_contacts": len(contacts),
|
||
"contacts_with_all_profiles": contacts_with_all,
|
||
"lead_times_hours": LEAD_TIMES,
|
||
"bands_evaluated": len(per_band_results),
|
||
},
|
||
"per_band": {
|
||
str(b): {
|
||
"n": info["n"],
|
||
"rhos": {str(k): round(v, 4) for k, v in info["rhos"].items()},
|
||
"delta_6h": info["delta_6h"],
|
||
"delta_24h": info["delta_24h"],
|
||
}
|
||
for b, info in sorted(per_band_results.items())
|
||
},
|
||
}
|
||
|
||
json_path = report_dir / f"forecast-{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 table ───────────────────────────────────────────────
|
||
md = []
|
||
md.append(f"# Forecast Skill Degradation — {today}\n")
|
||
md.append(
|
||
"> Auto-generated by `scripts/validate_forecast.py`. Measures how well "
|
||
"the propagation scoring algorithm predicts contact distances as the "
|
||
"weather profile ages. The lag-0h profile uses actual conditions at "
|
||
"contact time (ground truth). Lag-Nh uses the weather profile from "
|
||
"N hours earlier — representing the forecast that would have been "
|
||
"available at that lead time.\n"
|
||
)
|
||
md.append(
|
||
f"- **Total contacts** (≥2020): {len(contacts):,}"
|
||
)
|
||
md.append(
|
||
f"- **Contacts with all {len(LEAD_TIMES)} profiles**: {contacts_with_all:,} " +
|
||
f"({contacts_with_all * 100 // max(1, len(contacts))}%)"
|
||
)
|
||
md.append(f"- **Generated**: {generated_at.isoformat(timespec='seconds')}\n")
|
||
|
||
md.append(
|
||
"Higher ρ = better distance prediction. Δρ(0h→Nh) = ρ(0h) − ρ(Nh) "
|
||
"measures skill degradation — positive values mean the forecast skill "
|
||
"is worse than using current conditions.\n"
|
||
)
|
||
|
||
md.append("## Per-Band Per-Lag Spearman ρ\n")
|
||
|
||
# Build header
|
||
col_headers = "| Band | N |"
|
||
col_sep = "|------|--:|"
|
||
for lead in LEAD_TIMES:
|
||
col_headers += f" ρ({lead}h) |"
|
||
col_sep += "-------:|" if lead == 0 else "------:|"
|
||
col_headers += " Δρ(0→6h) | Δρ(0→24h) |"
|
||
col_sep += "----------|------------|"
|
||
|
||
md.append(col_headers)
|
||
md.append(col_sep)
|
||
|
||
# Sort bands by contact count descending for display
|
||
sorted_bands = sorted(per_band_results.keys(), key=lambda b: per_band_results[b]["n"], reverse=True)
|
||
for band_mhz in sorted_bands:
|
||
info = per_band_results[band_mhz]
|
||
row = f"| {band_mhz} MHz | {info['n']} |"
|
||
for lead in LEAD_TIMES:
|
||
rho = info["rhos"].get(lead, 0.0)
|
||
row += f" {rho:+.4f} |"
|
||
row += f" {info['delta_6h']:+.4f} | {info['delta_24h']:+.4f} |"
|
||
md.append(row)
|
||
|
||
# ── Key findings ────────────────────────────────────────────────────────
|
||
md.append("")
|
||
md.append("## Key Findings\n")
|
||
|
||
# Check monotonic degradation
|
||
monotonic_bands = 0
|
||
non_monotonic_bands = []
|
||
for band_mhz in sorted_bands:
|
||
info = per_band_results[band_mhz]
|
||
rhos = info["rhos"]
|
||
prev = None
|
||
monotonic = True
|
||
for lead in LEAD_TIMES:
|
||
rho = rhos.get(lead, 0.0)
|
||
if prev is not None and rho > prev + 0.001: # slight tolerance
|
||
monotonic = False
|
||
prev = rho
|
||
if monotonic:
|
||
monotonic_bands += 1
|
||
else:
|
||
non_monotonic_bands.append(band_mhz)
|
||
|
||
md.append(f"- **Monotonic degradation** (ρ decreases with lead time): {monotonic_bands}/{len(sorted_bands)} bands")
|
||
|
||
if non_monotonic_bands:
|
||
md.append(f"- **Non-monotonic bands** (ρ increases at some lead): {', '.join(f'{b} MHz' for b in non_monotonic_bands)}")
|
||
md.append(
|
||
" - This may indicate the algorithm puts disproportionate weight "
|
||
"on noisy short-term features that vary within hours. If ρ(6h) > ρ(0h), "
|
||
"the current-conditions score is noisier than the 6h-lagged score — "
|
||
"weather features at lag-0 may introduce variance that degrades the "
|
||
"correlation."
|
||
)
|
||
|
||
# @6h vs @0h comparison
|
||
rho_6h_better = 0
|
||
for band_mhz in sorted_bands:
|
||
info = per_band_results[band_mhz]
|
||
r0 = info["rhos"].get(0, 0.0)
|
||
r6 = info["rhos"].get(6, 0.0)
|
||
if r6 > r0:
|
||
rho_6h_better += 1
|
||
if rho_6h_better > 0:
|
||
md.append(f"- **ρ(6h) > ρ(0h)**: {rho_6h_better} bands — the 6-hour lagged score outperforms current-conditions")
|
||
|
||
# Average degradation
|
||
if per_band_results:
|
||
avg_delta_6 = sum(info["delta_6h"] for info in per_band_results.values()) / len(per_band_results)
|
||
avg_delta_24 = sum(info["delta_24h"] for info in per_band_results.values()) / len(per_band_results)
|
||
md.append(f"- **Mean Δρ(0→6h)**: {avg_delta_6:+.4f}")
|
||
md.append(f"- **Mean Δρ(0→24h)**: {avg_delta_24:+.4f}")
|
||
else:
|
||
md.append("- **No bands passed the ≥30 contact threshold.**")
|
||
|
||
# Caveats
|
||
md.append("")
|
||
md.append("## Caveats\n")
|
||
md.append(
|
||
"- **Lagged profiles use analysis data**: The HRRR profiles at T−N "
|
||
"represent actual weather conditions at that earlier time, not a true "
|
||
"forecast run initialized at T−N. Real HRRR forecasts would contain "
|
||
"model error growth; these measurements capture the degradation from "
|
||
"weather *evolution* alone (the lower bound of forecast skill loss)."
|
||
)
|
||
md.append(
|
||
"- **Snap to nearest grid point**: Profiles are looked up at the HRRR "
|
||
"grid point nearest to the contact location (rounded to 0.125°). "
|
||
"A single point approximates the full path's conditions."
|
||
)
|
||
md.append(
|
||
"- **Filter threshold**: Only bands with ≥30 contacts having ALL "
|
||
"6 profiles (lag-0 through lag-24) are included in the table."
|
||
)
|
||
md.append(
|
||
"- **Censored data**: Contacts are confirmed success events only. "
|
||
"Correlation ρ measures discrimination among successful contacts."
|
||
)
|
||
md.append("")
|
||
|
||
md_path = report_dir / f"forecast-{today}.md"
|
||
md_text = "\n".join(md)
|
||
md_path.write_text(md_text + "\n")
|
||
print(f"• wrote {md_path}", file=sys.stderr)
|
||
|
||
# ── Print to stdout ─────────────────────────────────────────────────────
|
||
print(md_text)
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|