Files
Northern-Thailand-Ping-Rive…/tests/test_dam_features.py
T
grabowski 7e642555b4 chore: remove the Python service; Python is the training stack only
Phase 4 of docs/RUST_REWRITE.md. Since 2026-10-18 ping-collector (rust/)
collects, forecasts, notifies (ntfy + Matrix) and serves the website/API;
nothing in production ran the Python service code any more.

Removed (~17k lines): the FastAPI app (web_api, schemas, health_check,
metrics, postgres_history, forecast_history, thaiwater), the Python RID
collector and CLI (water_scraper_v3, main, run.py), the notifier and Matrix
alerting (notify, alerting), the multi-backend adapters (SQLite, MySQL,
InfluxDB, VictoriaMetrics) with demo databases, models/validators/exceptions/
logging/rate limiter, ml/skill.py (served by Rust now), the migration tooling
(api_parity, collector_parity, e2e_notify, install_collector.sh,
gen_notify_cases.py, spikes/rust-forecast), Docker/compose/Grafana files,
sql/, the release workflow and the docs for all of that. Their tests went
with them.

Kept in Python: src/ml (training, evaluation harness, export; predict.py as
the reference the Rust forecaster is tested against), the HII and RID-dam
clients/stores for the archive backfills (their live collectors removed),
the backfill scripts, retrain.sh + timer, forecast_parity.py.

- PostgreSQL only: src/config.py is database_url() from POSTGRES_* / .env;
  the stores lost their sqlite/mysql branches (and the 2026-08 column
  migration). The database tests run against a throwaway schema from
  TEST_DATABASE_URL (tests/conftest.py pg_url, skipped without it), as the
  Rust tests do; CI gets a PostgreSQL service. 100 tests (66 without a DB).
- The notifier settings moved from src/notify.py into src/ml/export.py;
  flood_config.json regenerates byte-identical.
- tests/test_dashboard.py checks every path the dashboard fetches against
  ping-collector's router.
- Dependencies: fastapi, uvicorn, pydantic, influxdb, pymysql, psutil,
  schedule, sphinx, pyinstaller, mypy, pytest-asyncio dropped; python-dotenv
  1.1.1 explicit (it came in through uvicorn[standard]).
- Formatting contract extended to scripts/ and tests/ (reformatted once).
- scripts/install.sh is the one installer (fresh host or update, in place in
  a checkout without touching its ownership): binary, units, uv sync, .env
  defaults, removes the retired water-monitor.service and any old
  --alert-check cron job, dry run + forecast check, restart, readiness wait.
- Docs, CLAUDE.md, CONTRIBUTING.md, .env.example, Makefile, unit comments
  and Rust doc comments updated to the Rust service.
2026-10-18 17:15:07 +07:00

131 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, pg_url):
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
engine = create_engine(pg_url)
with engine.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)"
)
)
engine.dispose()
result = load_daily(db_url=pg_url, 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