Direct queries against prod (75M HRRR profiles, 16,864 soundings, 392k surface obs, new hrrr_native_profiles table) drive these changes: * Reinstate shallow-BL bonus as an HPBL multiplier on the refractivity score (1.10× <200m → 0.78× ≥2000m). The Apr 11 "shallow BL bonus removed" conclusion was an artifact of the prior matching strategy; with the cleaner contact↔HRRR join (n=680) the binned data goes 230 km avg at HPBL <200m vs 100 km at ≥2000m, monotonic across all bins. * Add 6 missing bands (142, 145, 288, 322, 403, 411 GHz) so contacts in those bands stop being silently dropped. Coefficients extrapolate ITU-R P.676/P.838 trends from the existing 134/241 GHz entries. * Bump ERA5 poll timeout 10 min → 1 hour and Era5MonthBatchWorker max_attempts 3 → 5 so CDS slowness stops discarding tiles. Also dump the full failure body when CDS omits the message field — the prior "ERA5 job failed: nil" was masking real error reasons in oban_jobs. * Add scripts/recalibrate_algo.py so this analysis can be re-run any time new data lands without manual SQL. Reads PROP_PROD_DB_URL from .envrc, drops a Markdown report into docs/algo-reports/. * Append a dated Part 2c to algo.md documenting the corpus expansion, the honest accounting of historical HRRR coverage (only ~1,020 of 58k contacts are precision-matchable), and the empty era5/rtma/climatology tables. Update the gaseous-absorption and rain-attenuation tables to include the new bands. Test suite: 1,335 tests, 0 failures.
344 lines
12 KiB
Python
Executable file
344 lines
12 KiB
Python
Executable file
#!/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`),
|
|
and produces a Markdown report covering:
|
|
|
|
* row counts and date ranges for the key tables
|
|
* per-band contact distribution including bands that BandConfig hasn't seen
|
|
* monthly sounding ducting refresh
|
|
* native HRRR profile duct stats
|
|
* per-band Pearson correlations of contact distance vs HRRR fields
|
|
* binned distance distributions for HPBL and pressure at 10 GHz
|
|
|
|
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
|
|
import textwrap
|
|
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 ──────────────────────────────────────────────────────────
|
|
|
|
ROW_COUNTS_SQL = """
|
|
SELECT 'contacts' tbl, count(*)::bigint n,
|
|
min(qso_timestamp)::date lo, max(qso_timestamp)::date hi FROM contacts
|
|
UNION ALL SELECT 'surface_observations', count(*),
|
|
min(observed_at)::date, max(observed_at)::date FROM surface_observations
|
|
UNION ALL SELECT 'soundings', count(*),
|
|
min(observed_at)::date, max(observed_at)::date FROM soundings
|
|
UNION ALL SELECT 'hrrr_profiles', count(*),
|
|
min(valid_time)::date, max(valid_time)::date FROM hrrr_profiles
|
|
UNION ALL SELECT 'hrrr_native_profiles', count(*),
|
|
min(valid_time)::date, max(valid_time)::date FROM hrrr_native_profiles
|
|
UNION ALL SELECT 'era5_profiles', count(*),
|
|
min(valid_time)::date, max(valid_time)::date FROM era5_profiles
|
|
UNION ALL SELECT 'iemre_observations', count(*), NULL::date, NULL::date
|
|
FROM iemre_observations
|
|
UNION ALL SELECT 'nexrad_observations', count(*),
|
|
min(observed_at)::date, max(observed_at)::date FROM nexrad_observations
|
|
UNION ALL SELECT 'rtma_observations', count(*),
|
|
min(valid_time)::date, max(valid_time)::date FROM rtma_observations
|
|
UNION ALL SELECT 'terrain_profiles', count(*), NULL::date, NULL::date
|
|
FROM terrain_profiles
|
|
UNION ALL SELECT 'propagation_scores', count(*),
|
|
min(valid_time)::date, max(valid_time)::date FROM propagation_scores
|
|
ORDER BY tbl;
|
|
"""
|
|
|
|
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 >= 902
|
|
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.
|
|
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;
|
|
"""
|
|
|
|
# Empty-table / data-quality smoke checks.
|
|
DATA_GAP_SQL = """
|
|
SELECT
|
|
(SELECT count(*) FROM era5_profiles) AS era5_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;
|
|
"""
|
|
|
|
|
|
# ─── 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 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 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_df(conn, ROW_COUNTS_SQL)))
|
|
|
|
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 (≥902 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 (this is the slow one)", file=sys.stderr)
|
|
joined = fetch_df(conn, PER_BAND_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## 10 GHz: HPBL bin distance distribution\n")
|
|
sections.append(md_table(hpbl_bins(joined)))
|
|
|
|
sections.append("\n## 10 GHz: pressure bin distance distribution\n")
|
|
sections.append(md_table(pressure_bins(joined)))
|
|
|
|
out_path.write_text("\n".join(sections))
|
|
print(f"wrote {out_path}", file=sys.stderr)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|