feat: ML flood-event forecasting from 8 years of gauge history
Security & Dependency Updates / Dependency Security Scan (push) Successful in 1m8s
Security & Dependency Updates / License Compliance (push) Successful in 25s
Security & Dependency Updates / Check for Dependency Updates (push) Successful in 20s
Security & Dependency Updates / Code Quality Metrics (push) Successful in 17s
Security & Dependency Updates / Security Summary (push) Successful in 9s

Add src/ml/ package predicting, per station and per 6/12/24 h horizon,
the probability of exceeding warning (3.0 m) and danger (4.5 m) levels
plus expected peak level, trained on the 592k-row PostgreSQL history:

- features.py: hourly grid with coverage gating and no future leakage;
  upstream stations enter at empirically measured travel-time lags
  (P.20 +17h ... P.103 +1h vs P.1); hour-of-day deliberately excluded
  (it encodes the scrape schedule, not hydrology)
- train.py: HistGradientBoosting regression + warn/danger classifier
  heads per station x horizon, >=30-positives gate with calibrated
  sigmoid-on-regression fallback, strict temporal splits, per-event
  lead-time evaluation; guards against sklearn 1.9.0 crash on
  degenerate feature columns
- predict.py: bundle loading with feature-name checks, heuristic
  fallback tier, get_latest_forecasts() for the API; raises when no
  models are trained so the endpoint 503s instead of serving
  persistence output as forecasts
- data.py: Postgres-first loader (FLOOD_ML_DB_URL override), HTTP API
  fallback (flagged: that path backfills synthetic discharge), csv.gz
  cache
- /forecast endpoint (15-min TTL cache) + dashboard flood-risk panel
  (hidden until models exist)
- docs/FLOOD_FORECASTING.md: full system doc with measured deployment
  numbers (~335 MB RSS, CPU negligible, ~6 min full retrain) and
  retraining policy

Validation: out-of-sample backtest of the record 2024 flood season
(train <= Aug 2024) alerted 24-48 h ahead of the Oct 5 peak; 2025-26
test split: P.1 6h PR-AUC 0.974, recall 98.3% at 1% false-alarm rate.

Also: fix P.81 station coordinates (was Ban Pong/Ratchaburi, 493 km
out of basin; now 18.6936 N 99.0819 E per RID station page), pin
scikit-learn==1.9.0 and numpy<2, gitignore model artifacts (~100 MB,
train on the server via scripts/train_flood_model.py).
This commit is contained in:
grabowski committed 2026-08-10 12:49:47 +07:00
1 parent 49a3de0087
commit 4358d52d55
15 files changed
+2188 -2

No files matched your search

