The Test Suite job failed on every push since the black check was added because the tree had never been formatted, and pre-commit said 120 columns while CI ran black's default 88. pyproject.toml now carries [tool.black] / [tool.isort] (88, black profile) as the single source; pre-commit reads it; `make format` applied it (13 files, whitespace only, 146 insertions / 128 deletions, tests unchanged at 146 passed). ci.yml: lint (black, isort, flake8 hard errors) + pytest. The Docker registry push, VictoriaMetrics integration test, staging/production deploy and Apache-Bench jobs were template scaffolding for hosts and registries that do not exist; production is a systemd unit updated by git pull. Removed rather than left permanently skipped. docs.yml: the "Check markdown links" step curl'd every URL in every .md and failed on localhost examples and the Tailscale IP, and the Sphinx jobs built artifacts nobody read. Replaced by two checks that mean something: relative links/images in README, CONTRIBUTING and docs/ resolve inside the repo, and the FastAPI OpenAPI schema exports with the documented endpoints present (uploaded as an artifact).
233 lines
8.2 KiB
Python
233 lines
8.2 KiB
Python
"""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
|
|
|
|
|
|
def backfill_db(engine, db_type: str, chunk_rows: int = 5000) -> int:
|
|
"""Push the full Open-Meteo history (2021+) into openmeteo_rain.
|
|
|
|
Loads (or fetches) the archive cache and upserts in chunks; idempotent,
|
|
safe to re-run, and safe alongside the hourly live writer.
|
|
"""
|
|
history = load_history()
|
|
if history is None or history.empty:
|
|
logger.error("no rain history available to backfill")
|
|
return 0
|
|
total = 0
|
|
for start in range(0, len(history), chunk_rows):
|
|
part = history.iloc[start : start + chunk_rows]
|
|
total += save_to_db(part, engine, db_type)
|
|
logger.info(f"openmeteo_rain backfill: {total}/{len(history)} rows")
|
|
return total
|
|
|
|
|
|
def save_to_db(df: pd.DataFrame, engine, db_type: str) -> int:
|
|
"""Upsert per-point + catchment-mean hourly rain into openmeteo_rain.
|
|
|
|
Called by the leader worker's hourly precompute with the live forecast
|
|
frame, so the DB accumulates both what fell (past rows are the model
|
|
analysis) and what was forecast (future rows, overwritten as they become
|
|
past). The ML training path reads Open-Meteo's own archive, not this
|
|
table — this is for dashboards, SQL analysis, and source independence.
|
|
"""
|
|
if df is None or df.empty:
|
|
return 0
|
|
from sqlalchemy import text
|
|
|
|
point_cols = [p[0] for p in CATCHMENT_POINTS]
|
|
ddl_cols = ", ".join(f"{c} NUMERIC(6,2)" for c in point_cols)
|
|
ddl = (
|
|
"CREATE TABLE IF NOT EXISTS openmeteo_rain ("
|
|
"timestamp TIMESTAMP PRIMARY KEY, "
|
|
f"{ddl_cols}, catchment_mean NUMERIC(6,2), "
|
|
"created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
|
|
)
|
|
cols = ["timestamp"] + point_cols + ["catchment_mean"]
|
|
placeholders = ", ".join(f":{c}" for c in cols)
|
|
updates = ", ".join(
|
|
f"{c} = " + (f"VALUES({c})" if db_type == "mysql" else f"EXCLUDED.{c}")
|
|
for c in cols[1:]
|
|
)
|
|
if db_type == "mysql":
|
|
sql = (
|
|
f"INSERT INTO openmeteo_rain ({', '.join(cols)}) VALUES ({placeholders}) "
|
|
f"ON DUPLICATE KEY UPDATE {updates}"
|
|
)
|
|
else:
|
|
sql = (
|
|
f"INSERT INTO openmeteo_rain ({', '.join(cols)}) VALUES ({placeholders}) "
|
|
f"ON CONFLICT (timestamp) DO UPDATE SET {updates}"
|
|
)
|
|
mean = df.mean(axis=1)
|
|
params = [
|
|
{
|
|
"timestamp": ts.to_pydatetime(),
|
|
**{c: (None if pd.isna(row[c]) else float(row[c])) for c in point_cols},
|
|
"catchment_mean": None if pd.isna(mean.loc[ts]) else float(mean.loc[ts]),
|
|
}
|
|
for ts, row in df.iterrows()
|
|
]
|
|
try:
|
|
with engine.begin() as conn:
|
|
conn.execute(text(ddl))
|
|
conn.execute(text(sql), params)
|
|
return len(params)
|
|
except Exception as error:
|
|
logger.error(f"openmeteo_rain save failed: {error}")
|
|
return 0
|