diff --git a/docs/FLOOD_FORECASTING.md b/docs/FLOOD_FORECASTING.md index 9ae4ebc..3b91f5f 100644 --- a/docs/FLOOD_FORECASTING.md +++ b/docs/FLOOD_FORECASTING.md @@ -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, 22โ€“28 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 **24โ€“26 hours ahead**. This is the genuinely impressive case: the river was in normal state, and the alert came from upstream routing alone. diff --git a/docs/img/backtest-2024-p1-detail.png b/docs/img/backtest-2024-p1-detail.png new file mode 100644 index 0000000..8b87f97 Binary files /dev/null and b/docs/img/backtest-2024-p1-detail.png differ diff --git a/src/alerting.py b/src/alerting.py index 3b98cef..04bb789 100644 --- a/src/alerting.py +++ b/src/alerting.py @@ -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) diff --git a/src/static/dashboard.html b/src/static/dashboard.html index d2401a2..faedc41 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -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);