diff --git a/docs/FLOOD_FORECASTING.md b/docs/FLOOD_FORECASTING.md index d0838f5..3774614 100644 --- a/docs/FLOOD_FORECASTING.md +++ b/docs/FLOOD_FORECASTING.md @@ -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 validated. -**Thresholds are P.1-datum-specific but applied network-wide.** `THRESHOLDS` -carries a single default of (3.0, 4.5) m for every station, and gauge datums -differ enormously: P.5 spends 27.8% of all recorded hours above 3.0 m, P.103 -11.1%, P.81 8.2%, P.77 7.6% — versus 0.95% at P.1. On those stations "warning" is -close to a normal wet-season level, so **P.103 currently over-alerts on the -default thresholds**. The models are calibrated to the labels they were given, so -the metrics are internally valid; it is the operational meaning of the label that -is wrong. Per-station calibration against RID's published flood stages is the -pending follow-up — it changes only `features.THRESHOLDS`, plus a retrain. +**Thresholds are per-station as of 2026-08-10.** `THRESHOLDS` now carries +calibrated (warning, danger) pairs for all 16 stations, derived 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 (`P1_FLOOD_STAGES`): warning 3.70 m (city flooding +begins, stage 1) and danger 4.20 m (stage 5). The prior single default of +(3.0, 4.5) m made P.103 badly over-alert (its bank-full level is ~6.75 m) and +P.67 under-alert (overflow at ~2.9 m, 1.6 m below the old danger line). +**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 diff --git a/src/ml/features.py b/src/ml/features.py index 66ab020..45bef6d 100644 --- a/src/ml/features.py +++ b/src/ml/features.py @@ -20,10 +20,48 @@ logger = logging.getLogger(__name__) # Per-station (warning, danger) level thresholds in meters. "*" is the default # 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]] = { "*": (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} FFILL_LIMIT_H = 3 MIN_WINDOW_COVERAGE = 0.5 diff --git a/src/ml/predict.py b/src/ml/predict.py index f4f1cd6..a0d5602 100644 --- a/src/ml/predict.py +++ b/src/ml/predict.py @@ -154,22 +154,36 @@ def _model_forecast( p_warning = _clip_probability(p_warning) p_danger = min(_clip_probability(p_danger), p_warning) - results.append( - { - "station_code": station_code, - "horizon_hours": horizon_h, - "p_warning": p_warning, - "p_danger": p_danger, - "predicted_max_level": predicted_max, - "current_level": current_level, - "as_of": as_of.isoformat(), - "model_version": bundle["model_version"], - "trained_at": bundle["trained_at"], - "source": "model", - "threshold_warning": warn_thr, - "threshold_danger": danger_thr, - } - ) + row = { + "station_code": station_code, + "horizon_hours": horizon_h, + "p_warning": p_warning, + "p_danger": p_danger, + "predicted_max_level": predicted_max, + "current_level": current_level, + "as_of": as_of.isoformat(), + "model_version": bundle["model_version"], + "trained_at": bundle["trained_at"], + "source": "model", + "threshold_warning": warn_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 diff --git a/src/static/dashboard.html b/src/static/dashboard.html index ee00b64..f9dce15 100644 --- a/src/static/dashboard.html +++ b/src/static/dashboard.html @@ -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 @@
Model probability of reaching warning / danger levels within 6, 12 and 24 hours