prop/scripts/recalibrate_algo.py
Graham McIntire 5fd37a17fd
feat(propagation): per-band weight calibration from full-corpus correlation
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
2026-04-18 10:19:26 -05:00

711 lines
27 KiB
Python
Executable file
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
"""
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"),
("rtma_observations", "valid_time"),
("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.
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 rtma_observations) AS rtma_rows,
(SELECT count(*) FROM metar_5min_observations) AS metar5_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;
"""
# ─── 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 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()
sections.append("\n## Data gaps (these should NOT be zero in a healthy prod)\n")
sections.append(
"\n".join(f"- **{k}**: {v}" for k, v in gaps.items()) + "\n"
)
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())