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
+43 -2
View File
@@ -451,8 +451,9 @@ acts before any gauge rises, and `rain_fc24` — a weather *forecast* — acts
before the rain itself falls. **Remaining honest limits:** marginal before the rain itself falls. **Remaining honest limits:** marginal
just-over-threshold crests (2025: +2 h) are intrinsically short-notice; the just-over-threshold crests (2025: +2 h) are intrinsically short-notice; the
rain series only exists from 2021-03, so older training rows are rain-blind; rain series only exists from 2021-03, so older training rows are rain-blind;
forecast-rain quality bounds what the feature can add; and Mae Ngat/Mae Kuang forecast-rain quality bounds what the feature can add; and Mae Ngat reservoir
dam releases remain uningested (see `docs/DATA_SOURCES.md`). state, though now ingested daily (see `docs/DATA_SOURCES.md`), measurably
*hurts* alert lead as a model feature — see the 2026-08-13 experiment below.
**Danger-level skill at P.1 is unproven.** P.1 never crossed 4.5 m in the **Danger-level skill at P.1 is unproven.** P.1 never crossed 4.5 m in the
2025-01-01 → 2026-08-10 test span (`base_rate_danger` is 0.0, so every danger 2025-01-01 → 2026-08-10 test span (`base_rate_danger` is 0.0, so every danger
@@ -475,6 +476,46 @@ P.1 additionally reports `stages`: exceedance probability for each of the seven
official inundation stages (3.704.60 m), computed from the regression head and official inundation stages (3.704.60 m), computed from the regression head and
its calibration sigma, so they need no retrain and no per-stage classifiers. its calibration sigma, so they need no retrain and no per-stage classifiers.
### 2026-08-13: Mae Ngat dam features — a documented negative result
With `rid_reservoir_daily` backfilled to 2018 (daily Mae Ngat storage/inflow/
outflow, `src/ml/dam.py`), the obvious v4 experiment was to feed reservoir
state to the mainstem models: during the Oct 2024 flood the dam hit 113% of
usable capacity with 1922 MCM/day inflow spikes on the crossing days.
**It fails the acceptance gate.** On the 2024 record-flood backtest (train
< 1 Sep 2024, belt-and-braces alerting, identical to the deployed pipeline):
| dam features | first-alert lead | record-peak err (24 h ahead) |
|----------------------------|------------------|------------------------------|
| none (deployed v3 config) | **+13 h** (PASS) | +0.24 m |
| all four | +10 h (FAIL) | +0.22 m |
| storage % + 3-day delta | +12 h | +0.21…+0.27 m |
| inflow + outflow | +10 h (FAIL) | +0.35 m |
| outflow only | +12 h | +0.20 m |
Every subset costs 13 h of warning for at most a ~3 cm peak-error gain. The
mechanism is the publication lag: RID posts the daily report on the morning of
its own date (features apply it from 07:00, `dam.py`'s leakage rule), so at the
04:00 first-alert hour of 24 Sep 2024 the freshest dam row still described
23 Sep — a benign reservoir quietly absorbing inflow (outflow 0.13 MCM/day).
The columns therefore argue *against* imminent flooding exactly when the rain
features are (correctly) raising the alarm. The rolling-origin harness agrees:
`rise_rain_dam` matches `rise_rain` on leads and false alarms, only nudging
event-peak amplitude (0.11 → 0.03 m on the Sep 2024 event), and `rise_dam`
(dam without rain) is strictly worse with alarm-latch artifacts.
**Disposition:** dam features are OFF by default (`train_all(use_dam=False)`;
opt-in via `--dam` on the training CLI, `scripts/backtest_render.py --dam`,
and the `rise_rain_dam` / `rise_dam` harness variants). The collector keeps
accruing daily rows; revisit post-monsoon when the 2026 season adds dam-era
flood events — an intraday scrape (the lsim.rid.go.th source, reachable only
from Thai networks) would remove the publication-lag objection entirely.
**Shipped from the same work:** the HII gap-fill merge in the data loader
(`fill_from_hii`, +9,341 h at P.81, +682 h at P.92, +810 h at P.20) is
lead-neutral — the gate holds at 13 h with fill on — and ships enabled.
## 6. Deployment ## 6. Deployment
### API ### API
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 130 KiB

+17 -6
View File
@@ -45,19 +45,23 @@ AMBER = "#c07d10"
RED = "#d9534f" RED = "#d9534f"
def fit_backtest_model(df_long: pd.DataFrame, train_end: str): def fit_backtest_model(df_long: pd.DataFrame, train_end: str, use_dam: bool = False):
"""Train the 24 h regression + warning heads on rows <= train_end only. """Train the 24 h regression + warning heads on rows <= train_end only.
Mirrors the deployed hgb-v3 pipeline: the regression head learns the RISE Mirrors the deployed hgb-v3 pipeline: the regression head learns the RISE
over the current level, with Open-Meteo catchment-rain features (trailing over the current level, with Open-Meteo catchment-rain features (trailing
sums + the forward-24h forecast sum); label statistics are bounded to the sums + the forward-24h forecast sum); label statistics are bounded to the
training cutoff. training cutoff. use_dam=True adds the Mae Ngat reservoir columns — an
ablation-only configuration (2026-08-13 result: costs 1-3 h of lead).
""" """
from src.ml import dam as dam_mod
from src.ml import rain as rain_mod from src.ml import rain as rain_mod
rain_series = rain_mod.catchment_mean(rain_mod.load_history()) rain_series = rain_mod.catchment_mean(rain_mod.load_history())
dam_frame = dam_mod.load_history() if use_dam else None
X, Y, _meta = features.build_matrix( X, Y, _meta = features.build_matrix(
df_long, STATION, (HORIZON,), stats_end=train_end, rain=rain_series df_long, STATION, (HORIZON,), stats_end=train_end, rain=rain_series,
dam=dam_frame,
) )
train_mask = X.index <= pd.Timestamp(train_end) train_mask = X.index <= pd.Timestamp(train_end)
X_train, Y_train = X.loc[train_mask], Y.loc[train_mask] X_train, Y_train = X.loc[train_mask], Y.loc[train_mask]
@@ -200,16 +204,23 @@ def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--db-url", default=None) parser.add_argument("--db-url", default=None)
parser.add_argument("--out-dir", default=os.path.join("docs", "img")) parser.add_argument("--out-dir", default=os.path.join("docs", "img"))
parser.add_argument("--dam", action="store_true",
help="ablation: include Mae Ngat reservoir features "
"(2026-08 result: costs 1-3 h of alert lead)")
parser.add_argument("--no-hii-fill", action="store_true",
help="ablation: load without the HII gap-fill merge")
args = parser.parse_args(argv) args = parser.parse_args(argv)
df = data.load_measurements(db_url=args.db_url) df = data.load_measurements(
db_url=args.db_url, hii_fill=not args.no_hii_fill
)
if df.empty: if df.empty:
print("no measurement data available", file=sys.stderr) print("no measurement data available", file=sys.stderr)
return 1 return 1
os.makedirs(args.out_dir, exist_ok=True) os.makedirs(args.out_dir, exist_ok=True)
# --- October 2024 record flood: trained only on data before 1 Sep 2024 --- # --- October 2024 record flood: trained only on data before 1 Sep 2024 ---
X, reg, clf = fit_backtest_model(df, "2024-08-31") X, reg, clf = fit_backtest_model(df, "2024-08-31", use_dam=args.dam)
obs, fc, flood_start, first_alert = event_series( obs, fc, flood_start, first_alert = event_series(
df, X, reg, clf, "2024-09-10", "2024-10-14 23:00") df, X, reg, clf, "2024-09-10", "2024-10-14 23:00")
peak = float(obs.max()) peak = float(obs.max())
@@ -235,7 +246,7 @@ def main(argv=None) -> int:
detail=True) detail=True)
# --- September 2025 flood: the deployed configuration (trained <= 2024) --- # --- September 2025 flood: the deployed configuration (trained <= 2024) ---
X25, reg25, clf25 = fit_backtest_model(df, "2024-12-31") X25, reg25, clf25 = fit_backtest_model(df, "2024-12-31", use_dam=args.dam)
obs25, fc25, flood25, alert25 = event_series( obs25, fc25, flood25, alert25 = event_series(
df, X25, reg25, clf25, "2025-09-22", "2025-10-02 12:00") df, X25, reg25, clf25, "2025-09-22", "2025-10-02 12:00")
pred_at_alert = float(fc25.loc[alert25:, "pred_max"].iloc[:24].max()) if alert25 is not None else None pred_at_alert = float(fc25.loc[alert25:, "pred_max"].iloc[:24].max()) if alert25 is not None else None
+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.""" """A trainable candidate producing (pred_abs, sigma_per_row) on test rows."""
def __init__(self, name: str, target: str, weighted: bool = False, 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.name = name
self.target = target # 'abs' or 'rise' self.target = target # 'abs' or 'rise'
self.weighted = weighted self.weighted = weighted
self.quantile = quantile self.quantile = quantile
self.use_rain = use_rain self.use_rain = use_rain
self.use_dam = use_dam
def fit_predict( def fit_predict(
self, X_tr, y_abs_tr, X_te self, X_tr, y_abs_tr, X_te
@@ -74,6 +76,14 @@ class Variant:
raise ValueError( raise ValueError(
f"{self.name} requires the rain series (run without --no-rain)" 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_tr = X_tr["level"]
level_te = X_te["level"].to_numpy() level_te = X_te["level"].to_numpy()
y_tr = (y_abs_tr - level_tr) if self.target == "rise" else y_abs_tr 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, "rise_quantile": Variant("rise_quantile", target="rise", weighted=True,
quantile=True), quantile=True),
"rise_rain": Variant("rise_rain", target="rise", use_rain=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]: def _find_events(observed: pd.Series, thr: float) -> List[dict]:
"""Contiguous >=thr episodes (gaps under EVENT_GAP_H merged).""" """Contiguous >=thr episodes (gaps under EVENT_GAP_H merged)."""
@@ -198,11 +216,12 @@ def evaluate_station(
variants: Optional[List[str]] = None, variants: Optional[List[str]] = None,
seasons: Tuple[int, ...] = SEASONS, seasons: Tuple[int, ...] = SEASONS,
rain: Optional[pd.Series] = None, rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> Dict: ) -> Dict:
"""Run every fold x variant for one station; returns the results tree.""" """Run every fold x variant for one station; returns the results tree."""
warn_thr, _ = features.get_thresholds(station) warn_thr, _ = features.get_thresholds(station)
grid = features.make_hourly_grid(df_long) 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")] observed = grid.observed[(station, "water_level")]
keep = X_all["obs_age_h"].notna() keep = X_all["obs_age_h"].notna()
@@ -211,7 +230,7 @@ def evaluate_station(
keep &= X_all.index >= pd.Timestamp(train_start) keep &= X_all.index >= pd.Timestamp(train_start)
X_all = X_all.loc[keep] 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": []} results: Dict = {"station": station, "warn_thr": warn_thr, "folds": []}
for year in seasons: for year in seasons:
@@ -253,7 +272,14 @@ def evaluate_station(
} }
for name, variant in chosen.items(): 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) pred_series = pd.Series(pred_abs, index=X_te.index)
p_warn = pd.Series( p_warn = pd.Series(
1.0 - _phi((warn_thr - pred_abs) / sigma), index=X_te.index 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("--out", default="models/eval_variants.json")
parser.add_argument("--no-rain", action="store_true", parser.add_argument("--no-rain", action="store_true",
help="skip loading the Open-Meteo rain series") 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) args = parser.parse_args(argv)
logging.basicConfig( logging.basicConfig(
@@ -375,12 +403,27 @@ def main(argv=None) -> int:
f"{rain_series.index.max()}" 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 variant_names = args.variants.split(",") if args.variants else None
all_results = [] all_results = []
for station in args.stations.split(","): for station in args.stations.split(","):
station = station.strip() station = station.strip()
logger.info(f"Evaluating {station}...") 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) all_results.append(results)
print(summarize(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") 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( 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: ) -> pd.DataFrame:
"""Build the deterministic-order feature matrix for one target station. """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 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 sum: the archived forecast series at training time, a real weather
forecast at serving time; it never contains river data. 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 idx = grid.observed.index
cols: Dict[str, pd.Series] = {} 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] 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) return pd.DataFrame(cols, index=idx)
@@ -358,10 +384,11 @@ def build_matrix(
horizons: Tuple[int, ...] = (6, 12, 24), horizons: Tuple[int, ...] = (6, 12, 24),
stats_end: Optional[str] = None, stats_end: Optional[str] = None,
rain: Optional[pd.Series] = None, rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> Tuple[pd.DataFrame, pd.DataFrame, dict]: ) -> Tuple[pd.DataFrame, pd.DataFrame, dict]:
"""Build (X, Y, meta) training/inference matrices for one station.""" """Build (X, Y, meta) training/inference matrices for one station."""
grid = make_hourly_grid(df_long) 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) Y = build_labels(grid, station, horizons, stats_end=stats_end)
keep = X["obs_age_h"].notna() keep = X["obs_age_h"].notna()
+30 -4
View File
@@ -128,6 +128,7 @@ def _model_forecast(
as_of: pd.Timestamp, as_of: pd.Timestamp,
current_level: float, current_level: float,
rain: Optional[pd.Series] = None, rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> List[dict]: ) -> List[dict]:
warn_thr = bundle["thresholds"]["warning"] warn_thr = bundle["thresholds"]["warning"]
danger_thr = bundle["thresholds"]["danger"] danger_thr = bundle["thresholds"]["danger"]
@@ -146,7 +147,9 @@ def _model_forecast(
) )
warn_thr, danger_thr = cfg_warn, cfg_danger 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"] expected_columns = bundle["feature_names"]
missing = [c for c in expected_columns if c not in feature_row.columns] missing = [c for c in expected_columns if c not in feature_row.columns]
if missing: if missing:
@@ -231,6 +234,7 @@ def _forecast_station(
now: pd.Timestamp, now: pd.Timestamp,
horizons: Tuple[int, ...], horizons: Tuple[int, ...],
rain: Optional[pd.Series] = None, rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> List[dict]: ) -> List[dict]:
level_col = (station_code, "water_level") level_col = (station_code, "water_level")
if level_col not in grid.observed.columns: if level_col not in grid.observed.columns:
@@ -267,7 +271,7 @@ def _forecast_station(
bundle = _load_bundle(bundle_path) bundle = _load_bundle(bundle_path)
model_results = _model_forecast( 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: if model_results is None:
return _heuristic_forecast( return _heuristic_forecast(
@@ -306,6 +310,7 @@ def get_forecasts(
models_dir: Union[str, Path] = DEFAULT_MODELS_DIR, models_dir: Union[str, Path] = DEFAULT_MODELS_DIR,
now: Optional[Union[datetime.datetime, str]] = None, now: Optional[Union[datetime.datetime, str]] = None,
rain: Optional[pd.Series] = None, rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> List[dict]: ) -> List[dict]:
"""Produce flood forecasts for every station present in `readings_by_station`. """Produce flood forecasts for every station present in `readings_by_station`.
@@ -329,7 +334,13 @@ def get_forecasts(
try: try:
results.extend( results.extend(
_forecast_station( _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: except Exception as error:
@@ -376,4 +387,19 @@ def get_latest_forecasts(
logger.warning("live rain unavailable; rain features will be NaN") logger.warning("live rain unavailable; rain features will be NaN")
rain = pd.Series(dtype=float) 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_start: str = SPLIT_B_TEST_START,
split_test_end: str = SPLIT_B_TEST_END, split_test_end: str = SPLIT_B_TEST_END,
rain: Optional[pd.Series] = None, rain: Optional[pd.Series] = None,
dam: Optional[pd.DataFrame] = None,
) -> Tuple[Optional[dict], dict]: ) -> Tuple[Optional[dict], dict]:
"""Train every head for one station. Returns (bundle_or_None, station_metrics).""" """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: if meta["n_rows"] < MIN_ROWS_TO_TRAIN:
return None, { return None, {
"status": "failed", "status": "failed",
@@ -412,8 +413,13 @@ def train_station(
] = f"only {n_pos} positives in train span (< {MIN_POSITIVES_FOR_CLASSIFIER})" ] = f"only {n_pos} positives in train span (< {MIN_POSITIVES_FOR_CLASSIFIER})"
final_heads[head_key] = None final_heads[head_key] = None
# v3 = rise target + Open-Meteo rain features; v2 = rise target only # v4 = + Mae Ngat dam features; v3 = rise + rain; v2 = rise target only
version_prefix = "hgb-v3" if "rain_24h" in feature_names else "hgb-v2" 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 = { bundle = {
"station_code": station, "station_code": station,
"model_version": f"{version_prefix}+{_git_short_sha()}", "model_version": f"{version_prefix}+{_git_short_sha()}",
@@ -443,6 +449,8 @@ def train_all(
skip_eval: bool = False, skip_eval: bool = False,
hgb_overrides: Optional[dict] = None, hgb_overrides: Optional[dict] = None,
use_rain: bool = True, use_rain: bool = True,
use_dam: bool = False,
db_url: Optional[str] = None,
) -> dict: ) -> dict:
"""Train and save every requested station's models. Returns the metrics.json payload.""" """Train and save every requested station's models. Returns the metrics.json payload."""
models_dir = Path(models_dir) models_dir = Path(models_dir)
@@ -462,7 +470,42 @@ def train_all(
logger.info( logger.info(
f"rain series: {rain_series.index.min()} .. {rain_series.index.max()}" 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()}" model_version = f"{version_prefix}+{_git_short_sha()}"
station_results: Dict[str, dict] = {} station_results: Dict[str, dict] = {}
@@ -480,6 +523,7 @@ def train_all(
skip_eval=skip_eval, skip_eval=skip_eval,
hgb_overrides=hgb_overrides, hgb_overrides=hgb_overrides,
rain=rain_series, rain=rain_series,
dam=dam_frame,
) )
if bundle is None: if bundle is None:
logger.warning(f"{station}: failed ({station_metrics.get('reason')})") logger.warning(f"{station}: failed ({station_metrics.get('reason')})")
@@ -542,6 +586,12 @@ def main(argv: Optional[List[str]] = None) -> None:
action="store_true", action="store_true",
help="train without the Open-Meteo rain features (v2-style bundles)", 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) args = parser.parse_args(argv)
if args.stations == "all": if args.stations == "all":
@@ -570,6 +620,8 @@ def main(argv: Optional[List[str]] = None) -> None:
models_dir=Path(args.models_dir), models_dir=Path(args.models_dir),
skip_eval=args.skip_eval, skip_eval=args.skip_eval,
use_rain=not args.no_rain, use_rain=not args.no_rain,
use_dam=args.dam,
db_url=resolve_db_url(args.db_url),
) )
trained = sum( trained = sum(
1 for s in metrics_payload["stations"].values() if s["status"] == "trained" 1 for s in metrics_payload["stations"].values() if s["status"] == "trained"
+128
View File
@@ -0,0 +1,128 @@
"""Tests for the Mae Ngat dam series (src/ml/dam.py) and its feature gating."""
import datetime
import numpy as np
import pandas as pd
from src.ml import features
from src.ml.dam import FFILL_LIMIT_H, REPORT_HOUR, hourly_frame
def _daily(days=5, start="2024-09-20"):
idx = pd.date_range(start, periods=days, freq="D")
return pd.DataFrame(
{
"storage_pct": np.linspace(90, 110, days),
"inflow_mcm": np.linspace(2, 20, days),
"outflow_mcm": np.linspace(0.5, 5, days),
},
index=idx,
)
class TestHourlyFrame:
def test_daily_value_visible_from_report_hour_only(self):
hourly = hourly_frame(_daily())
day0 = pd.Timestamp("2024-09-20")
# Nothing before the first report hour
assert hourly.index.min() == day0 + pd.Timedelta(hours=REPORT_HOUR)
# The day's value holds from 07:00 through the next morning
assert hourly.loc[day0 + pd.Timedelta(hours=7), "storage_pct"] == 90.0
assert hourly.loc[day0 + pd.Timedelta(hours=23), "storage_pct"] == 90.0
next_6am = day0 + pd.Timedelta(days=1, hours=6)
next_7am = day0 + pd.Timedelta(days=1, hours=7)
assert hourly.loc[next_6am, "storage_pct"] == 90.0 # yesterday's value
assert hourly.loc[next_7am, "storage_pct"] == 95.0 # today's report
def test_ffill_capped_after_missing_days(self):
daily = _daily(days=2).drop(index=pd.Timestamp("2024-09-21"))
# extend with a far-later row so the gap sits mid-frame
late = _daily(days=1, start="2024-09-28")
hourly = hourly_frame(pd.concat([daily, late]))
gap_ts = pd.Timestamp("2024-09-20") + pd.Timedelta(
hours=REPORT_HOUR + FFILL_LIMIT_H + 1
)
assert np.isnan(hourly.loc[gap_ts, "storage_pct"])
def test_none_and_empty(self):
assert hourly_frame(None) is None
assert hourly_frame(pd.DataFrame()) is None
class TestLoadDaily:
def test_empty_db_result_does_not_wipe_cache(self, tmp_path):
from sqlalchemy import create_engine, text
from src.ml.dam import CACHE_FILE, load_daily
# Good cache from a previous run
cache_path = tmp_path / CACHE_FILE
_daily(3).rename_axis("date").to_csv(cache_path, compression="gzip")
# Reachable DB whose table exists but is empty
db = f"sqlite:///{tmp_path}/empty_dam.db"
with create_engine(db).begin() as conn:
conn.execute(
text(
"CREATE TABLE rid_reservoir_daily (dam_id TEXT, date DATE, "
"storage_pct REAL, inflow_mcm REAL, outflow_mcm REAL)"
)
)
result = load_daily(db_url=db, cache_dir=tmp_path)
assert result.empty # honest empty result...
cached = pd.read_csv(cache_path, index_col=0)
assert len(cached) == 3 # ...but the good cache survives
def _grid(hours=400, start="2024-09-15"):
idx = pd.date_range(start, periods=hours, freq="h")
frames = []
for code in ("P.1", "P.82"):
frames.append(
pd.DataFrame(
{
"timestamp": idx,
"station_code": code,
"water_level": 2.0,
"discharge": 100.0,
}
)
)
return features.make_hourly_grid(pd.concat(frames, ignore_index=True))
class TestFeatureGating:
def test_dam_columns_only_for_dam_stations(self):
grid = _grid()
dam = hourly_frame(_daily(days=20, start="2024-09-10"))
X_p1 = features.build_features(grid, "P.1", dam=dam)
X_p82 = features.build_features(grid, "P.82", dam=dam)
for col in features.DAM_FEATURES:
assert col in X_p1.columns
assert col not in X_p82.columns
# values actually aligned, not all-NaN
assert X_p1["dam_storage_pct"].notna().any()
assert X_p1["dam_inflow"].notna().any()
def test_none_dam_omits_columns(self):
X = features.build_features(_grid(), "P.1", dam=None)
for col in features.DAM_FEATURES:
assert col not in X.columns
def test_empty_dam_frame_yields_nan_columns(self):
# Serving contract: empty frame -> columns exist as NaN so dam-trained
# bundles pass the feature guard when the DB read fails.
X = features.build_features(_grid(), "P.1", dam=pd.DataFrame())
for col in features.DAM_FEATURES:
assert col in X.columns
assert X[col].isna().all()
def test_storage_delta_72h(self):
grid = _grid(hours=24 * 12, start="2024-09-15")
dam = hourly_frame(_daily(days=20, start="2024-09-10"))
X = features.build_features(grid, "P.1", dam=dam)
ts = pd.Timestamp("2024-09-24 12:00")
expected = X.loc[ts, "dam_storage_pct"] - X.loc[
ts - pd.Timedelta(hours=72), "dam_storage_pct"
]
assert abs(X.loc[ts, "dam_storage_pct_d3"] - expected) < 1e-9
+2 -2
View File
@@ -197,7 +197,7 @@ def test_train_smoke_and_roundtrip(tmp_path):
df = make_synth(n, data_stations, seed=7, pulses=pulses) df = make_synth(n, data_stations, seed=7, pulses=pulses)
metrics = train.train_all( metrics = train.train_all(
df, target_stations, models_dir=tmp_path, skip_eval=True, hgb_overrides={"max_iter": 20}, use_rain=False df, target_stations, models_dir=tmp_path, skip_eval=True, hgb_overrides={"max_iter": 20}, use_rain=False, use_dam=False
) )
assert metrics["stations"]["P.1"]["status"] == "trained" assert metrics["stations"]["P.1"]["status"] == "trained"
assert metrics["stations"]["P.20"]["status"] == "trained" assert metrics["stations"]["P.20"]["status"] == "trained"
@@ -237,7 +237,7 @@ def test_feature_name_stability(tmp_path):
upstream = [code for code, _lead in features.UPSTREAM_LEADS["P.1"]] upstream = [code for code, _lead in features.UPSTREAM_LEADS["P.1"]]
data_stations = ["P.1"] + upstream data_stations = ["P.1"] + upstream
df = make_synth(300, data_stations, seed=11, pulses={"P.1": [(100, 20, 2.0)]}) df = make_synth(300, data_stations, seed=11, pulses={"P.1": [(100, 20, 2.0)]})
train.train_all(df, ["P.1"], models_dir=tmp_path, skip_eval=True, hgb_overrides={"max_iter": 10}, use_rain=False) train.train_all(df, ["P.1"], models_dir=tmp_path, skip_eval=True, hgb_overrides={"max_iter": 10}, use_rain=False, use_dam=False)
# Safe: loading the bundle this same test just wrote to tmp_path, not an external file. # Safe: loading the bundle this same test just wrote to tmp_path, not an external file.
bundle = joblib.load(tmp_path / "flood_P.1.joblib") bundle = joblib.load(tmp_path / "flood_P.1.joblib")