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.
This commit is contained in:
+10
-2
@@ -37,9 +37,17 @@ THRESHOLDS: Dict[str, Tuple[float, float]] = {
|
|||||||
"P.4A": (3.40, 3.90),
|
"P.4A": (3.40, 3.90),
|
||||||
"P.5": (4.55, 4.95),
|
"P.5": (4.55, 4.95),
|
||||||
"P.67": (2.45, 2.90),
|
"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.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.81": (5.15, 6.30),
|
||||||
# P.82 never reached 100% capacity in the record (max level 3.78, max 96.4%);
|
# 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.
|
# danger sits just below the observed maximum so the head can actually train.
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ logger = logging.getLogger(__name__)
|
|||||||
# Hysteresis: an all-clear needs the level this far BELOW the threshold, so a
|
# 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.
|
# river bobbing around 3.70 m does not toggle warning/clear every hour.
|
||||||
CLEAR_MARGIN_M = 0.10
|
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 alert fires when p_warning(24h) rises through ON, clears below OFF.
|
||||||
OUTLOOK_ON = 0.50
|
OUTLOOK_ON = 0.50
|
||||||
OUTLOOK_OFF = 0.25
|
OUTLOOK_OFF = 0.25
|
||||||
@@ -255,6 +264,22 @@ def evaluate(
|
|||||||
key = f"level:{code}"
|
key = f"level:{code}"
|
||||||
prev = state.get(key) or "clear"
|
prev = state.get(key) or "clear"
|
||||||
cur = _level_state(level, warn, danger, prev)
|
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:
|
if cur == prev:
|
||||||
continue
|
continue
|
||||||
name = STATION_NAMES.get(code, code)
|
name = STATION_NAMES.get(code, code)
|
||||||
|
|||||||
@@ -182,6 +182,73 @@ def test_stale_feed_and_recovery(pub):
|
|||||||
assert len(pub.sent) == 2 and "recovered" in pub.sent[1].title
|
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):
|
def test_state_survives_restart_via_sql(tmp_path, pub):
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user