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:
2026-08-10 15:35:00 +07:00
parent 29f4b5818d
commit e4d5d274f0
6 changed files with 162 additions and 32 deletions
+15 -7
View File
@@ -78,7 +78,9 @@ def test_no_future_leakage():
def test_label_alignment():
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(
{
"timestamp": idx,
@@ -90,14 +92,14 @@ def test_label_alignment():
grid = features.make_hourly_grid(df)
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
# includes t=5 for t=0 .. t=4 (inclusive), so exceed_warn_6 should be 1
# for t=0..4 and not (necessarily) for later rows in this hand-built series.
# Level crosses the warning threshold at t=5. A 6h forward window (t, t+6]
# first includes t=5 for t=0 .. t=4 (inclusive), so exceed_warn_6 should be
# 1 for t=0..4 and not (necessarily) for later rows in this hand-built series.
for t in range(5):
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.
assert labels["max_level_6"].iloc[0] == pytest.approx(3.5)
# max_level_6 at t=0 covers hours 1..6 -> includes the peak.
assert labels["max_level_6"].iloc[0] == pytest.approx(peak)
def test_label_coverage_gate():
@@ -169,12 +171,18 @@ _FORECAST_KEYS = {
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_danger"] <= 1.0
assert row["p_danger"] <= row["p_warning"]
assert row["predicted_max_level"] >= row["current_level"]
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):