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 TT, you define two: an assert level TonT_{on} and a clear level ToffT_{off}, with the hard requirement that Ton>ToffT_{on} > T_{off} for a rising (high-side) alarm. The distance between them is the deadband width,

Δ=TonToff.\Delta = T_{on} - T_{off}.

Once the alarm is active, the signal must fall all the way back through the deadband — below ToffT_{off}, not merely below TonT_{on} — before the alarm will clear. A reading that dithers inside [Toff,Ton][T_{off}, T_{on}] therefore changes nothing: the state is latched. Sizing Δ\Delta is an engineering decision, not a guess. If σ\sigma is the standard deviation of the measurement noise, a deadband of Δkσ\Delta \ge k\,\sigma with kk 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 σ\sigma 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.

Deadband alarm state machine: CLEAR, PENDING, ALARM and CLEARING Four states arranged in a clockwise cycle. CLEAR to PENDING on signal at or above the set threshold. PENDING to ALARM after the on-debounce elapses, emitting RAISE; PENDING back to CLEAR if the signal falls below the set threshold early. ALARM to CLEARING on signal at or below the clear threshold. CLEARING to CLEAR after the off-debounce elapses, emitting CLEAR; CLEARING back to ALARM if the signal rises above the clear threshold early. CLEAR PENDING ALARM CLEARING latched, quiet on-timer running latched, active off-timer running signal ≥ T_on signal < T_on (early) held ≥ t_on → RAISE signal ≤ T_off signal > T_off (early) held ≥ t_off → CLEAR
@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 TonT_{on} 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 TonT_{on}; alarm arms at or above it
clear_threshold float 0.8 Clear level ToffT_{off}; alarm clears at or below it
on_debounce timedelta 120 s Sustained time above TonT_{on} required before RAISE
off_debounce timedelta 120 s Sustained time below ToffT_{off} required before CLEAR
deadband Δ\Delta derived 0.2 TonToffT_{on} - T_{off}; sized to kσ\ge k\sigma 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 σ\sigma during a steady window and widen Δ\Delta, or add a rate-of-change condition upstream rather than shrinking the debounce.
  • RAISE never fires even though the reading is clearly over the limit. Almost always a timestamp problem: if samples carry the same ts, or the clock runs backward across a source boundary, ts - self._timer_start never reaches on_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 RAISE is delayed by up to on_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_off value can trip CLEARING. 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.