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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user