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:
@@ -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
|
||||
Reference in New Issue
Block a user