feat: per-station flood thresholds and Chiang Mai inundation stages for P.1
Replace the network-wide (3.0, 4.5) m thresholds with per-station values calibrated from the DB's discharge_percent (RID % of channel capacity): warning = median level at 75-85% capacity, danger = median at 95-105%. Fixes P.103 over-alerting (bank-full ~6.75 m, not 4.5) and P.67 under-alerting (overflow ~2.9 m). Requires a retrain to take effect in the classifier heads. P.1 uses the official Chiang Mai municipal inundation map instead: warning 3.70 m (stage 1, city flooding begins), danger 4.20 m (stage 5), with the full 7-stage table (3.70-4.60 m + discharge) in features.P1_FLOOD_STAGES. Forecast rows for P.1 now include per-stage exceedance probabilities computed from the regression head + calibration sigma - available immediately without retraining. Dashboard: "Chiang Mai city flood outlook" block above the forecast grid (predicted peak + 7 stage-probability chips) and a toggleable georeferenced overlay of the official flood-zone map (static/flood-zones-p1.jpg, bounds tunable in FLOOD_ZONE_BOUNDS).
This commit is contained in:
@@ -130,6 +130,14 @@
|
||||
.risk-chips { display: flex; gap: 6px; }
|
||||
.risk-chip { flex: 1; text-align: center; border-radius: 8px; padding: 5px 4px; font-size: .64rem; font-weight: 800; color: white; }
|
||||
.risk-chip span { display: block; font-weight: 650; font-size: .58rem; opacity: .85; }
|
||||
.p1-outlook { border: 1px solid var(--border); border-left: 4px solid var(--river); border-radius: 12px; padding: 12px 14px; margin-top: 14px; background: #f7fbfa; }
|
||||
.p1-outlook-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.p1-outlook-head strong { font-size: .88rem; }
|
||||
.p1-peak { color: var(--muted); font-size: .76rem; }
|
||||
.stage-strip { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.stage-chip { min-width: 74px; text-align: center; border-radius: 9px; padding: 6px 8px; font-size: .7rem; font-weight: 800; color: white; }
|
||||
.stage-chip small { display: block; font-weight: 650; font-size: .6rem; opacity: .88; }
|
||||
.zones-button { font-size: .72rem; padding: 7px 11px; }
|
||||
.leaflet-popup-content-wrapper { border-radius: 14px; box-shadow: 0 12px 35px rgba(14,45,54,.2); }
|
||||
.popup { min-width: 190px; }
|
||||
.popup-code { font-size: .7rem; color: var(--river); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
|
||||
@@ -213,6 +221,15 @@
|
||||
<div><h2 style="margin:0;font-size:1rem">Flood risk outlook <span style="color:var(--muted);font-weight:650;font-size:.72rem">· experimental</span></h2>
|
||||
<p id="forecast-status" class="subtitle">Model probability of reaching warning / danger levels within 6, 12 and 24 hours</p></div>
|
||||
</div>
|
||||
<div class="p1-outlook" id="p1-outlook" style="display:none">
|
||||
<div class="p1-outlook-head">
|
||||
<div><strong>Chiang Mai city flood outlook · P.1 Nawarat Bridge</strong>
|
||||
<div class="p1-peak" id="p1-peak"></div></div>
|
||||
<button type="button" class="zones-button" id="zones-toggle">Show flood zones on map</button>
|
||||
</div>
|
||||
<div class="stage-strip" id="p1-stages"></div>
|
||||
<div class="p1-peak" style="margin-top:7px">Chance the river reaches each official inundation stage within 24 h — city flooding begins at stage 1 (3.70 m); each stage floods additional districts.</div>
|
||||
</div>
|
||||
<div class="forecast-grid" id="forecast-grid"></div>
|
||||
</section>
|
||||
|
||||
@@ -589,6 +606,53 @@
|
||||
return '#1e8b60';
|
||||
}
|
||||
|
||||
// Official Chiang Mai inundation map (keyed to P.1), georeferenced approximately.
|
||||
// Tune bounds if the river course in the scan drifts from the basemap.
|
||||
const FLOOD_ZONE_IMAGE = '/static/flood-zones-p1.jpg';
|
||||
const FLOOD_ZONE_BOUNDS = [[18.680, 98.925], [18.855, 99.105]];
|
||||
let zoneOverlay = null;
|
||||
|
||||
function toggleFloodZones() {
|
||||
if (!state.map) return;
|
||||
const button = $('zones-toggle');
|
||||
if (zoneOverlay) {
|
||||
state.map.removeLayer(zoneOverlay);
|
||||
zoneOverlay = null;
|
||||
if (button) button.textContent = 'Show flood zones on map';
|
||||
} else {
|
||||
zoneOverlay = L.imageOverlay(FLOOD_ZONE_IMAGE, FLOOD_ZONE_BOUNDS, { opacity: .62, interactive: false }).addTo(state.map);
|
||||
state.map.flyToBounds(FLOOD_ZONE_BOUNDS, { maxZoom: 13, duration: .8 });
|
||||
if (button) button.textContent = 'Hide flood zones';
|
||||
document.getElementById('station-map').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function stageColor(p) {
|
||||
if (p >= .5) return '#cc4b37';
|
||||
if (p >= .2) return '#d99018';
|
||||
if (p >= .05) return '#0a91b9';
|
||||
return '#1e8b60';
|
||||
}
|
||||
|
||||
function renderP1Outlook(rows) {
|
||||
const card = $('p1-outlook');
|
||||
const p1rows = rows.filter((r) => r.station_code === 'P.1' && Array.isArray(r.stages));
|
||||
if (!p1rows.length) { card.style.display = 'none'; return; }
|
||||
const row = p1rows.reduce((best, r) => r.horizon_hours > best.horizon_hours ? r : best);
|
||||
$('p1-peak').textContent = `Now ${Number(row.current_level).toFixed(2)} m · predicted peak next ${row.horizon_hours} h: ${Number(row.predicted_max_level).toFixed(2)} m`;
|
||||
const strip = $('p1-stages');
|
||||
strip.replaceChildren();
|
||||
row.stages.forEach((s) => {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'stage-chip';
|
||||
chip.style.background = stageColor(s.p_exceed);
|
||||
chip.title = `Stage ${s.stage}: river at ${s.level.toFixed(2)} m — ${Math.round(s.p_exceed * 100)}% within ${row.horizon_hours} h`;
|
||||
chip.innerHTML = `${Math.round(s.p_exceed * 100)}%<small>S${s.stage} · ${s.level.toFixed(2)} m</small>`;
|
||||
strip.appendChild(chip);
|
||||
});
|
||||
card.style.display = 'block';
|
||||
}
|
||||
|
||||
async function loadForecasts() {
|
||||
const card = $('forecast-card');
|
||||
try {
|
||||
@@ -601,6 +665,7 @@
|
||||
if (!byStation.has(row.station_code)) byStation.set(row.station_code, []);
|
||||
byStation.get(row.station_code).push(row);
|
||||
});
|
||||
renderP1Outlook(rows);
|
||||
const grid = $('forecast-grid');
|
||||
grid.replaceChildren();
|
||||
const stations = [...byStation.entries()].map(([code, list]) => ({
|
||||
@@ -634,6 +699,7 @@
|
||||
}
|
||||
|
||||
$('refresh-button').addEventListener('click', loadDashboard);
|
||||
$('zones-toggle').addEventListener('click', toggleFloodZones);
|
||||
$('history-range').addEventListener('change', () => { if (state.selectedStation) loadHistory(state.selectedStation); });
|
||||
loadDashboard();
|
||||
window.setInterval(loadDashboard, 5 * 60 * 1000);
|
||||
|
||||
Reference in New Issue
Block a user