fix: one current river level, not two
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 29s
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
CI/CD Pipeline - Northern Thailand Ping River Monitor / Code Quality (push) Successful in 18s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s

The verdict banner and the P.1 outlook each wrote state.p1Now from a
different feed — the banner from the latest measurement, the outlook from
current_level on the forecast rows, which carries whatever the model saw
at its as_of. Forecasts are precomputed hourly, so the two drifted apart:
production showed 1.66 m in the banner and 1.52 m in the outlook directly
below it. Harmless at low water; at flood stage two contradictory river
levels on one screen undermine the warning.

setP1Level() now arbitrates: freshest timestamp wins, and the replay and
demo hooks pass force since they deliberately pin a level that is not the
live one. The outlook renders the arbitrated value and clamps the shown
peak to at least the current level, so a stale forecast can no longer
predict a peak below where the river already is. endReplay drops the
replayed level so live data re-arbitrates cleanly.

Verified against a stub reproducing the exact production conditions
(gauge 1.66 at 08:35 vs forecast 1.52 at 08:00): both now read 1.66; a
2.50 m rise against a stale 1.81 m peak renders 2.50/2.50; the 2024
replay still tracks its frames and returns to live on stop.
This commit is contained in:
2026-08-14 09:25:39 +07:00
parent 382daa7d86
commit 7e64e0cf18
2 changed files with 64 additions and 8 deletions
+42 -8
View File
@@ -395,7 +395,7 @@
<script>
(function () {
'use strict';
const state = { map: null, layers: [], markers: new Map(), hasFit: false, historyChart: null, selectedStation: null, historyRequestId: 0, p1Stages: null, p1Now: null, rainLayer: null, useDates: false, forecastExpanded: false };
const state = { map: null, layers: [], markers: new Map(), hasFit: false, historyChart: null, selectedStation: null, historyRequestId: 0, p1Stages: null, p1Now: null, p1NowAt: null, rainLayer: null, useDates: false, forecastExpanded: false };
const $ = (id) => document.getElementById(id);
// ---- Localisation -----------------------------------------------------
@@ -1328,6 +1328,30 @@
});
}
// Single source of truth for "the river right now".
//
// Two independent feeds carry a P.1 level: the latest measurement, and
// `current_level` on the forecast rows, which is whatever the model saw at
// its `as_of` (forecasts are precomputed hourly, so it lags). Both used to
// write state.p1Now unconditionally and the page showed two different
// "now" values at once — observed 1.66 m in the verdict banner against
// 1.52 m in the outlook directly below it. Harmless at low water, but at
// flood stage two contradictory river levels destroy trust in the warning.
//
// The freshest reading wins; `force` is for the replay and demo hooks,
// which deliberately pin a level that is not the live one.
function setP1Level(value, at, opts) {
const options = opts || {};
if (value == null || Number.isNaN(Number(value))) return;
const stamp = at ? new Date(at).getTime() : null;
const known = Number.isFinite(stamp) ? stamp : null;
if (!options.force && known != null && state.p1NowAt != null && known < state.p1NowAt) {
return; // an older snapshot must not overwrite a newer one
}
state.p1Now = Number(value);
if (known != null) state.p1NowAt = known;
}
// At-a-glance verdict from P.1 level + 24 h forecast. Level thresholds are
// the official Chiang Mai inundation stages (city flooding starts 3.70 m).
function updateFloodVerdict() {
@@ -1368,7 +1392,7 @@
// Flow at the basin anchor (summing sequential mainstem gauges would
// double-count the same water, so no basin-wide total is shown).
const p1 = readings.get('P.1');
if (p1?.water_level != null) state.p1Now = Number(p1.water_level);
setP1Level(p1?.water_level, p1?.timestamp);
updateFloodVerdict();
const p1Flow = p1?.discharge == null ? null : Number(p1.discharge);
$('total-flow').textContent = p1Flow == null ? '—' : p1Flow.toLocaleString(loc(), { maximumFractionDigits: 1 });
@@ -1581,6 +1605,8 @@
function endReplay() {
if (state.replayTimer) window.clearInterval(state.replayTimer);
state.replayTimer = null;
state.p1Now = null; // drop the replayed level so live data re-arbitrates
state.p1NowAt = null;
$('replay-2024').textContent = t('replay.start');
$('replay-clock').style.display = 'none';
setLiveIndicator('live');
@@ -1626,7 +1652,8 @@
$('peak-station').textContent = peakFlow ? t('replay.stress', peakFlow.code) : t('replay.nodischarge');
restyleRiver(readings);
const p1Level = data.stations['P.1'] ? data.stations['P.1'].level[frame] : null;
if (p1Level != null) state.p1Now = Number(p1Level);
// force: replay frames are 2024 timestamps, older than anything live
if (p1Level != null) setP1Level(p1Level, null, { force: true });
restyleFloodZones();
const ts = new Date(data.timestamps[frame]);
// minute included: a lone "17" reads as a year in Thai output
@@ -1736,31 +1763,38 @@
if (!p1rows.length) { card.style.display = 'none'; return; }
const row = p1rows.reduce((best, r) => r.horizon_hours > best.horizon_hours ? r : best);
state.p1Stages = row.stages;
state.p1Now = row.current_level == null ? null : Number(row.current_level);
// Offered, not imposed: only used when it is fresher than the latest
// measurement (see setP1Level).
setP1Level(row.current_level, row.as_of);
// Demo hooks: ?demo_level=4.4 pins a simulated P.1 level; ?demo_rise=1 animates
// the water from the real level up to the 2024 record (5.30 m).
const params = new URLSearchParams(location.search);
const demoLevel = Number(params.get('demo_level'));
const simulated = Number.isFinite(demoLevel) && demoLevel > 0;
if (simulated) state.p1Now = demoLevel;
if (simulated) setP1Level(demoLevel, null, { force: true });
if ((simulated || params.has('demo_rise')) && !state.replayTimer) setLiveIndicator('sim', 'pill.sim');
if (params.has('demo_rise') && !state.demoRiseTimer) {
let level = state.p1Now ?? 2.2;
state.demoRiseTimer = window.setInterval(() => {
level = Math.min(level + 0.05, 5.30);
state.p1Now = level;
setP1Level(level, null, { force: true });
$('p1-peak').textContent = t('outlook.sim.rising', level.toFixed(2), Number(row.current_level).toFixed(2));
restyleFloodZones();
if (level >= 5.30) window.clearInterval(state.demoRiseTimer);
}, 700);
}
restyleFloodZones();
// Show the SAME level the verdict banner shows, and never let the
// predicted peak read below it (the peak was computed from the
// forecast's own, possibly older, current_level).
const shownNow = state.p1Now != null ? state.p1Now : Number(row.current_level);
const shownPeak = Math.max(Number(row.predicted_max_level), shownNow);
$('p1-peak').textContent = simulated
? t('outlook.sim', demoLevel.toFixed(2), Number(row.current_level).toFixed(2))
: t('outlook.now', Number(row.current_level).toFixed(2), row.horizon_hours,
Number(row.predicted_max_level).toFixed(2));
: t('outlook.now', shownNow.toFixed(2), row.horizon_hours, shownPeak.toFixed(2));
renderStageChips(row.stages, row.horizon_hours);
card.style.display = 'block';
updateFloodVerdict(); // banner reflects the arbitrated level, not a stale one
}
async function loadForecasts() {
+22
View File
@@ -175,3 +175,25 @@ def test_dashboard_default_language_respects_browser_order():
assert "langs.some" not in html # the old any-English-wins test
assert "if (code.startsWith('th')) return 'th';" in html
def test_dashboard_shows_one_current_river_level():
"""The verdict banner and the P.1 outlook must not disagree about "now".
Production served 1.66 m in the banner and 1.52 m in the outlook at the
same moment: the banner used the latest measurement, the outlook used the
forecast payload's current_level from an older as_of.
"""
html = DASHBOARD_PATH.read_text(encoding="utf-8")
# One arbitrated writer, freshest-wins
assert "function setP1Level(" in html
assert "state.p1NowAt" in html
# Neither feed may assign the level directly any more
assert "state.p1Now = Number(p1.water_level)" not in html
assert "state.p1Now = row.current_level" not in html
# The outlook renders the arbitrated level, and never a peak below it
assert "const shownNow = state.p1Now != null" in html
assert "Math.max(Number(row.predicted_max_level), shownNow)" in html