Files
Northern-Thailand-Ping-Rive…/src/ml/predict.py
T
grabowski 5ad8e4eac3 ci: green pipelines that check what exists; one formatting contract
The Test Suite job failed on every push since the black check was added
because the tree had never been formatted, and pre-commit said 120
columns while CI ran black's default 88. pyproject.toml now carries
[tool.black] / [tool.isort] (88, black profile) as the single source;
pre-commit reads it; `make format` applied it (13 files, whitespace only,
146 insertions / 128 deletions, tests unchanged at 146 passed).

ci.yml: lint (black, isort, flake8 hard errors) + pytest. The Docker
registry push, VictoriaMetrics integration test, staging/production
deploy and Apache-Bench jobs were template scaffolding for hosts and
registries that do not exist; production is a systemd unit updated by
git pull. Removed rather than left permanently skipped.

docs.yml: the "Check markdown links" step curl'd every URL in every .md
and failed on localhost examples and the Tailscale IP, and the Sphinx
jobs built artifacts nobody read. Replaced by two checks that mean
something: relative links/images in README, CONTRIBUTING and docs/
resolve inside the repo, and the FastAPI OpenAPI schema exports with
the documented endpoints present (uploaded as an artifact).
2026-09-11 23:05:37 +02:00

408 lines
14 KiB
Python

