Tuning Deadbands to Suppress Nuisance Alarms
A sensor sitting almost exactly on its limit is the single most common source of alarm fatigue in a water utility control room: turbidity that hovers at 1.00 NTU, a chlorine residual grazing its floor, a tank level parked on a setpoint. Sampled every few seconds, the reading crosses and re-crosses the line dozens of times an hour, and a naive comparator fires a fresh violation on every crossing. This page, part of the Threshold Tuning Frameworks section, shows how to stop that chatter without ever masking a real, sustained exceedance. The two techniques that do the work are hysteresis — separate thresholds for asserting and clearing an alarm — and time-based debounce, a minimum duration the signal must hold before either edge is honored. Together they replace the brittle single-line test that a first-pass rule engine ships with, and they slot in directly ahead of the exceedance evaluation carried out in MCL Exceedance Logic Implementation so that only meaningful edges ever reach the Public Notification Workflows downstream.
The core idea is a gap. Instead of one threshold , you define two: an assert level and a clear level , with the hard requirement that for a rising (high-side) alarm. The distance between them is the deadband width,
Once the alarm is active, the signal must fall all the way back through the deadband — below , not merely below — before the alarm will clear. A reading that dithers inside therefore changes nothing: the state is latched. Sizing is an engineering decision, not a guess. If is the standard deviation of the measurement noise, a deadband of with around 2 to 3 will swallow ordinary dither while staying far narrower than any margin you have already reserved for the operational bands described in the sibling page on configuring operational warning bands below MCLs.
Prerequisites & Environment Setup
The implementation is pure Python 3.10+ and the standard library — no numerical dependencies are required for the state machine itself, which keeps it trivially unit-testable and safe to embed inside a larger rule engine or a Celery worker. You will want pydantic if you load deadband definitions from a configuration store and want them validated on the way in, and pytest to run the deterministic test suite in the verification section.
python3 -m venv .venv && source .venv/bin/activate
pip install "pydantic==2.7.*" "pytest==8.2.*"
# Optional: pandas if you replay historian data through the state machine offline
pip install "pandas==2.2.*"
Two non-package prerequisites matter more than the libraries. First, an honest estimate of your measurement noise for each tag, taken from a window when the process was genuinely steady — this is what sizes the deadband. Second, a clear decision about polling cadence, because the debounce is expressed in wall-clock time and interpreted against sample timestamps, not sample counts. If your poller drops readings, the state machine must see the true elapsed time between the samples it does receive, which is why every method below takes an explicit timestamp rather than reading the clock itself.
Step-by-Step Implementation
Step 1 — Define the deadband and debounce as validated configuration
Treat the two thresholds and the two debounce durations as versioned configuration per tag, never as inline constants. The __post_init__ guard below refuses any definition where the deadband is inverted or collapsed, which catches the most damaging misconfiguration — an assert level at or below the clear level — before it can silently defeat the hysteresis.
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Optional
class AlarmState(enum.Enum):
CLEAR = "CLEAR" # below the assert level, quiescent
PENDING = "PENDING" # above assert, waiting out the on-debounce
ALARM = "ALARM" # asserted and latched
CLEARING = "CLEARING" # below clear, waiting out the off-debounce
@dataclass(frozen=True)
class DeadbandConfig:
tag: str
set_threshold: float # T_on: alarm asserts at or above this level
clear_threshold: float # T_off: alarm clears at or below this level
on_debounce: timedelta # sustained time above T_on before RAISE
off_debounce: timedelta # sustained time below T_off before CLEAR
def __post_init__(self) -> None:
if self.set_threshold <= self.clear_threshold:
raise ValueError(
f"{self.tag}: set_threshold ({self.set_threshold}) must exceed "
f"clear_threshold ({self.clear_threshold}); a non-positive "
f"deadband cannot suppress dither."
)
if self.on_debounce < timedelta(0) or self.off_debounce < timedelta(0):
raise ValueError(f"{self.tag}: debounce durations must be non-negative")
Step 2 — Implement the four-state hysteresis machine
The alarm lives in exactly one of four states. CLEAR and ALARM are the two latched, quiescent states; PENDING and CLEARING are the transient states where a debounce timer is running. Only two transitions emit an edge to the outside world — the promotion from PENDING to ALARM returns "RAISE", and the promotion from CLEARING to CLEAR returns "CLEAR". Every other call returns None, meaning “nothing to report.” The diagram traces the full cycle, including the two back-edges that make dither a no-op.
@dataclass
class DeadbandAlarm:
config: DeadbandConfig
state: AlarmState = AlarmState.CLEAR
_timer_start: Optional[datetime] = field(default=None, repr=False)
def update(self, value: float, ts: datetime) -> Optional[str]:
"""Feed one timestamped sample. Returns 'RAISE', 'CLEAR', or None."""
cfg = self.config
if self.state is AlarmState.CLEAR:
if value >= cfg.set_threshold:
self.state = AlarmState.PENDING
self._timer_start = ts
return None
if self.state is AlarmState.PENDING:
if value < cfg.set_threshold:
self.state = AlarmState.CLEAR # dithered back down; no alarm
self._timer_start = None
elif ts - self._timer_start >= cfg.on_debounce:
self.state = AlarmState.ALARM
self._timer_start = None
return "RAISE"
return None
if self.state is AlarmState.ALARM:
if value <= cfg.clear_threshold:
self.state = AlarmState.CLEARING
self._timer_start = ts
return None
if self.state is AlarmState.CLEARING:
if value > cfg.clear_threshold:
self.state = AlarmState.ALARM # rebounded; stay latched
self._timer_start = None
elif ts - self._timer_start >= cfg.off_debounce:
self.state = AlarmState.CLEAR
self._timer_start = None
return "CLEAR"
return None
raise RuntimeError(f"unreachable alarm state: {self.state}")
Two properties make this safe for compliance work. It is deterministic: the output depends only on the sequence of (value, ts) pairs, never on the ambient clock or on how fast the loop happens to run, so a replay of historian data produces exactly the reconstruction operators saw live. And it never masks a sustained exceedance: the on_debounce only delays the RAISE edge by a bounded, configured amount; a reading that stays above will always promote to ALARM once that window elapses. The deadband suppresses flapping, not genuine events.
Step 3 — Drive the machine from a reading stream
In production each tag owns one DeadbandAlarm instance, and validated readings — the same quality-flagged records produced by the ingestion layer — are fed in as they arrive. Skip samples the ingestion side marked bad or interpolated so a dropout cannot be mistaken for a clean crossing; classifying those gaps is the job of the Monitoring Gap Detection Algorithms section, and it must run before the deadband, not after. Only the two real edges are forwarded onward.
def process_stream(alarm: DeadbandAlarm, readings) -> None:
"""readings: iterable of dicts with 'value', 'timestamp', 'quality'."""
for r in readings:
if r["quality"] not in ("GOOD",):
continue # let gap detection decide what a dropout means
edge = alarm.update(r["value"], r["timestamp"])
if edge == "RAISE":
dispatch_violation(alarm.config.tag, r) # into the notification path
elif edge == "CLEAR":
dispatch_return_to_normal(alarm.config.tag, r)
Configuration Reference
Every parameter below is per tag and belongs in versioned configuration. Size on_debounce to be longer than the longest legitimate transient you want to ignore, but far shorter than any regulatory averaging window, so that a true exceedance is still flagged with time to spare.
| Parameter | Type | Example | Meaning |
|---|---|---|---|
tag |
str | TURB_EFF_01 |
Point identifier the alarm is bound to |
set_threshold |
float | 1.0 |
Assert level ; alarm arms at or above it |
clear_threshold |
float | 0.8 |
Clear level ; alarm clears at or below it |
on_debounce |
timedelta | 120 s |
Sustained time above required before RAISE |
off_debounce |
timedelta | 120 s |
Sustained time below required before CLEAR |
| deadband | derived | 0.2 |
; sized to of the noise |
| State | Latched? | Timer running? | Emits on exit |
|---|---|---|---|
CLEAR |
yes | no | — |
PENDING |
no | on-debounce | RAISE (to ALARM) or nothing (to CLEAR) |
ALARM |
yes | no | — |
CLEARING |
no | off-debounce | CLEAR (to CLEAR) or nothing (to ALARM) |
Verification & Testing
Because the machine is a pure function of its input sequence, it is tested with a synthetic clock and hand-built sample streams — no hardware, no mocks of wall time. The suite below pins the three behaviors that matter: a brief spike is swallowed, a sustained exceedance raises exactly once, and dither at the clear level does not flap the alarm back off.
from datetime import datetime, timedelta, timezone
BASE = datetime(2026, 7, 17, 12, 0, tzinfo=timezone.utc)
def make_alarm() -> DeadbandAlarm:
return DeadbandAlarm(DeadbandConfig(
tag="TURB_EFF_01",
set_threshold=1.0,
clear_threshold=0.8,
on_debounce=timedelta(minutes=2),
off_debounce=timedelta(minutes=2),
))
def test_inverted_deadband_is_rejected():
import pytest
with pytest.raises(ValueError):
DeadbandConfig("X", 0.8, 1.0, timedelta(0), timedelta(0))
def test_brief_spike_does_not_raise():
a = make_alarm()
assert a.update(1.2, BASE) is None # -> PENDING
assert a.state is AlarmState.PENDING
assert a.update(0.9, BASE + timedelta(seconds=30)) is None
assert a.state is AlarmState.CLEAR # dither swallowed
def test_sustained_exceedance_raises_once():
a = make_alarm()
a.update(1.2, BASE)
edges = [a.update(1.2, BASE + timedelta(seconds=s)) for s in (60, 121)]
assert edges == [None, "RAISE"]
assert a.state is AlarmState.ALARM
def test_dither_at_clear_level_does_not_flap():
a = make_alarm()
a.update(1.2, BASE)
a.update(1.2, BASE + timedelta(minutes=3)) # -> ALARM
assert a.update(0.7, BASE + timedelta(minutes=4)) is None # -> CLEARING
assert a.update(0.9, BASE + timedelta(minutes=4, seconds=30)) is None
assert a.state is AlarmState.ALARM # rebounded, latched
Acceptance criteria before promoting a tuned deadband to production:
Troubleshooting & Gotchas
- The alarm still flaps on a slow trend. Hysteresis defeats fast dither, not a genuine drift that walks across both thresholds over minutes. If a slow ramp is oscillating around the deadband, the deadband is too narrow for the actual noise plus drift — remeasure during a steady window and widen , or add a rate-of-change condition upstream rather than shrinking the debounce.
RAISEnever fires even though the reading is clearly over the limit. Almost always a timestamp problem: if samples carry the samets, or the clock runs backward across a source boundary,ts - self._timer_startnever reacheson_debounce. Normalize every reading to a monotonic UTC axis at ingestion before it enters the state machine.- An exceedance was reported late. That is the debounce working as designed — the
RAISEis delayed by up toon_debounce. Keep that window well under any regulatory averaging period so the delay is immaterial to the compliance determination handled by the MCL Exceedance Logic Implementation section. - A gap in the feed cleared the alarm on its own. If dropped samples are passed through as zeros or nulls, a sub-
T_offvalue can tripCLEARING. Filter on the quality flag first so a monitoring gap is never mistaken for a return to normal. - Two operators disagree on when the alarm cleared. This happens when the machine reads the ambient clock instead of the sample timestamp, making replays diverge from the live run. Keep
update()a pure function of(value, ts)so the audit trail is reproducible.
Related
- Threshold Tuning Frameworks — the parent section this deadband technique belongs to
- Configuring Operational Warning Bands Below MCLs — sibling page on where to place the thresholds a deadband then guards
- Monitoring Gap Detection Algorithms — filters dropouts before they reach the state machine
- Violation Detection & Rule Engine Logic — the parent architecture these debounced edges feed into