feat: refuse silent v3->v2 downgrade; monthly retrain timer with staged promote

train_all() now raises RainUnavailableError when use_rain=True and the
Open-Meteo history cannot be loaded, instead of logging a warning and
writing gauge-only (v2) bundles over the deployed v3 set -- which is what
the 2026-09-01 server retrain did unnoticed. --no-rain remains the explicit
way to get v2. CLI exits 2 with a one-line error. Three tests cover the
guard, the opt-out, and the v3 happy path.

scripts/retrain.sh trains into models/.staging, refuses to promote unless
metrics.json shows hgb-v3+ and >=14 trained stations, then renames bundles
into place (previous generation kept in models/.previous). No API restart:
predict.py reloads by mtime on the hourly precompute.

water-monitor-retrain.{service,timer}: 1st of each month 03:30, Persistent,
OMP_NUM_THREADS=4, Nice=15, same sandbox as the API unit. install.sh now
does `uv sync` into .venv (one env rule; removes a stale venv/) and enables
the timer. water-monitor.service in the repo matched neither the deployed
unit nor the uv env; it now does (run.py --web-api, .venv, EnvironmentFile).
This commit is contained in:
2026-09-11 21:37:11 +02:00
parent 0a4bf843ff
commit 764764e07e
11 changed files with 326 additions and 26 deletions
+69
View File
@@ -233,6 +233,75 @@ def test_heuristic_fallback(tmp_path):
assert row["trained_at"] is None
def _p1_synth(n: int = 300, seed: int = 11) -> pd.DataFrame:
upstream = [code for code, _lead in features.UPSTREAM_LEADS["P.1"]]
return make_synth(n, ["P.1"] + upstream, seed=seed, pulses={"P.1": [(100, 20, 2.0)]})
def test_train_refuses_silent_rain_downgrade(tmp_path, monkeypatch):
"""use_rain=True with no rain series must abort, not write v2 bundles.
Regression for the 2026-09-01 server retrain that overwrote v3 with v2
because the Open-Meteo archive fetch failed on a cache-less checkout.
"""
from src.ml import rain as rain_mod
df = _p1_synth()
overrides = {"max_iter": 10}
# Case 1: the loader returns None (archive unreachable, no cache file)
monkeypatch.setattr(rain_mod, "load_history", lambda *a, **k: None)
with pytest.raises(train.RainUnavailableError, match="--no-rain"):
train.train_all(df, ["P.1"], models_dir=tmp_path, skip_eval=True, hgb_overrides=overrides, use_rain=True, use_dam=False)
assert not (tmp_path / "flood_P.1.joblib").exists()
assert not (tmp_path / "metrics.json").exists()
# Case 2: the loader raises (network / parse error)
def boom(*a, **k):
raise ConnectionError("simulated Open-Meteo outage")
monkeypatch.setattr(rain_mod, "load_history", boom)
with pytest.raises(train.RainUnavailableError, match="simulated Open-Meteo outage"):
train.train_all(df, ["P.1"], models_dir=tmp_path, skip_eval=True, hgb_overrides=overrides, use_rain=True, use_dam=False)
assert not (tmp_path / "flood_P.1.joblib").exists()
# Explicit opt-out still produces v2 bundles as before
metrics = train.train_all(df, ["P.1"], models_dir=tmp_path, skip_eval=True, hgb_overrides=overrides, use_rain=False, use_dam=False)
assert metrics["model_version"].startswith("hgb-v2+")
assert (tmp_path / "flood_P.1.joblib").exists()
def test_train_with_rain_series_yields_v3(tmp_path, monkeypatch):
from src.ml import rain as rain_mod
df = _p1_synth()
idx = pd.date_range(df["timestamp"].min(), df["timestamp"].max(), freq="h")
fake_rain = pd.DataFrame({"a": np.linspace(0, 1, len(idx)), "b": 0.5}, index=idx)
monkeypatch.setattr(rain_mod, "load_history", lambda *a, **k: fake_rain)
metrics = train.train_all(df, ["P.1"], models_dir=tmp_path, skip_eval=True, hgb_overrides={"max_iter": 10}, use_rain=True, use_dam=False)
assert metrics["model_version"].startswith("hgb-v3+")
bundle = joblib.load(tmp_path / "flood_P.1.joblib")
assert set(features.RAIN_FEATURES) <= set(bundle["feature_names"])
def test_cli_exit_code_on_rain_failure(tmp_path, monkeypatch, caplog):
"""The console entry turns the guard into a one-line error and exit 2."""
from src.ml import rain as rain_mod
df = _p1_synth()
monkeypatch.setattr(rain_mod, "load_history", lambda *a, **k: None)
monkeypatch.setattr(train, "load_measurements", lambda *a, **k: df)
monkeypatch.setattr(train, "resolve_db_url", lambda *a, **k: None)
monkeypatch.setattr(
"sys.argv",
["train", "--stations", "P.1", "--models-dir", str(tmp_path), "--skip-eval"],
)
assert train.cli() == 2
assert "refusing to silently downgrade" in caplog.text
assert not (tmp_path / "metrics.json").exists()
def test_feature_name_stability(tmp_path):
upstream = [code for code, _lead in features.UPSTREAM_LEADS["P.1"]]
data_stations = ["P.1"] + upstream