"""Flood forecast inference.
Integration contract (see get_forecasts / get_latest_forecasts): callers pass
raw station readings, get back one forecast dict per station x horizon. A
station with a stale, missing, or version-mismatched model transparently
falls back to a simple persistence heuristic instead of raising -- this
module must never crash the caller (e.g. the web API).
"""
import datetime
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
import joblib
import numpy as np
import pandas as pd
from . import features
logger = logging.getLogger(__name__)
# Anchored to the repo root so the API finds trained bundles regardless of CWD.
DEFAULT_MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
DEFAULT_HORIZONS: Tuple[int, ...] = (6, 12, 24)
STALE_AFTER_H = 6.0
HEURISTIC_SIGMA = 0.3
HEURISTIC_VERSION = "heuristic-v1"
# Keyed by (path, mtime) so a retrained model (new mtime) invalidates the old entry.
_MODEL_CACHE: Dict[Tuple[str, float], dict] = {}
def _load_bundle(path: Path) -> dict:
# joblib.load runs arbitrary pickle code; safe here because `path` is always
# models/flood_{station}.joblib, an artifact this pipeline's own train.py wrote --
# never a user- or network-supplied file.
key = (str(path), path.stat().st_mtime)
cached = _MODEL_CACHE.get(key)
if cached is not None:
return cached
bundle = joblib.load(path)
for stale_key in [k for k in _MODEL_CACHE if k[0] == str(path)]:
del _MODEL_CACHE[stale_key]
_MODEL_CACHE[key] = bundle
return bundle
def _readings_to_long_df(readings_by_station: Dict[str, List[dict]]) -> pd.DataFrame:
rows = []
for station_code, readings in readings_by_station.items():
for reading in readings:
timestamp = reading.get("timestamp")
if isinstance(timestamp, str):
timestamp = pd.to_datetime(timestamp)
rows.append(
{
"timestamp": timestamp,
"station_code": station_code,
"water_level": reading.get("water_level"),
"discharge": reading.get("discharge"),
}
)
if not rows:
return pd.DataFrame(
columns=["timestamp", "station_code", "water_level", "discharge"]
)
df = pd.DataFrame(rows)
return df.dropna(subset=["timestamp"])
def _clip_probability(value: float) -> float:
return float(min(max(value, 0.0), 1.0))
def _sigmoid_probability(predicted_max: float, threshold: float, sigma: float) -> float:
return 1.0 / (1.0 + np.exp(-(predicted_max - threshold) / sigma))
def _heuristic_forecast(
station_code: str,
as_of: pd.Timestamp,
current_level: float,
level_t_minus_3: Optional[float],
warn_thr: float,
danger_thr: float,
horizons: Tuple[int, ...],
) -> List[dict]:
if level_t_minus_3 is None:
rate = 0.0
else:
rate = max(0.0, (current_level - level_t_minus_3) / 3.0)
results = []
for horizon_h in horizons:
predicted_max = max(current_level + rate * horizon_h * 0.7, current_level)
p_warning = _clip_probability(
_sigmoid_probability(predicted_max, warn_thr, HEURISTIC_SIGMA)
)
p_danger = _clip_probability(
_sigmoid_probability(predicted_max, danger_thr, HEURISTIC_SIGMA)
)
p_danger = min(p_danger, p_warning)
results.append(
{
"station_code": station_code,
"horizon_hours": horizon_h,
"p_warning": p_warning,
"p_danger": p_danger,
"predicted_max_level": predicted_max,
"current_level": current_level,
"as_of": as_of.isoformat(),
"model_version": HEURISTIC_VERSION,
"trained_at": None,
"source": "heuristic",
"threshold_warning": warn_thr,
"threshold_danger": danger_thr,
}
)
return results
def _model_forecast(
station_code: str,
grid: features.HourlyGrid,
bundle: dict,
as_of: pd.Timestamp,
current_level: float,
rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> List[dict]:
warn_thr = bundle["thresholds"]["warning"]
danger_thr = bundle["thresholds"]["danger"]
# If thresholds changed since this bundle was trained, its classifier heads
# answer the OLD question (labels for the old levels) while stages/config use
# the new ones — a silent contradiction on the dashboard. Until a retrain,
# answer the current question consistently: use the regression + sigma against
# the configured thresholds and skip the stale heads.
cfg_warn, cfg_danger = features.get_thresholds(station_code)
thresholds_stale = (cfg_warn, cfg_danger) != (warn_thr, danger_thr)
if thresholds_stale:
logger.warning(
f"{station_code}: bundle thresholds ({warn_thr}, {danger_thr}) differ from "
f"configured ({cfg_warn}, {cfg_danger}); using regression-derived probabilities "
"until the model is retrained"
)
warn_thr, danger_thr = cfg_warn, cfg_danger
feature_row = features.build_features(grid, station_code, rain=rain, dam=dam).loc[
[as_of]
]
expected_columns = bundle["feature_names"]
missing = [c for c in expected_columns if c not in feature_row.columns]
if missing:
logger.error(
f"Feature mismatch for {station_code} (missing {missing}); falling back to heuristic"
)
return None
feature_row = feature_row[expected_columns]
results = []
for horizon_h in bundle["horizons"]:
reg = bundle["heads"].get(f"max_{horizon_h}")
if reg is None:
results.append(None)
continue
raw_prediction = float(reg.predict(feature_row)[0])
if bundle.get("regression_target") == "rise":
# v2 bundles predict the rise over the current level
raw_prediction += current_level
predicted_max = max(raw_prediction, current_level)
sigma_h = bundle["sigma"].get(horizon_h, HEURISTIC_SIGMA)
# Belt-and-braces: the classifier head OR the regression-sigmoid path,
# whichever is more alarmed. The 2026-08-11 backtest showed a trained
# classifier staying silent through the 2024 record flood while the
# regression head tracked it — alerting must never be worse than the
# regression fallback.
warn_head = (
None if thresholds_stale else bundle["heads"].get(f"warn_{horizon_h}")
)
p_warning = _sigmoid_probability(predicted_max, warn_thr, sigma_h)
if warn_head is not None:
p_warning = max(
p_warning, float(warn_head.predict_proba(feature_row)[0][1])
)
danger_head = (
None if thresholds_stale else bundle["heads"].get(f"danger_{horizon_h}")
)
p_danger = _sigmoid_probability(predicted_max, danger_thr, sigma_h)
if danger_head is not None:
p_danger = max(
p_danger, float(danger_head.predict_proba(feature_row)[0][1])
)
p_warning = _clip_probability(p_warning)
p_danger = min(_clip_probability(p_danger), p_warning)
row = {
"station_code": station_code,
"horizon_hours": horizon_h,
"p_warning": p_warning,
"p_danger": p_danger,
"predicted_max_level": predicted_max,
"current_level": current_level,
"as_of": as_of.isoformat(),
"model_version": bundle["model_version"],
"trained_at": bundle["trained_at"],
"source": "model",
"threshold_warning": warn_thr,
"threshold_danger": danger_thr,
}
stages = features.FLOOD_STAGES.get(station_code)
if stages:
# Exceedance probability per official inundation stage, from the
# regression head and its validation-residual sigma. These are
# threshold-agnostic, so no retraining is needed to serve them.
row["stages"] = [
{
"stage": s["stage"],
"level": s["level"],
"p_exceed": _clip_probability(
_sigmoid_probability(predicted_max, s["level"], sigma_h)
),
}
for s in stages
]
results.append(row)
return results
def _forecast_station(
station_code: str,
grid: features.HourlyGrid,
models_dir: Path,
now: pd.Timestamp,
horizons: Tuple[int, ...],
rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> List[dict]:
level_col = (station_code, "water_level")
if level_col not in grid.observed.columns:
logger.warning(f"No data for station {station_code}; omitting")
return []
observed_level = grid.observed[level_col].dropna()
if observed_level.empty:
logger.warning(f"No observed readings for station {station_code}; omitting")
return []
as_of = observed_level.index.max()
current_level = float(observed_level.loc[as_of])
staleness_h = (pd.Timestamp(now) - as_of).total_seconds() / 3600.0
warn_thr, danger_thr = features.get_thresholds(station_code)
t_minus_3 = as_of - pd.Timedelta(hours=3)
level_t_minus_3 = (
float(observed_level.loc[t_minus_3])
if t_minus_3 in observed_level.index
else None
)
bundle_path = models_dir / f"flood_{station_code}.joblib"
if not bundle_path.exists() or staleness_h > STALE_AFTER_H:
return _heuristic_forecast(
station_code,
as_of,
current_level,
level_t_minus_3,
warn_thr,
danger_thr,
horizons,
)
bundle = _load_bundle(bundle_path)
model_results = _model_forecast(
station_code, grid, bundle, as_of, current_level, rain=rain, dam=dam
)
if model_results is None:
return _heuristic_forecast(
station_code,
as_of,
current_level,
level_t_minus_3,
warn_thr,
danger_thr,
horizons,
)
# Per-horizon heads that were skipped at train time (e.g. too few positives) still
# need a forecast row -- fall back to the single-horizon heuristic for just that row.
filled = []
for horizon_h, row in zip(bundle["horizons"], model_results):
if row is not None:
filled.append(row)
else:
filled.extend(
_heuristic_forecast(
station_code,
as_of,
current_level,
level_t_minus_3,
warn_thr,
danger_thr,
(horizon_h,),
)
)
return filled
def get_forecasts(
readings_by_station: Dict[str, List[dict]],
models_dir: Union[str, Path] = DEFAULT_MODELS_DIR,
now: Optional[Union[datetime.datetime, str]] = None,
rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> List[dict]:
"""Produce flood forecasts for every station present in `readings_by_station`.
Each reading dict needs at least {timestamp, water_level, discharge}; extra
keys are ignored so raw API/DB rows can be passed straight through. At
least 96 hours of span is required to populate every feature; 336 hours
(14 days) is recommended.
"""
models_dir = Path(models_dir)
if now is None:
now = datetime.datetime.now()
now = pd.Timestamp(now)
df_long = _readings_to_long_df(readings_by_station)
if df_long.empty:
return []
grid = features.make_hourly_grid(df_long)
results: List[dict] = []
for station_code in readings_by_station.keys():
try:
results.extend(
_forecast_station(
station_code,
grid,
models_dir,
now,
DEFAULT_HORIZONS,
rain=rain,
dam=dam,
)
)
except Exception as error:
logger.error(f"Forecast failed for station {station_code}: {error}")
return results
def get_latest_forecasts(
db_url: Optional[str] = None,
models_dir: Union[str, Path] = DEFAULT_MODELS_DIR,
hours: int = 336,
) -> List[dict]:
"""Convenience wrapper for web_api: load the latest window from the DB/API and forecast.
Raises FileNotFoundError when no trained model bundle exists at all, so the
API can 503 instead of serving purely heuristic output as if it were a forecast.
"""
from .data import load_latest
if not sorted(Path(models_dir).glob("flood_*.joblib")):
raise FileNotFoundError(f"no trained model bundles in {models_dir}")
df_long = load_latest(db_url=db_url, hours=hours)
readings_by_station: Dict[str, List[dict]] = {}
if not df_long.empty:
for station_code, group in df_long.groupby("station_code"):
readings_by_station[station_code] = group[
["timestamp", "water_level", "discharge"]
].to_dict("records")
expected_stations = set(features.UPSTREAM_LEADS.keys())
for missing_station in expected_stations - set(readings_by_station.keys()):
logger.warning(
f"No recent data for station {missing_station}; omitting from forecasts"
)
# Live rain: trailing days + next-48h forecast. On fetch failure pass an
# EMPTY series (not None) so rain-trained bundles still find their columns
# (as NaN) and serve model output instead of tripping the feature guard.
from .rain import serving_series
rain = serving_series()
if rain is None:
logger.warning("live rain unavailable; rain features will be NaN")
rain = pd.Series(dtype=float)
# Recent Mae Ngat reservoir state; same empty-not-None contract so
# dam-trained bundles keep their columns (NaN) when the DB read fails.
from . import dam as dam_mod
try:
dam = dam_mod.serving_frame(db_url=db_url)
except Exception as error:
logger.warning(f"dam serving frame failed: {error}")
dam = None
if dam is None:
logger.warning("dam state unavailable; dam features will be NaN")
dam = pd.DataFrame()
return get_forecasts(readings_by_station, models_dir=models_dir, rain=rain, dam=dam)