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
+30 -1
View File
@@ -633,6 +633,34 @@
};
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
if (state.historyChart) { state.historyChart.destroy(); state.historyChart = null; }
// 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' })),
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: '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: {