feat: Mae Ngat dam features — built, evaluated, defaulted OFF
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 15s
Documentation / Validate Documentation (push) Failing after 8s
Documentation / Generate API Documentation (push) Successful in 9s
Documentation / Build Sphinx Documentation (push) Successful in 15s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 2s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 27s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Build Docker Image (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Integration Test with Services (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Staging (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Deploy to Production (push) Skipped
CI/CD Pipeline - Northern Thailand Ping River Monitor / Performance Test (push) Skipped

src/ml/dam.py loads rid_reservoir_daily into a leakage-safe hourly frame
(daily row visible from 07:00 its own date, ffill capped at 48 h) and is
plumbed through features/train/predict/evaluate exactly like rain, gated
to the six mainstem stations below the Mae Ngat confluence.

The experiment concludes as a documented NEGATIVE result: on the 2024
record-flood backtest every dam-feature subset costs 1-3 h of first-alert
lead (13h -> 10-12h) for <=3 cm of peak-error gain, because the daily RID
report lags up to 31 h and describes yesterday's benign absorbing
reservoir during fast onset. Features therefore default OFF (--dam
opt-in on the training and backtest CLIs; rise_rain_dam/rise_dam harness
variants, excluded from the default variant set). The ablation also
isolated the HII gap-fill as lead-neutral: the acceptance gate holds at
13 h with fill enabled, and docs/img charts are regenerated with the
shipping configuration. Full table in docs/FLOOD_FORECASTING.md §5.

Review-swarm fixes: evaluate.py skips variants whose feature family is
absent instead of crashing the run; --dam forwards --db-url and warns
loudly when no dam history loads; an empty DB result can no longer wipe
a good dam cache; run-level metrics version claims v4 only when a dam
station is actually in the set.
This commit is contained in:
2026-08-13 20:42:21 +07:00
parent 6af6fbe02c
commit 28b62e5a36
12 changed files with 464 additions and 25 deletions
+111
View File
@@ -0,0 +1,111 @@
"""Mae Ngat reservoir series for the flood models.
rid_reservoir_daily (collected hourly by src/rid_reservoir.py, backfilled to
2018) holds daily storage/inflow/outflow for every RID large dam. Mae Ngat
Somboon Chon (DAM_ID 200103) is the only large dam upstream of Chiang Mai:
in Oct 2024 its inflow hit 19-22 MCM/day and storage 114% of usable capacity
days around the P.1 crossing — upstream state no river gauge carries.
Leakage rule: RID publishes the daily report for date D on the morning of D,
so the row becomes visible to features at D 07:00 local time, never earlier.
Forward-fill is capped at FFILL_LIMIT_H so a stalled collector degrades to
NaN (HGB-native) instead of silently serving stale reservoir state.
Known residual optimism: the collector upserts keep-last (and re-fetches
yesterday), so the stored row for date D is RID's FINAL revision, which
training then back-dates to D 07:00 — values live serving may not have had
that morning. This bias works IN FAVOR of dam features, so the 2026-08-13
negative result (they cost 1-3 h of alert lead) holds a fortiori; but any
future POSITIVE result must first validate intraday row stability or shift
the flow columns to D+1 07:00.
"""
import datetime
import logging
from pathlib import Path
from typing import Optional
import pandas as pd
from ..rid_reservoir import MAE_NGAT_DAM_ID
from .data import CACHE_DIR, resolve_db_url
logger = logging.getLogger(__name__)
REPORT_HOUR = 7 # daily value valid from 07:00 local on its own date
FFILL_LIMIT_H = 48 # two missed daily reports -> NaN, not stale state
DAM_COLUMNS = ("storage_pct", "inflow_mcm", "outflow_mcm")
CACHE_FILE = f"dam_{MAE_NGAT_DAM_ID}.csv.gz"
def load_daily(
db_url: Optional[str] = None,
dam_id: str = MAE_NGAT_DAM_ID,
start: Optional[datetime.date] = None,
cache_dir: Path = CACHE_DIR,
) -> Optional[pd.DataFrame]:
"""Daily dam rows indexed by date. DB first, on-disk cache as fallback."""
cache_path = Path(cache_dir) / CACHE_FILE
resolved = resolve_db_url(db_url)
if resolved:
try:
from sqlalchemy import create_engine, text
query = (
"SELECT date, storage_pct, inflow_mcm, outflow_mcm "
"FROM rid_reservoir_daily WHERE dam_id = :dam_id"
)
params = {"dam_id": dam_id}
if start is not None:
query += " AND date >= :start"
params["start"] = start
engine = create_engine(resolved, pool_pre_ping=True)
with engine.connect() as conn:
daily = pd.read_sql(
text(query + " ORDER BY date"), conn, params=params
)
daily["date"] = pd.to_datetime(daily["date"])
daily = daily.set_index("date")
for col in DAM_COLUMNS:
daily[col] = pd.to_numeric(daily[col], errors="coerce")
# Only full, NON-EMPTY loads refresh the cache: a truncated or
# freshly-recreated table must not wipe a good fallback archive.
if start is None and not daily.empty:
cache_path.parent.mkdir(parents=True, exist_ok=True)
daily.to_csv(cache_path, compression="gzip")
return daily
except Exception as error:
logger.warning(f"dam series DB load failed: {error}")
if cache_path.exists():
logger.warning("falling back to on-disk cache for the dam series")
return pd.read_csv(cache_path, index_col=0, parse_dates=True)
return None
def hourly_frame(daily: Optional[pd.DataFrame]) -> Optional[pd.DataFrame]:
"""Step the daily rows onto an hourly grid, each valid from D 07:00."""
if daily is None or daily.empty:
return None
frame = daily.copy()
frame.index = pd.to_datetime(frame.index) + pd.Timedelta(hours=REPORT_HOUR)
frame = frame[~frame.index.duplicated(keep="last")].sort_index()
hourly_index = pd.date_range(
frame.index.min(),
frame.index.max() + pd.Timedelta(hours=FFILL_LIMIT_H),
freq="h",
)
return frame.reindex(hourly_index).ffill(limit=FFILL_LIMIT_H)
def load_history(db_url: Optional[str] = None) -> Optional[pd.DataFrame]:
"""Full hourly Mae Ngat history for training; None when unavailable."""
return hourly_frame(load_daily(db_url))
def serving_frame(
db_url: Optional[str] = None, days: int = 21
) -> Optional[pd.DataFrame]:
"""Recent hourly dam state for inference (covers the 336 h feature window
plus the 72 h storage-delta lag)."""
start = datetime.date.today() - datetime.timedelta(days=days)
return hourly_frame(load_daily(db_url, start=start))
+48 -5
View File
@@ -56,12 +56,14 @@ class Variant:
"""A trainable candidate producing (pred_abs, sigma_per_row) on test rows."""
def __init__(self, name: str, target: str, weighted: bool = False,
quantile: bool = False, use_rain: bool = False):
quantile: bool = False, use_rain: bool = False,
use_dam: bool = False):
self.name = name
self.target = target # 'abs' or 'rise'
self.weighted = weighted
self.quantile = quantile
self.use_rain = use_rain
self.use_dam = use_dam
def fit_predict(
self, X_tr, y_abs_tr, X_te
@@ -74,6 +76,14 @@ class Variant:
raise ValueError(
f"{self.name} requires the rain series (run without --no-rain)"
)
if not self.use_dam:
drop = [c for c in features.DAM_FEATURES if c in X_tr.columns]
X_tr = X_tr.drop(columns=drop)
X_te = X_te.drop(columns=drop)
elif "dam_storage_pct" not in X_tr.columns:
raise ValueError(
f"{self.name} requires the dam series (rid_reservoir_daily backfilled)"
)
level_tr = X_tr["level"]
level_te = X_te["level"].to_numpy()
y_tr = (y_abs_tr - level_tr) if self.target == "rise" else y_abs_tr
@@ -103,8 +113,16 @@ VARIANTS: Dict[str, Variant] = {
"rise_quantile": Variant("rise_quantile", target="rise", weighted=True,
quantile=True),
"rise_rain": Variant("rise_rain", target="rise", use_rain=True),
"rise_rain_dam": Variant("rise_rain_dam", target="rise", use_rain=True,
use_dam=True),
"rise_dam": Variant("rise_dam", target="rise", use_dam=True),
}
# Dam variants are opt-in by name: they require dam columns that only exist
# for features.DAM_STATIONS and only when the reservoir series loaded, and
# the 2026-08-13 ablation concluded them a negative result.
DEFAULT_VARIANTS = [k for k, v in VARIANTS.items() if not v.use_dam]
def _find_events(observed: pd.Series, thr: float) -> List[dict]:
"""Contiguous >=thr episodes (gaps under EVENT_GAP_H merged)."""
@@ -198,11 +216,12 @@ def evaluate_station(
variants: Optional[List[str]] = None,
seasons: Tuple[int, ...] = SEASONS,
rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> Dict:
"""Run every fold x variant for one station; returns the results tree."""
warn_thr, _ = features.get_thresholds(station)
grid = features.make_hourly_grid(df_long)
X_all = features.build_features(grid, station, rain=rain)
X_all = features.build_features(grid, station, rain=rain, dam=dam)
observed = grid.observed[(station, "water_level")]
keep = X_all["obs_age_h"].notna()
@@ -211,7 +230,7 @@ def evaluate_station(
keep &= X_all.index >= pd.Timestamp(train_start)
X_all = X_all.loc[keep]
chosen = {k: VARIANTS[k] for k in (variants or VARIANTS)}
chosen = {k: VARIANTS[k] for k in (variants or DEFAULT_VARIANTS)}
results: Dict = {"station": station, "warn_thr": warn_thr, "folds": []}
for year in seasons:
@@ -253,7 +272,14 @@ def evaluate_station(
}
for name, variant in chosen.items():
pred_abs, sigma = variant.fit_predict(X_tr, y_tr, X_te)
try:
pred_abs, sigma = variant.fit_predict(X_tr, y_tr, X_te)
except ValueError as error:
# A variant whose required feature family is absent (e.g. a
# dam variant on a non-DAM_STATIONS target) skips this fold
# instead of killing the whole run and its finished results.
logger.warning(f"{station} {year} {name}: skipped ({error})")
continue
pred_series = pd.Series(pred_abs, index=X_te.index)
p_warn = pd.Series(
1.0 - _phi((warn_thr - pred_abs) / sigma), index=X_te.index
@@ -352,6 +378,8 @@ def main(argv=None) -> int:
parser.add_argument("--out", default="models/eval_variants.json")
parser.add_argument("--no-rain", action="store_true",
help="skip loading the Open-Meteo rain series")
parser.add_argument("--no-dam", action="store_true",
help="skip loading the Mae Ngat reservoir series")
args = parser.parse_args(argv)
logging.basicConfig(
@@ -375,12 +403,27 @@ def main(argv=None) -> int:
f"{rain_series.index.max()}"
)
dam_frame = None
if not args.no_dam:
from . import dam as dam_mod
dam_frame = dam_mod.load_history(db_url=args.db_url)
if dam_frame is None:
logger.warning("dam history unavailable; dam features will be absent")
else:
logger.info(
f"dam series loaded: {dam_frame.index.min()} .. "
f"{dam_frame.index.max()}"
)
variant_names = args.variants.split(",") if args.variants else None
all_results = []
for station in args.stations.split(","):
station = station.strip()
logger.info(f"Evaluating {station}...")
results = evaluate_station(df, station, variant_names, rain=rain_series)
results = evaluate_station(
df, station, variant_names, rain=rain_series, dam=dam_frame
)
all_results.append(results)
print(summarize(results))
+29 -2
View File
@@ -202,9 +202,18 @@ def _hours_since_observed(mask_col: pd.Series) -> pd.Series:
RAIN_FEATURES = ("rain_6h", "rain_24h", "rain_72h", "rain_fc24")
DAM_FEATURES = ("dam_storage_pct", "dam_storage_pct_d3", "dam_inflow", "dam_outflow")
# Stations hydrologically downstream of the Mae Ngat confluence (Ping mainstem
# at/below Mae Taeng) — the only ones where reservoir state is causal. West-
# tributary and upper-mainstem stations never receive dam columns.
DAM_STATIONS = frozenset({"P.1", "P.103", "P.67", "P.21", "P.5", "P.81"})
def build_features(
grid: HourlyGrid, station: str, rain: Optional[pd.Series] = None
grid: HourlyGrid,
station: str,
rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> pd.DataFrame:
"""Build the deterministic-order feature matrix for one target station.
@@ -217,6 +226,11 @@ def build_features(
bundles even when the live fetch fails. rain_fc24 is the forward 24 h
sum: the archived forecast series at training time, a real weather
forecast at serving time; it never contains river data.
``dam`` is the hourly Mae Ngat reservoir frame (src/ml/dam.py; columns
storage_pct/inflow_mcm/outflow_mcm, already leakage-shifted to 07:00
report time). Same contract as rain: None omits the columns, an empty
frame yields NaN columns; only DAM_STATIONS receive them.
"""
idx = grid.observed.index
cols: Dict[str, pd.Series] = {}
@@ -280,6 +294,18 @@ def build_features(
r.shift(-1).iloc[::-1].rolling(24, min_periods=1).sum().iloc[::-1]
)
if dam is not None and station in DAM_STATIONS:
d = dam.reindex(idx)
def _dam_col(name: str) -> pd.Series:
return d[name] if name in d.columns else pd.Series(np.nan, index=idx)
storage = _dam_col("storage_pct")
cols["dam_storage_pct"] = storage
cols["dam_storage_pct_d3"] = storage - storage.shift(72)
cols["dam_inflow"] = _dam_col("inflow_mcm")
cols["dam_outflow"] = _dam_col("outflow_mcm")
return pd.DataFrame(cols, index=idx)
@@ -358,10 +384,11 @@ def build_matrix(
horizons: Tuple[int, ...] = (6, 12, 24),
stats_end: Optional[str] = None,
rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> 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, rain=rain)
X = build_features(grid, station, rain=rain, dam=dam)
Y = build_labels(grid, station, horizons, stats_end=stats_end)
keep = X["obs_age_h"].notna()
+30 -4
View File
@@ -128,6 +128,7 @@ def _model_forecast(
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"]
@@ -146,7 +147,9 @@ def _model_forecast(
)
warn_thr, danger_thr = cfg_warn, cfg_danger
feature_row = features.build_features(grid, station_code, rain=rain).loc[[as_of]]
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:
@@ -231,6 +234,7 @@ def _forecast_station(
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:
@@ -267,7 +271,7 @@ def _forecast_station(
bundle = _load_bundle(bundle_path)
model_results = _model_forecast(
station_code, grid, bundle, as_of, current_level, rain=rain
station_code, grid, bundle, as_of, current_level, rain=rain, dam=dam
)
if model_results is None:
return _heuristic_forecast(
@@ -306,6 +310,7 @@ def get_forecasts(
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`.
@@ -329,7 +334,13 @@ def get_forecasts(
try:
results.extend(
_forecast_station(
station_code, grid, models_dir, now, DEFAULT_HORIZONS, rain=rain
station_code,
grid,
models_dir,
now,
DEFAULT_HORIZONS,
rain=rain,
dam=dam,
)
)
except Exception as error:
@@ -376,4 +387,19 @@ def get_latest_forecasts(
logger.warning("live rain unavailable; rain features will be NaN")
rain = pd.Series(dtype=float)
return get_forecasts(readings_by_station, models_dir=models_dir, rain=rain)
# 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
)
+56 -4
View File
@@ -204,9 +204,10 @@ def train_station(
split_test_start: str = SPLIT_B_TEST_START,
split_test_end: str = SPLIT_B_TEST_END,
rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> Tuple[Optional[dict], dict]:
"""Train every head for one station. Returns (bundle_or_None, station_metrics)."""
X, Y, meta = features.build_matrix(df_long, station, horizons, rain=rain)
X, Y, meta = features.build_matrix(df_long, station, horizons, rain=rain, dam=dam)
if meta["n_rows"] < MIN_ROWS_TO_TRAIN:
return None, {
"status": "failed",
@@ -412,8 +413,13 @@ def train_station(
] = f"only {n_pos} positives in train span (< {MIN_POSITIVES_FOR_CLASSIFIER})"
final_heads[head_key] = None
# v3 = rise target + Open-Meteo rain features; v2 = rise target only
version_prefix = "hgb-v3" if "rain_24h" in feature_names else "hgb-v2"
# v4 = + Mae Ngat dam features; v3 = rise + rain; v2 = rise target only
if "dam_storage_pct" in feature_names:
version_prefix = "hgb-v4"
elif "rain_24h" in feature_names:
version_prefix = "hgb-v3"
else:
version_prefix = "hgb-v2"
bundle = {
"station_code": station,
"model_version": f"{version_prefix}+{_git_short_sha()}",
@@ -443,6 +449,8 @@ def train_all(
skip_eval: bool = False,
hgb_overrides: Optional[dict] = None,
use_rain: bool = True,
use_dam: bool = False,
db_url: Optional[str] = None,
) -> dict:
"""Train and save every requested station's models. Returns the metrics.json payload."""
models_dir = Path(models_dir)
@@ -462,7 +470,42 @@ def train_all(
logger.info(
f"rain series: {rain_series.index.min()} .. {rain_series.index.max()}"
)
version_prefix = "hgb-v3" if rain_series is not None else "hgb-v2"
# Mae Ngat reservoir state (rid_reservoir_daily, 2018+). OFF by default:
# the 2026-08-13 backtest ablation showed every dam-feature subset COSTS
# 1-3 h of first-alert lead on the 2024 record flood (the daily report
# lags up to 31 h, so during fast onset the columns describe yesterday's
# benign reservoir and damp the alarm). Kept as an opt-in for post-monsoon
# re-evaluation once the 2026 season adds dam-era flood events.
dam_frame = None
if use_dam:
try:
from . import dam as dam_mod
dam_frame = dam_mod.load_history(db_url=db_url)
except Exception as error:
logger.warning(f"dam history unavailable, training without it: {error}")
if dam_frame is None:
# load_history returns None (no raise) when both DB and cache
# miss — an explicitly requested experiment must say so loudly.
logger.warning(
"--dam requested but no dam history available; "
"training v3-style bundles WITHOUT dam features"
)
if dam_frame is not None:
logger.info(
f"dam series: {dam_frame.index.min()} .. {dam_frame.index.max()}"
)
# Run-level version: v4 only if some requested station actually receives
# dam columns (they are gated to DAM_STATIONS; per-bundle versions are
# derived from each station's own feature_names and remain authoritative).
if dam_frame is not None and any(s in features.DAM_STATIONS for s in stations):
version_prefix = "hgb-v4"
elif rain_series is not None:
version_prefix = "hgb-v3"
else:
version_prefix = "hgb-v2"
model_version = f"{version_prefix}+{_git_short_sha()}"
station_results: Dict[str, dict] = {}
@@ -480,6 +523,7 @@ def train_all(
skip_eval=skip_eval,
hgb_overrides=hgb_overrides,
rain=rain_series,
dam=dam_frame,
)
if bundle is None:
logger.warning(f"{station}: failed ({station_metrics.get('reason')})")
@@ -542,6 +586,12 @@ def main(argv: Optional[List[str]] = None) -> None:
action="store_true",
help="train without the Open-Meteo rain features (v2-style bundles)",
)
parser.add_argument(
"--dam",
action="store_true",
help="EXPERIMENTAL: include Mae Ngat reservoir features (v4 bundles); "
"the 2026-08 ablation showed they cost 1-3 h of alert lead",
)
args = parser.parse_args(argv)
if args.stations == "all":
@@ -570,6 +620,8 @@ def main(argv: Optional[List[str]] = None) -> None:
models_dir=Path(args.models_dir),
skip_eval=args.skip_eval,
use_rain=not args.no_rain,
use_dam=args.dam,
db_url=resolve_db_url(args.db_url),
)
trained = sum(
1 for s in metrics_payload["stations"].values() if s["status"] == "trained"