feat: Open-Meteo catchment rainfall series + rain features + harness variant
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 17s
Documentation / Validate Documentation (push) Failing after 7s
Documentation / Build Sphinx Documentation (push) Successful in 15s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 25s
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
Documentation / Generate API Documentation (push) Successful in 8s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 2s

src/ml/rain.py fetches hourly precipitation for five upper-Ping
catchment points (Chiang Dao, Mae Taeng, Mae Ngat, Mae Rim, city) from
the Open-Meteo forecast-model archive (2021-03 onward, no API key,
Bangkok-local timestamps, year-chunked local cache) plus the live
forecast endpoint for serving (trailing days + next 48h).

features.build_features/build_matrix accept the catchment-mean series
and add rain_6h/24h/72h trailing sums and rain_fc24 — the forward 24h
sum, a genuine forecast feature (archived forecasts at training time, a
real weather forecast at serving; never contains river data). Columns
exist only when a series is provided: HGB rejects all-NaN columns at
fit, so no-rain training omits them and serving passes an empty series
for alignment. evaluate.py gains the rise_rain variant (and --no-rain)
so the harness can judge whether rain beats the deployed rise baseline.
This commit is contained in:
2026-08-12 16:39:36 +07:00
parent 21e9d2e114
commit cbb3bf7369
3 changed files with 221 additions and 6 deletions
+29 -3
View File
@@ -56,15 +56,24 @@ 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): quantile: bool = False, use_rain: 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
def fit_predict( def fit_predict(
self, X_tr, y_abs_tr, X_te self, X_tr, y_abs_tr, X_te
) -> Tuple[np.ndarray, np.ndarray]: ) -> Tuple[np.ndarray, np.ndarray]:
if not self.use_rain:
drop = [c for c in features.RAIN_FEATURES if c in X_tr.columns]
X_tr = X_tr.drop(columns=drop)
X_te = X_te.drop(columns=drop)
elif "rain_24h" not in X_tr.columns:
raise ValueError(
f"{self.name} requires the rain series (run without --no-rain)"
)
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
@@ -93,6 +102,7 @@ VARIANTS: Dict[str, Variant] = {
"rise_weighted": Variant("rise_weighted", target="rise", weighted=True), "rise_weighted": Variant("rise_weighted", target="rise", weighted=True),
"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),
} }
@@ -187,11 +197,12 @@ def evaluate_station(
station: str, station: str,
variants: Optional[List[str]] = None, variants: Optional[List[str]] = None,
seasons: Tuple[int, ...] = SEASONS, seasons: Tuple[int, ...] = SEASONS,
rain: Optional[pd.Series] = 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) X_all = features.build_features(grid, station, rain=rain)
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()
@@ -339,6 +350,8 @@ def main(argv=None) -> int:
parser.add_argument("--variants", default=None, parser.add_argument("--variants", default=None,
help="comma list; default all") help="comma list; default all")
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",
help="skip loading the Open-Meteo rain series")
args = parser.parse_args(argv) args = parser.parse_args(argv)
logging.basicConfig( logging.basicConfig(
@@ -349,12 +362,25 @@ def main(argv=None) -> int:
logger.error("no measurement data") logger.error("no measurement data")
return 1 return 1
rain_series = None
if not args.no_rain:
from . import rain as rain_mod
rain_series = rain_mod.catchment_mean(rain_mod.load_history())
if rain_series is None:
logger.warning("rain history unavailable; rain features will be NaN")
else:
logger.info(
f"rain series loaded: {rain_series.index.min()} .. "
f"{rain_series.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) results = evaluate_station(df, station, variant_names, rain=rain_series)
all_results.append(results) all_results.append(results)
print(summarize(results)) print(summarize(results))
+30 -3
View File
@@ -200,8 +200,24 @@ def _hours_since_observed(mask_col: pd.Series) -> pd.Series:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def build_features(grid: HourlyGrid, station: str) -> pd.DataFrame: RAIN_FEATURES = ("rain_6h", "rain_24h", "rain_72h", "rain_fc24")
"""Build the deterministic-order feature matrix for one target station."""
def build_features(
grid: HourlyGrid, station: str, rain: Optional[pd.Series] = None
) -> pd.DataFrame:
"""Build the deterministic-order feature matrix for one target station.
``rain`` is the hourly catchment-average precipitation series (Open-Meteo,
src/ml/rain.py). Rain columns are added only when a series is passed:
HistGradientBoosting REJECTS all-NaN columns at fit time, so training
without rain must omit the columns entirely (bundles record their
feature_names, and serving subsets to them). At serving, pass an empty
series rather than None so the columns exist (as NaN) for rain-trained
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
forecast at serving time; it never contains river data.
"""
idx = grid.observed.index idx = grid.observed.index
cols: Dict[str, pd.Series] = {} cols: Dict[str, pd.Series] = {}
@@ -254,6 +270,16 @@ def build_features(grid: HourlyGrid, station: str) -> pd.DataFrame:
cols["doy_cos"] = np.cos(2 * np.pi * doy / 365.25) cols["doy_cos"] = np.cos(2 * np.pi * doy / 365.25)
cols["is_monsoon"] = idx.to_series().dt.month.isin(MONSOON_MONTHS).astype(float) cols["is_monsoon"] = idx.to_series().dt.month.isin(MONSOON_MONTHS).astype(float)
if rain is not None:
r = rain.reindex(idx)
cols["rain_6h"] = r.rolling(6, min_periods=1).sum()
cols["rain_24h"] = r.rolling(24, min_periods=1).sum()
cols["rain_72h"] = r.rolling(72, min_periods=1).sum()
# forward sum over (t, t+24]: shift(-1) starts the window at t+1
cols["rain_fc24"] = (
r.shift(-1).iloc[::-1].rolling(24, min_periods=1).sum().iloc[::-1]
)
return pd.DataFrame(cols, index=idx) return pd.DataFrame(cols, index=idx)
@@ -331,10 +357,11 @@ def build_matrix(
station: str, station: str,
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,
) -> 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) X = build_features(grid, station, rain=rain)
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()
+162
View File
@@ -0,0 +1,162 @@
"""Open-Meteo rainfall series for the upper Ping catchment.
One consistent source for training AND serving: the Open-Meteo forecast-model
archive (historical-forecast-api, 2021-03 onward) supplies hourly
precipitation at five catchment points above P.1; the live forecast endpoint
supplies the same series for recent days plus the next 48 h. Timestamps are
Asia/Bangkok local, matching the measurement grid. Rows before 2021-03 simply
have no rain data — HistGradientBoosting handles the NaNs natively.
The forward-looking sum built from this series is a legitimate *forecast*
feature, not label leakage: the series never contains river observations, and
at serving time the future values come from an actual weather forecast.
"""
import datetime
import logging
from pathlib import Path
from typing import Iterable, List, Optional, Tuple
import pandas as pd
import requests
logger = logging.getLogger(__name__)
# (name, lat, lon) — upper Ping catchment above P.1, headwaters to city
CATCHMENT_POINTS: Tuple[Tuple[str, float, float], ...] = (
("chiang_dao", 19.37, 98.97),
("mae_taeng", 19.12, 98.94),
("mae_ngat", 19.17, 99.05),
("mae_rim", 18.92, 98.92),
("chiang_mai", 18.79, 99.00),
)
HISTORY_URL = "https://historical-forecast-api.open-meteo.com/v1/forecast"
FORECAST_URL = "https://api.open-meteo.com/v1/forecast"
HISTORY_START = "2021-03-23" # archive begins here
CACHE_FILE = "rain_openmeteo.csv.gz"
def _points_params() -> dict:
return {
"latitude": ",".join(str(lat) for _, lat, _ in CATCHMENT_POINTS),
"longitude": ",".join(str(lon) for _, _, lon in CATCHMENT_POINTS),
"hourly": "precipitation",
"timezone": "Asia/Bangkok",
}
def _parse_multi(payload, columns: Iterable[str]) -> pd.DataFrame:
"""Open-Meteo returns a list when multiple coordinates are requested."""
results = payload if isinstance(payload, list) else [payload]
frames = {}
for name, result in zip(columns, results):
hourly = result.get("hourly", {})
idx = pd.to_datetime(hourly.get("time", []))
frames[name] = pd.Series(hourly.get("precipitation", []), index=idx)
df = pd.DataFrame(frames)
df.index.name = "timestamp"
return df
def fetch_history(
start: str, end: str, session: Optional[requests.Session] = None
) -> pd.DataFrame:
"""Hourly precipitation for all catchment points over [start, end]."""
session = session or requests.Session()
response = session.get(
HISTORY_URL,
params={**_points_params(), "start_date": start, "end_date": end},
timeout=120,
)
response.raise_for_status()
return _parse_multi(response.json(), [p[0] for p in CATCHMENT_POINTS])
def fetch_forecast(
past_days: int = 5,
forecast_days: int = 2,
session: Optional[requests.Session] = None,
) -> pd.DataFrame:
"""Recent + next-48h precipitation from the live forecast endpoint."""
session = session or requests.Session()
response = session.get(
FORECAST_URL,
params={
**_points_params(),
"past_days": past_days,
"forecast_days": forecast_days,
},
timeout=60,
)
response.raise_for_status()
return _parse_multi(response.json(), [p[0] for p in CATCHMENT_POINTS])
def load_history(
cache_dir: Path = Path("models/cache"),
end: Optional[datetime.date] = None,
refresh: bool = True,
) -> Optional[pd.DataFrame]:
"""Cached catchment rain history from 2021-03 to ~today.
Fetches year-sized chunks on first use (~6 requests), then only extends
the tail. Returns None when the API is unreachable and no cache exists.
"""
cache_dir.mkdir(parents=True, exist_ok=True)
cache_path = cache_dir / CACHE_FILE
end = end or datetime.date.today()
cached: Optional[pd.DataFrame] = None
if cache_path.exists():
cached = pd.read_csv(cache_path, index_col=0, parse_dates=True)
fetch_from = pd.Timestamp(HISTORY_START)
if cached is not None and len(cached):
fetch_from = cached.index.max() - pd.Timedelta(days=2) # re-fetch tail
if not refresh and cached is not None:
return cached
chunks: List[pd.DataFrame] = []
cursor = fetch_from.date()
try:
while cursor <= end:
chunk_end = min(
datetime.date(cursor.year, 12, 31), end
)
chunks.append(
fetch_history(cursor.isoformat(), chunk_end.isoformat())
)
cursor = datetime.date(cursor.year + 1, 1, 1)
except Exception as error:
logger.warning(f"Open-Meteo history fetch failed: {error}")
if not chunks and cached is None:
return None
if chunks:
fresh = pd.concat(chunks)
combined = (
pd.concat([cached[cached.index < fresh.index.min()], fresh])
if cached is not None
else fresh
)
combined = combined[~combined.index.duplicated(keep="last")].sort_index()
combined.to_csv(cache_path, compression="gzip")
return combined
return cached
def catchment_mean(df: Optional[pd.DataFrame]) -> Optional[pd.Series]:
"""Single catchment-average hourly rain series (mm)."""
if df is None or df.empty:
return None
return df.mean(axis=1)
def serving_series() -> Optional[pd.Series]:
"""Catchment rain for inference: trailing days + the next 48 h forecast."""
try:
return catchment_mean(fetch_forecast())
except Exception as error:
logger.warning(f"Open-Meteo forecast fetch failed: {error}")
return None