From 32399f189979c318c765ad69bcc4de2a56cc4625 Mon Sep 17 00:00:00 2001 From: grabowski Date: Sat, 12 Sep 2026 00:34:59 +0200 Subject: [PATCH] feat: recalibrate P.77/P.75 thresholds; capacity guard on alerts The first ntfy cycle announced "Warning level at P.77" at 3.02 m. That gauge's 2.85 m threshold sat below its own dry-season baseline (2.6-2.7 m at 8-14 % channel capacity): P.77 had been "above warning" for 761 of the last 2 146 hours, at 22 % capacity. Across 2018-2024, 75-85 % capacity reads 3.35-4.57 m and 95-105 % reads 4.27-5.08 m; set 4.30 / 4.90. P.75 moved 2.75/3.50 -> 3.20/3.65 on the same evidence (2024: 3.45 / 3.72). The predictor already handles changed thresholds (regression-derived probabilities until the Oct 1 retrain). Second line of defence in notify.py: a clear->alert transition is only announced when RID's discharge_percent for the reading is >= 60 %, so a re-rated or datum-shifted gauge cannot page subscribers again. P.1 is exempt (its stages come from the inundation map, not capacity); readings without a capacity figure fall back to level only; the all-clear edge is never blocked. 3 tests. --- src/ml/features.py | 12 ++++++-- src/notify.py | 25 +++++++++++++++++ tests/test_notify.py | 67 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/ml/features.py b/src/ml/features.py index 65d04a8..3b9dd3b 100644 --- a/src/ml/features.py +++ b/src/ml/features.py @@ -37,9 +37,17 @@ THRESHOLDS: Dict[str, Tuple[float, float]] = { "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.75: 2024 (the only year with a full flood record, 191% capacity peak) + # puts 75-85% at 3.45 m and 95-105% at 3.72 m; 2018/2022 agree within + # 0.15 m. The 2026-08 value (2.75) alerted on 15 quiet-season hours. + "P.75": (3.20, 3.65), "P.76": (5.35, 5.45), - "P.77": (2.85, 3.35), + # P.77: recalibrated 2026-09-12. The 2026-08 value (2.85) sat below the + # gauge's own dry-season baseline (2.6-2.7 m at 8-14% capacity), so the + # first ntfy cycle fired a "warning" at 22% capacity. Across 2018-2024, + # 75-85% capacity reads 3.35-4.57 m and 95-105% 4.27-5.08 m; 2024 (the + # best-sampled flood year) gives 4.57 / 5.08. Slightly conservative: + "P.77": (4.30, 4.90), "P.81": (5.15, 6.30), # P.82 never reached 100% capacity in the record (max level 3.78, max 96.4%); # danger sits just below the observed maximum so the head can actually train. diff --git a/src/notify.py b/src/notify.py index 62be094..4144d33 100644 --- a/src/notify.py +++ b/src/notify.py @@ -40,6 +40,15 @@ logger = logging.getLogger(__name__) # Hysteresis: an all-clear needs the level this far BELOW the threshold, so a # river bobbing around 3.70 m does not toggle warning/clear every hour. CLEAR_MARGIN_M = 0.10 +# Capacity guard. The level thresholds in features.THRESHOLDS were calibrated +# from RID's discharge_percent (% of channel capacity); if RID re-rates a +# gauge or moves its datum, the level crosses while capacity says the channel +# is nearly empty (P.77, 2026-09: 3.0 m "warning" at 22 %). A crossing is +# only announced when the reported capacity agrees that the river is high. +# P.1 is exempt: its stages come from the municipal inundation map, not from +# capacity. Readings without a capacity figure fall back to level only. +CAPACITY_GUARD_MIN_PCT = 60.0 +CAPACITY_GUARD_EXEMPT = {"P.1"} # Outlook alert fires when p_warning(24h) rises through ON, clears below OFF. OUTLOOK_ON = 0.50 OUTLOOK_OFF = 0.25 @@ -255,6 +264,22 @@ def evaluate( key = f"level:{code}" prev = state.get(key) or "clear" cur = _level_state(level, warn, danger, prev) + pct = r.get("discharge_percent") + if ( + cur != "clear" + and prev == "clear" + and code not in CAPACITY_GUARD_EXEMPT + and pct is not None + ): + try: + if float(pct) < CAPACITY_GUARD_MIN_PCT: + logger.info( + f"{code}: level {level:.2f} m >= {warn:.2f} but only " + f"{float(pct):.0f}% capacity; threshold looks stale, not alerting" + ) + continue + except (TypeError, ValueError): + pass if cur == prev: continue name = STATION_NAMES.get(code, code) diff --git a/tests/test_notify.py b/tests/test_notify.py index ab3a652..4275a40 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -182,6 +182,73 @@ def test_stale_feed_and_recovery(pub): assert len(pub.sent) == 2 and "recovered" in pub.sent[1].title +def test_capacity_guard_blocks_stale_threshold(pub): + """P.77 2026-09: 3.02 m >= 2.85 m 'warning' at 22 % capacity -> not a flood.""" + state = notify.InMemoryState() + r = { + "station_code": "P.77", + "water_level": 4.40, + "timestamp": "2026-09-24T12:00:00", + "discharge_percent": 10.3, + } + notify.evaluate([r], [], state, pub, now=NOW) + assert pub.sent == [] and state.get("level:P.77") is None + # same level with capacity agreeing -> alert + r["discharge_percent"] = 82.0 + notify.evaluate([r], [], state, pub, now=NOW) + assert topics(pub) == ["ping-p77-warning", "ping-warning"] + + +def test_capacity_guard_exempts_p1_and_missing_pct(pub): + state = notify.InMemoryState() + notify.evaluate( + [ + { + "station_code": "P.1", + "water_level": 3.75, + "timestamp": "2026-09-24T12:00:00", + "discharge_percent": 40.0, + } + ], + [], + state, + pub, + now=NOW, + ) + assert topics(pub) == ["ping-p1-warning", "ping-warning"] + pub.sent.clear() + notify.evaluate( + [ + { + "station_code": "P.103", + "water_level": 6.0, + "timestamp": "2026-09-24T12:00:00", + } + ], + [], + state, + pub, + now=NOW, + ) + assert topics(pub) == ["ping-p103-warning", "ping-warning"] + + +def test_capacity_guard_does_not_block_clearing(pub): + """Guard applies only to the clear->alert edge; the all-clear always goes out.""" + state = notify.InMemoryState() + r = { + "station_code": "P.67", + "water_level": 2.6, + "timestamp": "2026-09-24T12:00:00", + "discharge_percent": 90.0, + } + notify.evaluate([r], [], state, pub, now=NOW) + assert len(pub.sent) == 2 + r.update(water_level=2.2, discharge_percent=30.0) + notify.evaluate([r], [], state, pub, now=NOW) + assert "back to normal" in pub.sent[2].title + + def test_state_survives_restart_via_sql(tmp_path, pub): from sqlalchemy import create_engine