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.
129 lines
4.9 KiB
Python
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
|