Phase 4 of docs/RUST_REWRITE.md. Since 2026-10-18 ping-collector (rust/) collects, forecasts, notifies (ntfy + Matrix) and serves the website/API; nothing in production ran the Python service code any more. Removed (~17k lines): the FastAPI app (web_api, schemas, health_check, metrics, postgres_history, forecast_history, thaiwater), the Python RID collector and CLI (water_scraper_v3, main, run.py), the notifier and Matrix alerting (notify, alerting), the multi-backend adapters (SQLite, MySQL, InfluxDB, VictoriaMetrics) with demo databases, models/validators/exceptions/ logging/rate limiter, ml/skill.py (served by Rust now), the migration tooling (api_parity, collector_parity, e2e_notify, install_collector.sh, gen_notify_cases.py, spikes/rust-forecast), Docker/compose/Grafana files, sql/, the release workflow and the docs for all of that. Their tests went with them. Kept in Python: src/ml (training, evaluation harness, export; predict.py as the reference the Rust forecaster is tested against), the HII and RID-dam clients/stores for the archive backfills (their live collectors removed), the backfill scripts, retrain.sh + timer, forecast_parity.py. - PostgreSQL only: src/config.py is database_url() from POSTGRES_* / .env; the stores lost their sqlite/mysql branches (and the 2026-08 column migration). The database tests run against a throwaway schema from TEST_DATABASE_URL (tests/conftest.py pg_url, skipped without it), as the Rust tests do; CI gets a PostgreSQL service. 100 tests (66 without a DB). - The notifier settings moved from src/notify.py into src/ml/export.py; flood_config.json regenerates byte-identical. - tests/test_dashboard.py checks every path the dashboard fetches against ping-collector's router. - Dependencies: fastapi, uvicorn, pydantic, influxdb, pymysql, psutil, schedule, sphinx, pyinstaller, mypy, pytest-asyncio dropped; python-dotenv 1.1.1 explicit (it came in through uvicorn[standard]). - Formatting contract extended to scripts/ and tests/ (reformatted once). - scripts/install.sh is the one installer (fresh host or update, in place in a checkout without touching its ownership): binary, units, uv sync, .env defaults, removes the retired water-monitor.service and any old --alert-check cron job, dry run + forecast check, restart, readiness wait. - Docs, CLAUDE.md, CONTRIBUTING.md, .env.example, Makefile, unit comments and Rust doc comments updated to the Rust service.
181 lines
5.8 KiB
Python
181 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Compare ping-collector's forecast with the Python reference on the live database.
|
|
|
|
src/ml/predict.py is the reference implementation the Rust forecaster
|
|
(rust/forecast) was ported from. Run after any change to src/ml or
|
|
rust/forecast; scripts/install.sh runs it on every install. On the server, as
|
|
the service user from the app dir:
|
|
|
|
sudo -u water-monitor .venv/bin/python scripts/forecast_parity.py
|
|
|
|
Both sides get the same moment and the same Open-Meteo rain series (fetched
|
|
once here and handed to the collector as a file), so a difference is a porting
|
|
bug, not timing. Exit 0 when every row matches: station, horizon, as_of,
|
|
source, model and every level exactly; probabilities within a few units in the
|
|
last place (numpy's and libm's exp may differ in the last bit).
|
|
"""
|
|
|
|
import argparse
|
|
import datetime
|
|
import json
|
|
import math
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(REPO))
|
|
|
|
ULPS = 4
|
|
EXACT = (
|
|
"station_code",
|
|
"horizon_hours",
|
|
"as_of",
|
|
"source",
|
|
"model_version",
|
|
"trained_at",
|
|
"current_level",
|
|
"predicted_max_level",
|
|
"threshold_warning",
|
|
"threshold_danger",
|
|
)
|
|
|
|
|
|
def ulps(a: float, b: float) -> float:
|
|
if a == b:
|
|
return 0
|
|
return abs(a - b) / math.ulp(max(abs(a), abs(b)))
|
|
|
|
|
|
def compare(py: list, rs: list) -> list:
|
|
problems = []
|
|
if len(py) != len(rs):
|
|
problems.append(f"row count: python {len(py)}, collector {len(rs)}")
|
|
for p, r in zip(py, rs):
|
|
where = f"{p.get('station_code')} {p.get('horizon_hours')}h"
|
|
for k in EXACT:
|
|
if p.get(k) != r.get(k):
|
|
problems.append(
|
|
f"{where} {k}: python {p.get(k)!r}, collector {r.get(k)!r}"
|
|
)
|
|
for k in ("p_warning", "p_danger"):
|
|
d = ulps(float(p[k]), float(r[k]))
|
|
if d > ULPS:
|
|
problems.append(
|
|
f"{where} {k}: python {p[k]!r}, collector {r[k]!r} ({d:.0f} ulp)"
|
|
)
|
|
ps, rs_ = p.get("stages"), r.get("stages")
|
|
if (ps is None) != (rs_ is None) or (ps and len(ps) != len(rs_)):
|
|
problems.append(f"{where} stages differ")
|
|
elif ps:
|
|
for a, b in zip(ps, rs_):
|
|
if a["stage"] != b["stage"] or a["level"] != b["level"]:
|
|
problems.append(f"{where} stage {a['stage']} differs")
|
|
elif ulps(a["p_exceed"], b["p_exceed"]) > ULPS:
|
|
problems.append(
|
|
f"{where} stage {a['stage']} p_exceed {a['p_exceed']} vs {b['p_exceed']}"
|
|
)
|
|
return problems
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
parser.add_argument("--collector", default=str(REPO / "bin" / "ping-collector"))
|
|
parser.add_argument("--models-dir", default=str(REPO / "models"))
|
|
parser.add_argument(
|
|
"--db-url", default=None, help="default: the app's configuration"
|
|
)
|
|
parser.add_argument("--now", default=None, help="local time, default the clock")
|
|
args = parser.parse_args()
|
|
|
|
from src.ml import predict
|
|
from src.ml import rain as rain_mod
|
|
from src.ml.export import json_path_for
|
|
|
|
models = Path(args.models_dir)
|
|
stale = [
|
|
p.name
|
|
for p in models.glob("flood_*.joblib")
|
|
if not json_path_for(p).exists()
|
|
or json_path_for(p).stat().st_mtime < p.stat().st_mtime
|
|
]
|
|
if stale:
|
|
print(
|
|
f"not exported for the collector: {', '.join(stale)}; run `python -m src.ml.export`"
|
|
)
|
|
return 2
|
|
|
|
now = (
|
|
datetime.datetime.fromisoformat(args.now)
|
|
if args.now
|
|
else datetime.datetime.now()
|
|
)
|
|
rain = rain_mod.serving_series()
|
|
if rain is None:
|
|
import pandas as pd
|
|
|
|
rain = pd.Series(dtype=float)
|
|
py = predict.get_latest_forecasts(
|
|
db_url=args.db_url, models_dir=models, now=now, rain=rain
|
|
)
|
|
py = json.loads(json.dumps(py, default=float))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
rain_file = Path(tmp) / "rain.json"
|
|
rain_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"timestamps": [t.strftime("%Y-%m-%dT%H:%M:%S") for t in rain.index],
|
|
"values": [None if math.isnan(v) else float(v) for v in rain],
|
|
}
|
|
)
|
|
)
|
|
env = dict(os.environ, MODELS_DIR=str(models), RUST_LOG="error")
|
|
if args.db_url:
|
|
env["POSTGRES_CONNECTION_STRING"] = args.db_url
|
|
out = subprocess.run(
|
|
[
|
|
args.collector,
|
|
"forecast",
|
|
"--json",
|
|
"--now",
|
|
now.isoformat(),
|
|
"--rain-file",
|
|
str(rain_file),
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
cwd=REPO,
|
|
check=False,
|
|
)
|
|
if out.returncode != 0:
|
|
print(f"ping-collector forecast failed:\n{out.stderr}")
|
|
return 2
|
|
rs = json.loads(out.stdout)
|
|
|
|
problems = compare(py, rs)
|
|
models_used = sorted({r["model_version"] for r in py})
|
|
n_model = sum(r["source"] == "model" for r in py)
|
|
print(
|
|
f"as of {now:%Y-%m-%d %H:%M:%S}: {len(py)} rows ({n_model} from models {models_used}), "
|
|
f"rain {int(rain.notna().sum())} h"
|
|
)
|
|
if problems:
|
|
print(f"{len(problems)} DIFFERENCES:")
|
|
for p in problems[:40]:
|
|
print(" " + p)
|
|
return 1
|
|
exact = sum(p[k] == r[k] for p, r in zip(py, rs) for k in ("p_warning", "p_danger"))
|
|
print(
|
|
f"identical: every level and field; probabilities {exact}/{2 * len(py)} bit-identical, "
|
|
f"the rest within {ULPS} ulp"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|