thermograph/backend/drift_check.py

137 lines
5.8 KiB
Python
Raw Permalink Normal View History

"""Value-drift check: quantify how much the migration moved the data, per variable
and by grade band, by comparing the two live history sources head-to-head.
Open-Meteo is kept as a dormant fallback, so both sources are still fetchable: this
pulls NASA POWER (the new primary) and the Open-Meteo archive (ERA5) for a sample of
cells and reports, per variable:
- mean-absolute-difference / bias / max the RAW value drift, and
- grade-band divergence the share of (day, metric) whose graded band actually
changes between sources.
Since Open-Meteo IS ERA5, this doubles as a fidelity check for the ERA5 seed.
python drift_check.py [--limit N] [--days N] # on a networked box
Note a real property of the grading model worth reading the two numbers together:
grades are percentiles within each source's OWN distribution, so a uniform bias
between sources shifts MAD but leaves grades unchanged. High MAD with low grade
divergence means "different absolute values, same story" usually fine. High grade
divergence is the signal that actually matters.
The comparison logic (compare_values / grade_band_divergence) is unit-tested; the
fetch is not.
"""
import datetime
import sys
import polars as pl
from data import cities
from data import climate
from data import grading
from data import grid
# Variables compared for raw drift (feels is derived; precip/wind/gust/humid optional).
_METRICS = ("tmax", "tmin", "precip", "wind", "gust", "humid", "feels")
def compare_values(a: pl.DataFrame, b: pl.DataFrame, metrics=_METRICS) -> dict:
"""Per-variable raw-drift stats over the dates the two frames share: n aligned
days, mean-absolute-difference, mean bias (a - b), and max absolute difference.
Days where either source is null for a metric are dropped from that metric."""
j = a.join(b, on="date", how="inner", suffix="_b")
out = {}
for m in metrics:
if m not in a.columns or m not in b.columns:
continue
d = j.select((pl.col(m) - pl.col(f"{m}_b")).alias("d")).drop_nulls()
if d.height == 0:
out[m] = {"n": 0, "mad": None, "bias": None, "max_abs": None}
continue
diff = d["d"]
out[m] = {
"n": d.height,
"mad": round(float(diff.abs().mean()), 3),
"bias": round(float(diff.mean()), 3),
"max_abs": round(float(diff.abs().max()), 3),
}
return out
def grade_band_divergence(a: pl.DataFrame, b: pl.DataFrame, start, end) -> dict:
"""Per-metric share of days whose graded band differs between the two sources over
[start, end]. Each source's days are graded against its OWN ±7-day climatology
(grading.grade_range), so this measures whether the migration changes the story a
day tells, not just its raw value."""
ga = {d["date"]: d for d in grading.grade_range(a, start, end)}
gb = {d["date"]: d for d in grading.grade_range(b, start, end)}
per: dict[str, list[int]] = {}
for date in ga.keys() & gb.keys():
da, db = ga[date], gb[date]
for m in grading.CLIMO_METRICS:
va, vb = da.get(m), db.get(m)
if va and vb:
slot = per.setdefault(m, [0, 0]) # [compared, differ]
slot[0] += 1
slot[1] += int(va["g"] != vb["g"])
return {m: {"compared": c, "differ": d, "pct": round(100.0 * d / c, 1) if c else None}
for m, (c, d) in sorted(per.items())}
def check_cell(cell: dict, days: int) -> dict:
"""Fetch both sources for a cell and compare them over the most recent `days`."""
nasa = climate._fetch_history_nasa(cell) # new primary (+ Meteostat gusts)
om = climate._fetch_history(cell) # Open-Meteo archive (ERA5)
end = min(nasa["date"].max(), om["date"].max())
start = end - datetime.timedelta(days=days)
return {"values": compare_values(nasa, om),
"grades": grade_band_divergence(nasa, om, start, end)}
def _mean(xs: list) -> "float | None":
xs = [x for x in xs if x is not None]
return round(sum(xs) / len(xs), 3) if xs else None
def main(limit: "int | None" = None, days: int = 365) -> None:
todo = cities.all_cities()
if limit:
todo = todo[:limit]
mad_by_metric: dict[str, list] = {}
grade_by_metric: dict[str, list] = {}
checked = failed = 0
for i, c in enumerate(todo, 1):
cell = grid.snap(c["lat"], c["lon"])
try:
rep = check_cell(cell, days)
except Exception as e: # noqa: BLE001 - one cell's outage shouldn't abort the sweep
failed += 1
print(f"[{i}/{len(todo)}] FAILED {c['slug']}: {e}")
continue
checked += 1
vals = ", ".join(f"{m} mad={s['mad']}" for m, s in rep["values"].items()
if s.get("mad") is not None)
grds = ", ".join(f"{m} {s['pct']}%" for m, s in rep["grades"].items())
print(f"[{i}/{len(todo)}] {c['slug']} ({cell['id']})")
print(f" values: {vals}")
print(f" grade drift: {grds}")
for m, s in rep["values"].items():
mad_by_metric.setdefault(m, []).append(s.get("mad"))
for m, s in rep["grades"].items():
grade_by_metric.setdefault(m, []).append(s.get("pct"))
print(f"\n=== summary over {checked} cells (failed={failed}) ===")
print("mean MAD (raw drift):")
for m in _METRICS:
if m in mad_by_metric:
print(f" {m:7s} {_mean(mad_by_metric[m])}")
print("mean grade-band divergence (share of days a grade changes):")
for m in sorted(grade_by_metric):
print(f" {m:7s} {_mean(grade_by_metric[m])}%")
if __name__ == "__main__":
argv = sys.argv[1:]
lim = int(argv[argv.index("--limit") + 1]) if "--limit" in argv else None
dys = int(argv[argv.index("--days") + 1]) if "--days" in argv else 365
main(limit=lim, days=dys)