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
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:
@@ -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.70–4.60 m), computed from the regression head and
|
official inundation stages (3.70–4.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 19–22 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 1–3 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 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user