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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user