feat: discharge-driven river animation speed, replay stat tiles, hourly doc render
CI/CD Pipeline - Northern Thailand Ping River Monitor / Test Suite (3.11) (push) Failing after 24s
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 15s
Documentation / Validate Documentation (push) Failing after 10s
Documentation / Generate API Documentation (push) Successful in 11s
Documentation / Build Sphinx Documentation (push) Successful in 20s
CI/CD Pipeline - Northern Thailand Ping River Monitor / Cleanup (push) Successful in 1s
Documentation / Documentation Summary (push) Successful in 3s

- river dash animation now flows at a speed continuously derived from
  each segment's discharge (period 260/(Q+45) s, clamped 0.5-5.5 s) via
  inline per-path animation-duration, so it updates live and per-frame
  during the replay (CSS speed classes removed - setStyle cannot change
  classes)
- the replay drives the Combined discharge and Strongest flow stat
  tiles each frame (marked '2024 replay'), restored on finish
- docs: hour-by-hour detection detail render (22-28 Sep 2024) showing
  the model alert at 24 Sep 01:00, flooding at 25 Sep 01:00, and the
  24 h warning between them; embedded with commentary
- Matrix alerts now link to https://water.buildfor.life/ (override via
  ALERT_DASHBOARD_URL), replacing the Grafana public dashboard link
