feat: forecast precompute + issued-forecast archive + dashboard overlay
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 23s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 12s
Documentation / Validate Documentation (push) Failing after 7s
Documentation / Generate API Documentation (push) Successful in 8s
Documentation / Documentation Summary (push) Successful in 3s
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 / Build Sphinx Documentation (push) Successful in 14s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 0s

The collection-leader worker now precomputes forecasts after every
scrape cycle: primes the /forecast cache (users never trigger the
multi-second inference — its TTL rises to 4500s so the hourly refresh
always wins) and persists every issued forecast to a new
forecast_history table keyed by (as_of, station, horizon) with
predicted max level, warn/danger probabilities, current level, and
model_version. This is the operational record the backtests lacked —
predicted-vs-actual becomes a simple join instead of retraining
historical models.

GET /api/forecast/history/{station} serves the archive (hours or
start/end + horizon filters, 5-min edge cache), and the station history
chart overlays 'Model 24 h peak (as issued)' as a dashed violet line
once data accumulates.
This commit is contained in:
2026-08-12 14:13:39 +07:00
parent 731f10910e
commit 98023243af
5 changed files with 382 additions and 2 deletions
+172
View File
@@ -0,0 +1,172 @@
"""Persistence for issued flood forecasts.
Every background precompute stores what the deployed model predicted at that
moment — predicted 24/12/6 h peak, warning/danger probabilities, model
version. Keyed by (as_of, station, horizon), so hourly data yields one row
per station-horizon per hour regardless of how often the precompute runs.
This is the operational record that lets "predicted vs actual" be graphed
later without retraining historical models.
"""
import datetime
import logging
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
class ForecastHistoryStore:
"""SQL store (sqlite / postgresql / mysql), same pattern as HiiStore."""
def __init__(self, connection_string: str, db_type: str):
self.db_type = db_type.lower()
if self.db_type not in ("sqlite", "postgresql", "mysql"):
raise ValueError(
f"Forecast history requires a SQL database, got '{db_type}'"
)
self.connection_string = connection_string
self.engine = None
def connect(self) -> bool:
try:
from sqlalchemy import create_engine, text
self.engine = create_engine(self.connection_string, pool_pre_ping=True)
ddl = """
CREATE TABLE IF NOT EXISTS forecast_history (
as_of TIMESTAMP NOT NULL,
station_code VARCHAR(10) NOT NULL,
horizon_hours INTEGER NOT NULL,
predicted_max_level NUMERIC(8,3),
p_warning NUMERIC(7,5),
p_danger NUMERIC(7,5),
current_level NUMERIC(8,3),
model_version VARCHAR(64),
source VARCHAR(16),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (as_of, station_code, horizon_hours)
)
"""
index = (
"CREATE INDEX IF NOT EXISTS idx_forecast_history_station "
"ON forecast_history(station_code, as_of)"
)
with self.engine.begin() as conn:
conn.execute(text(ddl))
if self.db_type != "mysql": # MySQL lacks IF NOT EXISTS for indexes
conn.execute(text(index))
return True
except Exception as error:
logger.error(f"ForecastHistoryStore failed to connect: {error}")
self.engine = None
return False
def save_rows(self, rows: List[Dict]) -> int:
"""Upsert forecast rows as returned by ml.predict (idempotent)."""
if not rows:
return 0
if not self.engine and not self.connect():
return 0
from sqlalchemy import text
cols = (
"(as_of, station_code, horizon_hours, predicted_max_level, "
"p_warning, p_danger, current_level, model_version, source)"
)
values = (
"(:as_of, :station_code, :horizon_hours, :predicted_max_level, "
":p_warning, :p_danger, :current_level, :model_version, :source)"
)
update_cols = (
"predicted_max_level",
"p_warning",
"p_danger",
"current_level",
"model_version",
"source",
)
if self.db_type == "mysql":
updates = ", ".join(f"{c} = VALUES({c})" for c in update_cols)
sql = (
f"INSERT INTO forecast_history {cols} VALUES {values} "
f"ON DUPLICATE KEY UPDATE {updates}"
)
else:
updates = ", ".join(f"{c} = EXCLUDED.{c}" for c in update_cols)
sql = (
f"INSERT INTO forecast_history {cols} VALUES {values} "
f"ON CONFLICT (as_of, station_code, horizon_hours) "
f"DO UPDATE SET {updates}"
)
params = []
for row in rows:
as_of = row.get("as_of")
if isinstance(as_of, str):
as_of = datetime.datetime.fromisoformat(as_of)
if as_of is None or row.get("station_code") is None:
continue
params.append(
{
"as_of": as_of,
"station_code": row["station_code"],
"horizon_hours": row.get("horizon_hours"),
"predicted_max_level": row.get("predicted_max_level"),
"p_warning": row.get("p_warning"),
"p_danger": row.get("p_danger"),
"current_level": row.get("current_level"),
"model_version": row.get("model_version"),
"source": row.get("source"),
}
)
if not params:
return 0
try:
with self.engine.begin() as conn:
conn.execute(text(sql), params)
return len(params)
except Exception as error:
logger.error(f"ForecastHistoryStore save failed: {error}")
return 0
def fetch(
self,
station_code: str,
start: Optional[datetime.datetime] = None,
end: Optional[datetime.datetime] = None,
horizon_hours: Optional[int] = None,
limit: int = 5000,
) -> List[Dict]:
"""Issued forecasts for one station, ascending by as_of."""
if not self.engine and not self.connect():
return []
from sqlalchemy import text
clauses = ["station_code = :code"]
params: Dict = {"code": station_code, "limit": limit}
if start is not None:
clauses.append("as_of >= :start")
params["start"] = start
if end is not None:
clauses.append("as_of <= :end")
params["end"] = end
if horizon_hours is not None:
clauses.append("horizon_hours = :horizon")
params["horizon"] = horizon_hours
sql = (
"SELECT as_of, station_code, horizon_hours, predicted_max_level, "
"p_warning, p_danger, current_level, model_version, source "
f"FROM forecast_history WHERE {' AND '.join(clauses)} "
"ORDER BY as_of ASC, horizon_hours ASC LIMIT :limit"
)
try:
with self.engine.connect() as conn:
rows = [dict(r._mapping) for r in conn.execute(text(sql), params)]
for row in rows:
for key, value in row.items():
if hasattr(value, "is_finite"): # Decimal -> float
row[key] = float(value)
return rows
except Exception as error:
logger.error(f"ForecastHistoryStore fetch failed: {error}")
return []
+30 -1
View File
@@ -633,6 +633,34 @@
}; };
const sampled = rows.length > 2000 ? downsample(rows) : rows; const sampled = rows.length > 2000 ? downsample(rows) : rows;
// Issued model predictions (recorded hourly by the server) —
// overlays what the model said would happen, when it said it.
let predSeries = null;
try {
const fr = await fetch(`/api/forecast/history/${encodeURIComponent(stationCode)}?${query}&horizon=24`);
const forecastRows = fr.ok ? await fr.json() : [];
if (forecastRows.length) {
const keyOf = (value) => {
const d = new Date(value);
return rows.length > 2000
? `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`
: `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}-${d.getUTCHours()}`;
};
const byKey = new Map();
forecastRows.forEach((r) => {
if (r.predicted_max_level == null) return;
const key = keyOf(r.as_of);
if (!byKey.has(key)) byKey.set(key, []);
byKey.get(key).push(Number(r.predicted_max_level));
});
predSeries = sampled.map((row) => {
const vals = byKey.get(keyOf(row.timestamp));
return vals ? vals.reduce((a, c) => a + c, 0) / vals.length : null;
});
if (!predSeries.some((v) => v != null)) predSeries = null;
}
} catch (error) { /* overlay is optional */ }
// Safely clear existing chart instance // Safely clear existing chart instance
if (state.historyChart) { state.historyChart.destroy(); state.historyChart = null; } if (state.historyChart) { state.historyChart.destroy(); state.historyChart = null; }
// Clear any orphan Chart.js instance on the canvas // Clear any orphan Chart.js instance on the canvas
@@ -645,7 +673,8 @@
labels: sampled.map((row) => new Date(row.timestamp).toLocaleString('en-TH', { timeZone: 'Asia/Bangkok', month: 'short', day: 'numeric', year: '2-digit', hour: '2-digit' })), labels: sampled.map((row) => new Date(row.timestamp).toLocaleString('en-TH', { timeZone: 'Asia/Bangkok', month: 'short', day: 'numeric', year: '2-digit', hour: '2-digit' })),
datasets: [ datasets: [
{ label: 'Discharge (m³/s)', data: sampled.map((row) => row.discharge), borderColor: '#087da5', backgroundColor: 'rgba(8,125,165,.12)', yAxisID: 'flow', pointRadius: 0, tension: .25 }, { label: 'Discharge (m³/s)', data: sampled.map((row) => row.discharge), borderColor: '#087da5', backgroundColor: 'rgba(8,125,165,.12)', yAxisID: 'flow', pointRadius: 0, tension: .25 },
{ label: 'Water level (m)', data: sampled.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', pointRadius: 0, tension: .25 } { label: 'Water level (m)', data: sampled.map((row) => row.water_level), borderColor: '#d99018', yAxisID: 'level', pointRadius: 0, tension: .25 },
...(predSeries ? [{ label: 'Model 24 h peak (as issued)', data: predSeries, borderColor: '#7c4fd0', borderDash: [6, 4], yAxisID: 'level', pointRadius: 0, tension: .25, spanGaps: true }] : [])
] ]
}, },
options: { options: {
+79 -1
View File
@@ -60,7 +60,9 @@ HISTORY_TTL = 300 # 5 minutes
FORECAST_CACHE: Dict[str, tuple] = {} FORECAST_CACHE: Dict[str, tuple] = {}
FORECAST_CACHE_LOCK = Lock() FORECAST_CACHE_LOCK = Lock()
FORECAST_COMPUTE_LOCK = asyncio.Lock() # single-flight for expensive inference FORECAST_COMPUTE_LOCK = asyncio.Lock() # single-flight for expensive inference
FORECAST_TTL = 900 # 15 minutes # The leader worker precomputes after every scrape cycle (hourly); the TTL just
# needs to outlive one cycle so user requests never trigger inference themselves.
FORECAST_TTL = int(os.getenv("FORECAST_TTL_SECONDS", "4500"))
DB_STATS_CACHE: Dict[str, tuple] = {} DB_STATS_CACHE: Dict[str, tuple] = {}
DB_STATS_CACHE_LOCK = Lock() DB_STATS_CACHE_LOCK = Lock()
@@ -159,6 +161,20 @@ async def lifespan(app: FastAPI):
db_config = Config.get_database_config() db_config = Config.get_database_config()
app_state["scraper"] = EnhancedWaterMonitorScraper(db_config) app_state["scraper"] = EnhancedWaterMonitorScraper(db_config)
# Forecast history store (SQL only): records what the model predicted
try:
if db_config["type"] in ("sqlite", "postgresql", "mysql"):
from .forecast_history import ForecastHistoryStore
app_state["forecast_store"] = ForecastHistoryStore(
db_config["connection_string"], db_config["type"]
)
else:
app_state["forecast_store"] = None
except Exception as e:
app_state["forecast_store"] = None
logger.error(f"Forecast history store init failed: {e}")
# Initialize HII/ThaiWater collector (rainfall + backup water level) # Initialize HII/ThaiWater collector (rainfall + backup water level)
try: try:
from .hii_collector import create_collector_from_config from .hii_collector import create_collector_from_config
@@ -248,6 +264,32 @@ app.add_middleware(
) )
async def _precompute_forecasts():
"""Refresh the forecast cache and persist the issued forecasts (leader only)."""
try:
from .ml.predict import get_latest_forecasts
except ImportError:
return
try:
rows = await asyncio.to_thread(get_latest_forecasts)
except Exception as e:
logger.warning(f"Forecast precompute failed: {e}")
return
if not rows:
return
with FORECAST_CACHE_LOCK:
FORECAST_CACHE["all"] = (time.monotonic(), rows)
store = app_state.get("forecast_store")
if store:
try:
saved = await asyncio.to_thread(store.save_rows, rows)
logger.info(
f"Forecast precompute: cached {len(rows)} rows, persisted {saved}"
)
except Exception as e:
logger.warning(f"Forecast history save failed: {e}")
async def background_scraping_task(): async def background_scraping_task():
"""Background task for periodic data scraping""" """Background task for periodic data scraping"""
while True: while True:
@@ -309,6 +351,12 @@ async def background_scraping_task():
except Exception as e: except Exception as e:
logger.error(f"HII collection failed: {e}") logger.error(f"HII collection failed: {e}")
# Precompute forecasts on fresh data: primes the response
# cache (user requests never pay for inference) and records
# what the model predicted for later predicted-vs-actual
# evaluation.
await _precompute_forecasts()
app_state["is_scraping"] = False app_state["is_scraping"] = False
# Calculate next run time # Calculate next run time
@@ -366,6 +414,7 @@ def _send_umami_event(path: str, method: str, status: int, host: str, user_agent
_CACHE_CONTROL_RULES = ( _CACHE_CONTROL_RULES = (
("/static/", "public, max-age=3600"), ("/static/", "public, max-age=3600"),
("/measurements/latest", "public, max-age=30"), ("/measurements/latest", "public, max-age=30"),
("/api/forecast/", "public, max-age=300"),
("/api/hii/", "public, max-age=60"), ("/api/hii/", "public, max-age=60"),
("/measurements/history", "public, max-age=300"), ("/measurements/history", "public, max-age=300"),
("/forecast", "public, max-age=120"), ("/forecast", "public, max-age=120"),
@@ -1007,6 +1056,35 @@ async def _compute_forecasts(get_latest_forecasts):
return data return data
@app.get("/api/forecast/history/{station_code}")
async def get_forecast_history(
station_code: str,
hours: int = Query(168, ge=1),
start: Optional[date] = Query(None, description="First day (overrides hours)"),
end: Optional[date] = Query(None, description="Last day, inclusive"),
horizon: Optional[int] = Query(None, ge=1, le=48),
):
"""Issued model forecasts for a station — what the model predicted, when.
Populated by the hourly precompute; enables predicted-vs-actual charts.
"""
increment_counter("api_requests", labels={"endpoint": "forecast_history"})
store = app_state.get("forecast_store")
if not store:
return []
end_dt = (
datetime.combine(end, datetime.max.time()) if end else datetime.now()
)
start_dt = (
datetime.combine(start, datetime.min.time())
if start
else end_dt - timedelta(hours=hours)
)
return await asyncio.to_thread(
store.fetch, station_code, start_dt, end_dt, horizon
)
@app.get("/measurements/latest", response_model=List[MeasurementResponse]) @app.get("/measurements/latest", response_model=List[MeasurementResponse])
async def get_latest_measurements(response: Response, limit: int = 100): async def get_latest_measurements(response: Response, limit: int = 100):
"""Get latest measurements from all stations""" """Get latest measurements from all stations"""
+2
View File
@@ -77,6 +77,8 @@ def test_dashboard_loads_station_history_chart():
html = DASHBOARD_PATH.read_text(encoding="utf-8") html = DASHBOARD_PATH.read_text(encoding="utf-8")
assert "Station history" in html assert "Station history" in html
assert "/api/forecast/history/" in html
assert "Model 24 h peak (as issued)" in html
assert "PostgreSQL" not in html assert "PostgreSQL" not in html
assert "/measurements/history/" in html assert "/measurements/history/" in html
assert "history-chart" in html assert "history-chart" in html
+99
View File
@@ -0,0 +1,99 @@
"""Tests for the issued-forecast archive (store + API endpoint)."""
import asyncio
import datetime
import pytest
from src.forecast_history import ForecastHistoryStore
def _rows(as_of="2026-08-12T10:00:00"):
return [
{
"as_of": as_of,
"station_code": "P.1",
"horizon_hours": h,
"predicted_max_level": 2.8 + h / 100,
"p_warning": 0.02,
"p_danger": 0.001,
"current_level": 2.76,
"model_version": "hgb-v1+test",
"trained_at": "2026-08-12T10:52:00",
"source": "model",
}
for h in (6, 12, 24)
]
class TestForecastHistoryStore:
@pytest.fixture
def store(self, tmp_path):
store = ForecastHistoryStore(f"sqlite:///{tmp_path}/fh.db", "sqlite")
assert store.connect()
return store
def test_rejects_non_sql(self):
with pytest.raises(ValueError):
ForecastHistoryStore("http://x", "victoriametrics")
def test_roundtrip_and_upsert(self, store):
assert store.save_rows(_rows()) == 3
# Same as_of again -> upsert, still 3 rows
assert store.save_rows(_rows()) == 3
rows = store.fetch("P.1")
assert len(rows) == 3
assert [r["horizon_hours"] for r in rows] == [6, 12, 24]
assert rows[2]["predicted_max_level"] == pytest.approx(3.04)
assert rows[0]["model_version"] == "hgb-v1+test"
def test_fetch_filters(self, store):
store.save_rows(_rows("2026-08-12T10:00:00"))
store.save_rows(_rows("2026-08-12T11:00:00"))
only_24 = store.fetch("P.1", horizon_hours=24)
assert len(only_24) == 2
assert all(r["horizon_hours"] == 24 for r in only_24)
windowed = store.fetch(
"P.1",
start=datetime.datetime(2026, 8, 12, 10, 30),
end=datetime.datetime(2026, 8, 12, 12, 0),
)
assert len(windowed) == 3 # only the 11:00 issue
assert store.fetch("P.99") == []
def test_skips_malformed_rows(self, store):
rows = _rows() + [{"station_code": None, "as_of": None}]
assert store.save_rows(rows) == 3
class TestForecastHistoryEndpoint:
def test_endpoint_returns_rows(self, tmp_path, monkeypatch):
from src import web_api
store = ForecastHistoryStore(f"sqlite:///{tmp_path}/api-fh.db", "sqlite")
assert store.connect()
now = datetime.datetime.now().replace(minute=0, second=0, microsecond=0)
store.save_rows(_rows(now.isoformat()))
monkeypatch.setitem(web_api.app_state, "forecast_store", store)
rows = asyncio.run(
web_api.get_forecast_history(
"P.1", hours=48, start=None, end=None, horizon=24
)
)
assert len(rows) == 1
assert rows[0]["horizon_hours"] == 24
assert rows[0]["station_code"] == "P.1"
def test_endpoint_without_store(self, monkeypatch):
from src import web_api
monkeypatch.setitem(web_api.app_state, "forecast_store", None)
assert (
asyncio.run(
web_api.get_forecast_history(
"P.1", hours=168, start=None, end=None, horizon=None
)
)
== []
)