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
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:
1 parent
49a3de0087
commit
4358d52d55
15 files changed
+2188
-2
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user