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
+2
View File
@@ -77,6 +77,8 @@ def test_dashboard_loads_station_history_chart():
html = DASHBOARD_PATH.read_text(encoding="utf-8")
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 "/measurements/history/" 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
)
)
== []
)