+272
View File
@@ -0,0 +1,272 @@
"""Static config and feature/label engineering for the Ping River flood forecast models.
All feature computation is strictly causal (no row uses information timestamped after
itself) so it is safe to run identically at training time and at prediction time.
"""
import datetime
import logging
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Static configuration
# ---------------------------------------------------------------------------
# Per-station (warning, danger) level thresholds in meters. "*" is the default
# applied to any station without an explicit override.
THRESHOLDS: Dict[str, Tuple[float, float]] = {
"*": (3.0, 4.5),
}
MONSOON_MONTHS = {6, 7, 8, 9, 10}
FFILL_LIMIT_H = 3
MIN_WINDOW_COVERAGE = 0.5
BASIN_ANCHOR = "P.1"
# Empirical hours a station's water-level anomaly leads the basin anchor (P.1),
# derived from data-scout cross-correlation analysis. UPSTREAM_LEADS[station]
# lists, for each station, the (upstream_code, lead_hours) pairs to use as
# routed-upstream input features when forecasting `station`.
UPSTREAM_LEADS: Dict[str, List[Tuple[str, int]]] = {
"P.1": [("P.103", 1), ("P.67", 7), ("P.21", 9), ("P.75", 12), ("P.4A", 12), ("P.92", 15), ("P.20", 17)],
"P.103": [("P.67", 6), ("P.21", 8), ("P.75", 11), ("P.4A", 11), ("P.92", 14), ("P.20", 16)],
"P.21": [("P.67", 1), ("P.75", 3), ("P.4A", 3), ("P.92", 6), ("P.20", 8)],
"P.67": [("P.75", 5), ("P.4A", 5), ("P.92", 8), ("P.20", 10)],
"P.75": [("P.92", 3), ("P.20", 5)],
"P.4A": [("P.92", 3), ("P.20", 5)],
"P.92": [("P.20", 2)],
"P.20": [],
"P.5": [("P.1", 12), ("P.103", 13)],
"P.81": [("P.1", 4), ("P.103", 5)],
"P.82": [],
"P.84": [],
"P.87": [],
"P.77": [],
"P.85": [],
"P.76": [],
}
# Per-station usable-from dates: data before this cutoff is excluded from training
# because of known data-quality holes (see data-scout inventory).
TRAIN_START: Dict[str, str] = {"P.5": "2022-01-01"}
# Stations with data too sparse/broken to ever be a regression/classification
# target. They are still usable as upstream *input* features (HGB tolerates NaN).
NOT_TRAINABLE: Dict[str, str] = {"P.4A": "17% fill, dead 2019-2024"}
def get_thresholds(station_code: str) -> Tuple[float, float]:
"""Return (warning, danger) level thresholds for a station, falling back to the default."""
return THRESHOLDS.get(station_code, THRESHOLDS["*"])
# ---------------------------------------------------------------------------
# Hourly grid
# ---------------------------------------------------------------------------
@dataclass
class HourlyGrid:
"""A complete hourly time grid pivoted wide across stations.
observed: raw values, NaN where nothing was recorded that hour (pristine; used for labels).
filled: observed forward-filled per column with limit=FFILL_LIMIT_H (causal; used for features).
mask: boolean, True where `observed` has a real reading.
"""
observed: pd.DataFrame
filled: pd.DataFrame
mask: pd.DataFrame
def make_hourly_grid(df_long: pd.DataFrame) -> HourlyGrid:
"""Pivot a long station/timestamp measurement frame onto a complete hourly grid.
df_long columns: timestamp, station_code, water_level, discharge.
"""
if df_long.empty:
empty = pd.DataFrame(
index=pd.DatetimeIndex([], name="timestamp"),
columns=pd.MultiIndex.from_tuples([], names=["station_code", "field"]),
)
return HourlyGrid(observed=empty, filled=empty.copy(), mask=empty.copy())
df = df_long.copy()
df["timestamp"] = pd.to_datetime(df["timestamp"]).dt.floor("h")
df = df.drop_duplicates(subset=["station_code", "timestamp"], keep="last")
full_index = pd.date_range(df["timestamp"].min(), df["timestamp"].max(), freq="h", name="timestamp")
wide = df.pivot(index="timestamp", columns="station_code", values=["water_level", "discharge"])
wide = wide.reorder_levels([1, 0], axis=1).sort_index(axis=1)
wide = wide.reindex(full_index)
observed = wide
mask = observed.notna()
# Forward-fill only — never interpolate — so no row ever depends on a future value.
filled = observed.ffill(limit=FFILL_LIMIT_H)
return HourlyGrid(observed=observed, filled=filled, mask=mask)
def _series(grid_frame: pd.DataFrame, station: str, field: str, index: pd.Index) -> pd.Series:
"""Fetch a (station, field) column, or an all-NaN series if the station is absent."""
if (station, field) in grid_frame.columns:
return grid_frame[(station, field)]
return pd.Series(np.nan, index=index)
def _hours_since_observed(mask_col: pd.Series) -> pd.Series:
"""Hours since the last True in `mask_col` (0 at an observed hour; NaN if never observed yet)."""
idx = mask_col.index
obs_time = pd.Series(idx, index=idx).where(mask_col.to_numpy())
last_obs_time = obs_time.ffill()
age_hours = (idx.to_series() - last_obs_time).dt.total_seconds() / 3600.0
return age_hours
# ---------------------------------------------------------------------------
# Features
# ---------------------------------------------------------------------------
def build_features(grid: HourlyGrid, station: str) -> pd.DataFrame:
"""Build the deterministic-order feature matrix for one target station."""
idx = grid.observed.index
cols: Dict[str, pd.Series] = {}
level = _series(grid.filled, station, "water_level", idx)
discharge = _series(grid.observed, station, "discharge", idx)
obs_mask = _series(grid.mask, station, "water_level", idx).fillna(False)
cols["level"] = level
for k in (1, 2, 3, 6, 12, 24, 48, 72):
cols[f"level_lag_{k}"] = level.shift(k)
for k in (1, 3, 6, 12, 24):
cols[f"rise_{k}"] = level - level.shift(k)
cols["roll_mean_6"] = level.rolling(6, min_periods=1).mean()
cols["roll_mean_24"] = level.rolling(24, min_periods=1).mean()
cols["roll_max_6"] = level.rolling(6, min_periods=1).max()
cols["roll_max_24"] = level.rolling(24, min_periods=1).max()
cols["roll_max_72"] = level.rolling(72, min_periods=1).max()
cols["roll_min_24"] = level.rolling(24, min_periods=1).min()
cols["discharge"] = discharge
cols["discharge_lag_6"] = discharge.shift(6)
cols["discharge_lag_24"] = discharge.shift(24)
cols["discharge_rise_6"] = discharge - discharge.shift(6)
obs_age_h = _hours_since_observed(obs_mask)
cols["obs_age_h"] = obs_age_h.where(obs_age_h <= FFILL_LIMIT_H)
cols["cov_24h"] = obs_mask.rolling(24, min_periods=1).mean()
for upstream_code, lead_h in UPSTREAM_LEADS.get(station, []):
u_level = _series(grid.filled, upstream_code, "water_level", idx)
u_rise_6 = u_level - u_level.shift(6)
u_rollmax_24 = u_level.rolling(24, min_periods=1).max()
near_lag = max(0, lead_h - 3)
cols[f"{upstream_code}_level_lag_{near_lag}"] = u_level.shift(near_lag)
cols[f"{upstream_code}_level_lag_{lead_h}"] = u_level.shift(lead_h)
cols[f"{upstream_code}_level_lag_{lead_h + 3}"] = u_level.shift(lead_h + 3)
cols[f"{upstream_code}_rise_6_lag_{lead_h}"] = u_rise_6.shift(lead_h)
cols[f"{upstream_code}_rollmax_24_lag_{near_lag}"] = u_rollmax_24.shift(near_lag)
if station != BASIN_ANCHOR:
p1_level = _series(grid.filled, BASIN_ANCHOR, "water_level", idx)
cols["P1_level"] = p1_level
cols["P1_rollmax_24"] = p1_level.rolling(24, min_periods=1).max()
cols["P1_rise_24"] = p1_level - p1_level.shift(24)
doy = idx.to_series().dt.dayofyear.astype(float)
cols["doy_sin"] = np.sin(2 * np.pi * doy / 365.25)
cols["doy_cos"] = np.cos(2 * np.pi * doy / 365.25)
cols["is_monsoon"] = idx.to_series().dt.month.isin(MONSOON_MONTHS).astype(float)
return pd.DataFrame(cols, index=idx)
# ---------------------------------------------------------------------------
# Labels
# ---------------------------------------------------------------------------
def _future_window_stats(col: pd.Series, horizon_h: int) -> Tuple[pd.Series, pd.Series]:
"""For every t, (max, count) of observed values in the OPEN window (t, t+horizon_h]."""
reversed_col = col.iloc[::-1]
shifted = reversed_col.shift(1) # excludes t itself
fut_max = shifted.rolling(horizon_h, min_periods=1).max().iloc[::-1]
fut_count = shifted.rolling(horizon_h, min_periods=1).count().iloc[::-1]
return fut_max, fut_count
def build_labels(grid: HourlyGrid, station: str, horizons: Tuple[int, ...] = (6, 12, 24)) -> pd.DataFrame:
"""Build max-level and threshold-exceedance labels for one target station."""
idx = grid.observed.index
observed_level = _series(grid.observed, station, "water_level", idx)
warn_thr, danger_thr = get_thresholds(station)
out: Dict[str, pd.Series] = {}
for horizon_h in horizons:
fut_max, fut_count = _future_window_stats(observed_level, horizon_h)
cov = fut_count / horizon_h
enough_cov = cov >= MIN_WINDOW_COVERAGE
exceed_warn = pd.Series(np.nan, index=idx)
exceed_warn[fut_max >= warn_thr] = 1.0
exceed_warn[enough_cov & exceed_warn.isna()] = 0.0
exceed_danger = pd.Series(np.nan, index=idx)
exceed_danger[fut_max >= danger_thr] = 1.0
exceed_danger[enough_cov & exceed_danger.isna()] = 0.0
max_level_valid = fut_max.where(enough_cov | (fut_max >= warn_thr))
out[f"max_level_{horizon_h}"] = max_level_valid
out[f"exceed_warn_{horizon_h}"] = exceed_warn
out[f"exceed_danger_{horizon_h}"] = exceed_danger
return pd.DataFrame(out, index=idx)
# ---------------------------------------------------------------------------
# Glue
# ---------------------------------------------------------------------------
def build_matrix(
df_long: pd.DataFrame,
station: str,
horizons: Tuple[int, ...] = (6, 12, 24),
) -> Tuple[pd.DataFrame, pd.DataFrame, dict]:
"""Build (X, Y, meta) training/inference matrices for one station."""
grid = make_hourly_grid(df_long)
X = build_features(grid, station)
Y = build_labels(grid, station, horizons)
keep = X["obs_age_h"].notna()
train_start = TRAIN_START.get(station)
if train_start:
keep &= X.index >= pd.Timestamp(train_start)
X = X.loc[keep]
Y = Y.loc[keep]
positive_counts = {
col: int(Y[col].sum()) for col in Y.columns if col.startswith("exceed_") and Y[col].notna().any()
}
meta = {
"station_code": station,
"n_rows": int(len(X)),
"span": (
(X.index.min().isoformat(), X.index.max().isoformat()) if len(X) else (None, None)
),
"positive_counts": positive_counts,
}
return X, Y, meta