137 lines
5.8 KiB
Python
137 lines
5.8 KiB
Python
|
|
"""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)
|