Files
Northern-Thailand-Ping-Rive…/tests/test_dam_features.py
T
grabowski 28b62e5a36
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
feat: Mae Ngat dam features — built, evaluated, defaulted OFF
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.
2026-08-13 20:42:21 +07:00

129 lines
4.9 KiB
Python

"""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