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
"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
alerted **2426 hours ahead**. This is the genuinely impressive case: the river
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:
message += f"\n**Details:** {alert.message}\n"
# Add Grafana public dashboard link
grafana_url = "https://metrics.b4l.co.th/public-dashboards/655730aa044f44f49b355d01386018ca"
message += f"\n📈 **View Dashboard:** {grafana_url}"
# Add live dashboard link
dashboard_url = os.getenv("ALERT_DASHBOARD_URL", "https://water.buildfor.life/")
message += f"\n📈 **View Dashboard:** {dashboard_url}"
return self.send_message(message)
+25 -14
View File
@@ -116,12 +116,7 @@
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; } }
.flow-line { animation: riverMove 3s linear infinite; }
.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; }
.flow-line { animation: riverMove 3s linear infinite; } /* per-segment duration set inline from discharge */
@keyframes riverMove { to { stroke-dashoffset: -40; } }
@media (prefers-reduced-motion: reduce) { .flow-line, .flow-marker::before { animation: 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));
}
function riverSpeedClass(q) {
if (q == null) return 'flow-idle';
if (q < 25) return 'flow-slow';
if (q < 100) return 'flow-med';
if (q < 250) return 'flow-fast';
return 'flow-surge';
// Continuous dash-animation period from discharge: calm water drifts (~5.5 s
// per cycle), a flood races (~0.5 s). Applied as inline animation-duration on
// each SVG path so it can be updated live (setStyle cannot change classes).
function flowDuration(q) {
if (q == null) return 5.5;
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) {
@@ -387,12 +388,13 @@
return {
color: q == null ? '#69b7d0' : flowColor(q),
weight: riverWeight(q), opacity: .92, lineCap: 'round',
dashArray: '6 14', className: `flow-line ${riverSpeedClass(q)}`
dashArray: '6 14', className: 'flow-line'
};
}
}).addTo(state.map);
flow.bringToBack();
casing.bringToBack();
applyFlowSpeed(flow, flowBySegment);
state.layers.push(casing, flow);
state.riverNet = riverNetwork;
state.riverStations = stations;
@@ -410,6 +412,7 @@
const q = flows.get(f);
return { color: q == null ? '#69b7d0' : flowColor(q), weight: riverWeight(q), opacity: .92 };
});
applyFlowSpeed(state.riverFlow, flows);
}
function updateStationMarker(code, discharge) {
@@ -820,13 +823,21 @@
const frame = Math.min(i, n - 1);
const readings = new Map();
let totalFlow = 0;
let peakFlow = null;
Object.entries(data.stations).forEach(([code, series]) => {
const level = series.level[frame];
const discharge = series.discharge[frame];
readings.set(code, { water_level: level, 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);
const p1Level = data.stations['P.1'] ? data.stations['P.1'].level[frame] : null;
if (p1Level != null) state.p1Now = Number(p1Level);