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
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:
@@ -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
|
||||
)
|
||||
)
|
||||
== []
|
||||
)
|
||||
Reference in New Issue
Block a user