This commit is contained in:
2026-08-10 18:29:01 +07:00
parent 6112c681a7
commit f0183faa62
4 changed files with 38 additions and 17 deletions
+10
View File
@@ -306,6 +306,16 @@ honestly: the predicted peak tops out ~0.4 m short of the actual 5.30 m record
noisier on the recession limbs. The same model track drives the dashboard's noisier on the recession limbs. The same model track drives the dashboard's
"Replay Oct 2024 flood" feature, so this chart can be watched live on the map. "Replay Oct 2024 flood" feature, so this chart can be watched live on the map.
![Hour-by-hour detail of the detection window, 2228 September
2024](img/backtest-2024-p1-detail.png)
The hour-by-hour detail of the detection window shows the sequence exactly: the
predicted 24 h peak (amber) starts pulling away from the observed level late on
23 September as upstream gauges rise, the warning probability snaps from ~0 to
1.0 at **01:00 on 24 September**, and the river crosses 3.70 m at **01:00 on
25 September** — a clean 24-hour warning, delivered while the river in town
still looked normal at 2.8 m.
- **25 September cold start.** P.1's first warning crossing of the episode was - **25 September cold start.** P.1's first warning crossing of the episode was
alerted **2426 hours ahead**. This is the genuinely impressive case: the river alerted **2426 hours ahead**. This is the genuinely impressive case: the river
was in normal state, and the alert came from upstream routing alone. was in normal state, and the alert came from upstream routing alone.
Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+3 -3
View File
@@ -155,9 +155,9 @@ class MatrixNotifier:
if alert.message: if alert.message:
message += f"\n**Details:** {alert.message}\n" message += f"\n**Details:** {alert.message}\n"
# Add Grafana public dashboard link # Add live dashboard link
grafana_url = "https://metrics.b4l.co.th/public-dashboards/655730aa044f44f49b355d01386018ca" dashboard_url = os.getenv("ALERT_DASHBOARD_URL", "https://water.buildfor.life/")
message += f"\n📈 **View Dashboard:** {grafana_url}" message += f"\n📈 **View Dashboard:** {dashboard_url}"
return self.send_message(message) return self.send_message(message)
+25 -14
View File
@@ -116,12 +116,7 @@
opacity: .36; animation: pulse 2.2s ease-out infinite; opacity: .36; animation: pulse 2.2s ease-out infinite;
} }
@keyframes pulse { 0% { transform: scale(.72); opacity: .55; } 75%,100% { transform: scale(1.35); opacity: 0; } } @keyframes pulse { 0% { transform: scale(.72); opacity: .55; } 75%,100% { transform: scale(1.35); opacity: 0; } }
.flow-line { animation: riverMove 3s linear infinite; } .flow-line { animation: riverMove 3s linear infinite; } /* per-segment duration set inline from discharge */
.flow-idle { animation-duration: 5.5s; }
.flow-slow { animation-duration: 3s; }
.flow-med { animation-duration: 1.9s; }
.flow-fast { animation-duration: 1.15s; }
.flow-surge { animation-duration: .7s; }
@keyframes riverMove { to { stroke-dashoffset: -40; } } @keyframes riverMove { to { stroke-dashoffset: -40; } }
@media (prefers-reduced-motion: reduce) { .flow-line, .flow-marker::before { animation: none; } } @media (prefers-reduced-motion: reduce) { .flow-line, .flow-marker::before { animation: none; } }
.line-swatch { width: 24px; height: 4px; border-radius: 2px; flex: none; } .line-swatch { width: 24px; height: 4px; border-radius: 2px; flex: none; }
@@ -353,12 +348,18 @@
return q == null ? 2.5 : Math.max(3, Math.min(9, 2.5 + Math.sqrt(Math.max(0, q)) * .38)); return q == null ? 2.5 : Math.max(3, Math.min(9, 2.5 + Math.sqrt(Math.max(0, q)) * .38));
} }
function riverSpeedClass(q) { // Continuous dash-animation period from discharge: calm water drifts (~5.5 s
if (q == null) return 'flow-idle'; // per cycle), a flood races (~0.5 s). Applied as inline animation-duration on
if (q < 25) return 'flow-slow'; // each SVG path so it can be updated live (setStyle cannot change classes).
if (q < 100) return 'flow-med'; function flowDuration(q) {
if (q < 250) return 'flow-fast'; if (q == null) return 5.5;
return 'flow-surge'; return Math.max(.5, Math.min(5.5, 260 / (Number(q) + 45)));
}
function applyFlowSpeed(layerGroup, flows) {
layerGroup.eachLayer((layer) => {
if (layer._path) layer._path.style.animationDuration = `${flowDuration(flows.get(layer.feature))}s`;
});
} }
function riverGauges(stations, readings) { function riverGauges(stations, readings) {
@@ -387,12 +388,13 @@
return { return {
color: q == null ? '#69b7d0' : flowColor(q), color: q == null ? '#69b7d0' : flowColor(q),
weight: riverWeight(q), opacity: .92, lineCap: 'round', weight: riverWeight(q), opacity: .92, lineCap: 'round',
dashArray: '6 14', className: `flow-line ${riverSpeedClass(q)}` dashArray: '6 14', className: 'flow-line'
}; };
} }
}).addTo(state.map); }).addTo(state.map);
flow.bringToBack(); flow.bringToBack();
casing.bringToBack(); casing.bringToBack();
applyFlowSpeed(flow, flowBySegment);
state.layers.push(casing, flow); state.layers.push(casing, flow);
state.riverNet = riverNetwork; state.riverNet = riverNetwork;
state.riverStations = stations; state.riverStations = stations;
@@ -410,6 +412,7 @@
const q = flows.get(f); const q = flows.get(f);
return { color: q == null ? '#69b7d0' : flowColor(q), weight: riverWeight(q), opacity: .92 }; return { color: q == null ? '#69b7d0' : flowColor(q), weight: riverWeight(q), opacity: .92 };
}); });
applyFlowSpeed(state.riverFlow, flows);
} }
function updateStationMarker(code, discharge) { function updateStationMarker(code, discharge) {
@@ -820,13 +823,21 @@
const frame = Math.min(i, n - 1); const frame = Math.min(i, n - 1);
const readings = new Map(); const readings = new Map();
let totalFlow = 0; let totalFlow = 0;
let peakFlow = null;
Object.entries(data.stations).forEach(([code, series]) => { Object.entries(data.stations).forEach(([code, series]) => {
const level = series.level[frame]; const level = series.level[frame];
const discharge = series.discharge[frame]; const discharge = series.discharge[frame];
readings.set(code, { water_level: level, discharge }); readings.set(code, { water_level: level, discharge });
updateStationMarker(code, discharge); updateStationMarker(code, discharge);
if (discharge != null) totalFlow += discharge; if (discharge != null) {
totalFlow += discharge;
if (!peakFlow || discharge > peakFlow.value) peakFlow = { code, value: discharge };
}
}); });
// stat tiles follow the replay (restored by loadDashboard on finish)
$('total-flow').textContent = totalFlow.toLocaleString(undefined, { maximumFractionDigits: 1 });
$('peak-flow').textContent = peakFlow ? peakFlow.value.toFixed(1) : '—';
$('peak-station').textContent = peakFlow ? `${peakFlow.code} · m³/s · 2024 replay` : 'No discharge reported';
restyleRiver(readings); restyleRiver(readings);
const p1Level = data.stations['P.1'] ? data.stations['P.1'].level[frame] : null; const p1Level = data.stations['P.1'] ? data.stations['P.1'].level[frame] : null;
if (p1Level != null) state.p1Now = Number(p1Level); if (p1Level != null) state.p1Now = Number(p1Level);