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:
@@ -328,15 +328,19 @@ river has spent 57 hours above 4.5 m historically, 0.144% of all hours — but n
|
|||||||
out-of-sample number backs it. Treat `p_danger` at P.1 as indicative, not
|
out-of-sample number backs it. Treat `p_danger` at P.1 as indicative, not
|
||||||
validated.
|
validated.
|
||||||
|
|
||||||
**Thresholds are P.1-datum-specific but applied network-wide.** `THRESHOLDS`
|
**Thresholds are per-station as of 2026-08-10.** `THRESHOLDS` now carries
|
||||||
carries a single default of (3.0, 4.5) m for every station, and gauge datums
|
calibrated (warning, danger) pairs for all 16 stations, derived from the DB's
|
||||||
differ enormously: P.5 spends 27.8% of all recorded hours above 3.0 m, P.103
|
`discharge_percent` (RID % of channel capacity): warning = median level at
|
||||||
11.1%, P.81 8.2%, P.77 7.6% — versus 0.95% at P.1. On those stations "warning" is
|
75–85% capacity, danger = median level at 95–105%. P.1 instead uses the official
|
||||||
close to a normal wet-season level, so **P.103 currently over-alerts on the
|
Chiang Mai inundation map (`P1_FLOOD_STAGES`): warning 3.70 m (city flooding
|
||||||
default thresholds**. The models are calibrated to the labels they were given, so
|
begins, stage 1) and danger 4.20 m (stage 5). The prior single default of
|
||||||
the metrics are internally valid; it is the operational meaning of the label that
|
(3.0, 4.5) m made P.103 badly over-alert (its bank-full level is ~6.75 m) and
|
||||||
is wrong. Per-station calibration against RID's published flood stages is the
|
P.67 under-alert (overflow at ~2.9 m, 1.6 m below the old danger line).
|
||||||
pending follow-up — it changes only `features.THRESHOLDS`, plus a retrain.
|
**A retrain is required after any threshold change** — classifier labels depend
|
||||||
|
on them; until then, model rows report the thresholds baked into their bundle.
|
||||||
|
P.1 additionally reports `stages`: exceedance probability for each of the seven
|
||||||
|
official inundation stages (3.70–4.60 m), computed from the regression head and
|
||||||
|
its calibration sigma, so they need no retrain and no per-stage classifiers.
|
||||||
|
|
||||||
## 6. Deployment
|
## 6. Deployment
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,48 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Per-station (warning, danger) level thresholds in meters. "*" is the default
|
# Per-station (warning, danger) level thresholds in meters. "*" is the default
|
||||||
# applied to any station without an explicit override.
|
# applied to any station without an explicit override.
|
||||||
|
# Per-station (warning, danger) levels in metres on each gauge's own datum.
|
||||||
|
# Calibrated 2026-08-10 from the DB's discharge_percent (RID % of channel
|
||||||
|
# capacity): warning = median level at 75-85% capacity, danger = median level
|
||||||
|
# at 95-105%. P.1 instead uses the official Chiang Mai inundation map keyed to
|
||||||
|
# the P.1 gauge: city flooding begins at 3.70 m (stage 1) and reaches most
|
||||||
|
# districts by 4.20 m (stage 5) — see P1_FLOOD_STAGES.
|
||||||
THRESHOLDS: Dict[str, Tuple[float, float]] = {
|
THRESHOLDS: Dict[str, Tuple[float, float]] = {
|
||||||
"*": (3.0, 4.5),
|
"*": (3.0, 4.5),
|
||||||
|
"P.1": (3.70, 4.20),
|
||||||
|
"P.103": (5.95, 6.75),
|
||||||
|
"P.20": (2.35, 2.80),
|
||||||
|
"P.21": (3.20, 3.60),
|
||||||
|
"P.4A": (3.40, 3.90),
|
||||||
|
"P.5": (4.55, 4.95),
|
||||||
|
"P.67": (2.45, 2.90),
|
||||||
|
"P.75": (2.75, 3.50),
|
||||||
|
"P.76": (5.35, 5.45),
|
||||||
|
"P.77": (2.85, 3.35),
|
||||||
|
"P.81": (5.15, 6.30),
|
||||||
|
"P.82": (3.40, 3.80),
|
||||||
|
"P.84": (3.45, 3.90),
|
||||||
|
"P.85": (2.90, 3.35),
|
||||||
|
"P.87": (3.75, 4.05),
|
||||||
|
"P.92": (2.95, 3.60),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Official Chiang Mai flood-onset stages at the P.1 gauge (Nawarat Bridge),
|
||||||
|
# from the municipal inundation map (พื้นที่ท่วมตัวเมืองเชียงใหม่, events of
|
||||||
|
# 2548/2554/2565 BE): gauge level in m, RID discharge in m³/s. Each stage
|
||||||
|
# floods progressively more city zones.
|
||||||
|
P1_FLOOD_STAGES: List[Dict[str, float]] = [
|
||||||
|
{"stage": 1, "level": 3.70, "discharge_cms": 405},
|
||||||
|
{"stage": 2, "level": 3.90, "discharge_cms": 438},
|
||||||
|
{"stage": 3, "level": 4.00, "discharge_cms": 458},
|
||||||
|
{"stage": 4, "level": 4.10, "discharge_cms": 478},
|
||||||
|
{"stage": 5, "level": 4.20, "discharge_cms": 493},
|
||||||
|
{"stage": 6, "level": 4.30, "discharge_cms": 508},
|
||||||
|
{"stage": 7, "level": 4.60, "discharge_cms": 558},
|
||||||
|
]
|
||||||
|
|
||||||
|
FLOOD_STAGES: Dict[str, List[Dict[str, float]]] = {"P.1": P1_FLOOD_STAGES}
|
||||||
|
|
||||||
MONSOON_MONTHS = {6, 7, 8, 9, 10}
|
MONSOON_MONTHS = {6, 7, 8, 9, 10}
|
||||||
FFILL_LIMIT_H = 3
|
FFILL_LIMIT_H = 3
|
||||||
MIN_WINDOW_COVERAGE = 0.5
|
MIN_WINDOW_COVERAGE = 0.5
|
||||||
|
|||||||
+17
-3
@@ -154,8 +154,7 @@ def _model_forecast(
|
|||||||
p_warning = _clip_probability(p_warning)
|
p_warning = _clip_probability(p_warning)
|
||||||
p_danger = min(_clip_probability(p_danger), p_warning)
|
p_danger = min(_clip_probability(p_danger), p_warning)
|
||||||
|
|
||||||
results.append(
|
row = {
|
||||||
{
|
|
||||||
"station_code": station_code,
|
"station_code": station_code,
|
||||||
"horizon_hours": horizon_h,
|
"horizon_hours": horizon_h,
|
||||||
"p_warning": p_warning,
|
"p_warning": p_warning,
|
||||||
@@ -169,7 +168,22 @@ def _model_forecast(
|
|||||||
"threshold_warning": warn_thr,
|
"threshold_warning": warn_thr,
|
||||||
"threshold_danger": danger_thr,
|
"threshold_danger": danger_thr,
|
||||||
}
|
}
|
||||||
)
|
stages = features.FLOOD_STAGES.get(station_code)
|
||||||
|
if stages:
|
||||||
|
# Exceedance probability per official inundation stage, from the
|
||||||
|
# regression head and its validation-residual sigma. These are
|
||||||
|
# threshold-agnostic, so no retraining is needed to serve them.
|
||||||
|
row["stages"] = [
|
||||||
|
{
|
||||||
|
"stage": s["stage"],
|
||||||
|
"level": s["level"],
|
||||||
|
"p_exceed": _clip_probability(
|
||||||
|
_sigmoid_probability(predicted_max, s["level"], sigma_h)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for s in stages
|
||||||
|
]
|
||||||
|
results.append(row)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,14 @@
|
|||||||
.risk-chips { display: flex; gap: 6px; }
|
.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 { 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; }
|
.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); }
|
.leaflet-popup-content-wrapper { border-radius: 14px; box-shadow: 0 12px 35px rgba(14,45,54,.2); }
|
||||||
.popup { min-width: 190px; }
|
.popup { min-width: 190px; }
|
||||||
.popup-code { font-size: .7rem; color: var(--river); font-weight: 850; text-transform: uppercase; letter-spacing: .08em; }
|
.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>
|
<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>
|
<p id="forecast-status" class="subtitle">Model probability of reaching warning / danger levels within 6, 12 and 24 hours</p></div>
|
||||||
</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>
|
<div class="forecast-grid" id="forecast-grid"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -589,6 +606,53 @@
|
|||||||
return '#1e8b60';
|
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() {
|
async function loadForecasts() {
|
||||||
const card = $('forecast-card');
|
const card = $('forecast-card');
|
||||||
try {
|
try {
|
||||||
@@ -601,6 +665,7 @@
|
|||||||
if (!byStation.has(row.station_code)) byStation.set(row.station_code, []);
|
if (!byStation.has(row.station_code)) byStation.set(row.station_code, []);
|
||||||
byStation.get(row.station_code).push(row);
|
byStation.get(row.station_code).push(row);
|
||||||
});
|
});
|
||||||
|
renderP1Outlook(rows);
|
||||||
const grid = $('forecast-grid');
|
const grid = $('forecast-grid');
|
||||||
grid.replaceChildren();
|
grid.replaceChildren();
|
||||||
const stations = [...byStation.entries()].map(([code, list]) => ({
|
const stations = [...byStation.entries()].map(([code, list]) => ({
|
||||||
@@ -634,6 +699,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$('refresh-button').addEventListener('click', loadDashboard);
|
$('refresh-button').addEventListener('click', loadDashboard);
|
||||||
|
$('zones-toggle').addEventListener('click', toggleFloodZones);
|
||||||
$('history-range').addEventListener('change', () => { if (state.selectedStation) loadHistory(state.selectedStation); });
|
$('history-range').addEventListener('change', () => { if (state.selectedStation) loadHistory(state.selectedStation); });
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
window.setInterval(loadDashboard, 5 * 60 * 1000);
|
window.setInterval(loadDashboard, 5 * 60 * 1000);
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
@@ -78,7 +78,9 @@ def test_no_future_leakage():
|
|||||||
|
|
||||||
def test_label_alignment():
|
def test_label_alignment():
|
||||||
idx = pd.date_range("2020-01-01", periods=12, freq="h")
|
idx = pd.date_range("2020-01-01", periods=12, freq="h")
|
||||||
levels = [1.0, 1.0, 1.0, 1.0, 1.0, 3.5, 3.5, 1.0, 1.0, 1.0, 1.0, 1.0]
|
warn_thr, _danger_thr = features.get_thresholds("P.1")
|
||||||
|
peak = warn_thr + 0.3
|
||||||
|
levels = [1.0, 1.0, 1.0, 1.0, 1.0, peak, peak, 1.0, 1.0, 1.0, 1.0, 1.0]
|
||||||
df = pd.DataFrame(
|
df = pd.DataFrame(
|
||||||
{
|
{
|
||||||
"timestamp": idx,
|
"timestamp": idx,
|
||||||
@@ -90,14 +92,14 @@ def test_label_alignment():
|
|||||||
grid = features.make_hourly_grid(df)
|
grid = features.make_hourly_grid(df)
|
||||||
labels = features.build_labels(grid, "P.1", horizons=(6,))
|
labels = features.build_labels(grid, "P.1", horizons=(6,))
|
||||||
|
|
||||||
# Level crosses warn (3.0) at t=5. A 6h forward window (t, t+6] first
|
# Level crosses the warning threshold at t=5. A 6h forward window (t, t+6]
|
||||||
# includes t=5 for t=0 .. t=4 (inclusive), so exceed_warn_6 should be 1
|
# first includes t=5 for t=0 .. t=4 (inclusive), so exceed_warn_6 should be
|
||||||
# for t=0..4 and not (necessarily) for later rows in this hand-built series.
|
# 1 for t=0..4 and not (necessarily) for later rows in this hand-built series.
|
||||||
for t in range(5):
|
for t in range(5):
|
||||||
assert labels["exceed_warn_6"].iloc[t] == 1.0, f"t={t} expected warn exceedance"
|
assert labels["exceed_warn_6"].iloc[t] == 1.0, f"t={t} expected warn exceedance"
|
||||||
|
|
||||||
# max_level_6 at t=0 covers hours 1..6 -> includes the 3.5 peak.
|
# max_level_6 at t=0 covers hours 1..6 -> includes the peak.
|
||||||
assert labels["max_level_6"].iloc[0] == pytest.approx(3.5)
|
assert labels["max_level_6"].iloc[0] == pytest.approx(peak)
|
||||||
|
|
||||||
|
|
||||||
def test_label_coverage_gate():
|
def test_label_coverage_gate():
|
||||||
@@ -169,12 +171,18 @@ _FORECAST_KEYS = {
|
|||||||
|
|
||||||
|
|
||||||
def _assert_valid_forecast_row(row: dict) -> None:
|
def _assert_valid_forecast_row(row: dict) -> None:
|
||||||
assert set(row.keys()) == _FORECAST_KEYS
|
# "stages" is optional: model rows for stations in features.FLOOD_STAGES carry
|
||||||
|
# per-inundation-stage exceedance probabilities (currently P.1 only).
|
||||||
|
assert _FORECAST_KEYS <= set(row.keys())
|
||||||
|
assert set(row.keys()) - _FORECAST_KEYS <= {"stages"}
|
||||||
assert 0.0 <= row["p_warning"] <= 1.0
|
assert 0.0 <= row["p_warning"] <= 1.0
|
||||||
assert 0.0 <= row["p_danger"] <= 1.0
|
assert 0.0 <= row["p_danger"] <= 1.0
|
||||||
assert row["p_danger"] <= row["p_warning"]
|
assert row["p_danger"] <= row["p_warning"]
|
||||||
assert row["predicted_max_level"] >= row["current_level"]
|
assert row["predicted_max_level"] >= row["current_level"]
|
||||||
assert row["source"] in ("model", "heuristic")
|
assert row["source"] in ("model", "heuristic")
|
||||||
|
for stage in row.get("stages", []):
|
||||||
|
assert 0.0 <= stage["p_exceed"] <= 1.0
|
||||||
|
assert stage["level"] > 0
|
||||||
|
|
||||||
|
|
||||||
def test_train_smoke_and_roundtrip(tmp_path):
|
def test_train_smoke_and_roundtrip(tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user