CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 26s
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 / Validate Documentation (push) Failing after 7s
Documentation / Generate API Documentation (push) Successful in 9s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 12s
Documentation / Build Sphinx Documentation (push) Successful in 18s
Documentation / Documentation Summary (push) Successful in 2s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 0s
rain.backfill_db pushes the full cached Open-Meteo archive into the openmeteo_rain table in 5k-row idempotent upsert chunks; scripts/backfill_rain_db.py is the thin CLI (DB from Config/.env or --db-url). Safe to re-run and safe alongside the hourly live writer.
238 lines
8.3 KiB
Python
238 lines
8.3 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
|