Hysteresis Thresholds to Suppress Alert Flapping

When a sensor reading dithers around a single limit, a one-level comparator chatters an alarm on every noise excursion. Dual-threshold hysteresis — a Schmitt trigger in the amplitude domain — fixes this by separating the set and clear points, and it is the specific stabilizer behind Threshold Tuning & Alerting within the Data Capture, Validation & Metadata Sync stack. This page derives the band from measured sensor noise and latches it in a per-channel state machine.

Amplitude-Domain Flapping, Not a Timing Problem

Flapping has two independent causes and two independent fixes, and conflating them wastes tuning effort. This page addresses the amplitude cause: a reading whose instantaneous value sits within a noise band of the alarm limit, so Gaussian measurement noise alone carries it back and forth across a single threshold T. If the process mean is exactly at T and the noise standard deviation is σ, roughly half of all samples land on each side, and a single-limit comparator emits an alarm transition on nearly every sample. No amount of waiting fixes a signal genuinely parked on the boundary — that is what the amplitude-domain fix is for.

The orthogonal cause is temporal: a signal that has cleanly crossed the limit but arrives as a short-lived spike or a burst that should not, on its own, raise an alarm. That is a time-domain problem solved by Debouncing Instrument Alarms with Time Windows, which requires a condition to persist for N samples or T milliseconds before latching. Hysteresis and debounce compose: hysteresis decides at what amplitude the state flips, debounce decides for how long it must hold before the flip is trusted. This page assumes the input is already a validated, timestamp-aligned scalar per channel — the parsing and CRC gates upstream of the evaluator have run — and that a slow baseline drift, if present, is the province of Rolling Z-Score Anomaly Detection for Sensor Drift rather than a fixed limit at all.

The Two-Level Trigger and Sizing the Band from σ

A Schmitt trigger replaces the single limit with an ordered pair: an upper set point T_high that arms the alarm and a lower clear point T_low that disarms it. The alarm state is not a pure function of the current sample — it is a function of the sample and the previous state, which is what gives the comparator memory and immunity to boundary noise:

Inside the dead band the output holds its prior value. For the signal to produce a spurious transition it must now travel the full width of the band, not merely graze one line. Sizing that band is the entire design decision. Model the in-band reading as x = μ + noise with noise standard deviation σ measured directly from the instrument at rest — capture a few thousand samples with the process held nominally constant and take their sample standard deviation. To make a noise-driven round trip across the band statistically negligible, set the band to a multiple k of σ:

  • k = 4 puts each threshold 2σ off the midpoint if centred, giving roughly 2.3 % single-sided exceedance per independent sample — a reasonable floor.
  • k = 6 (±3σ) drops single-sided exceedance to ~0.13 % and is the common production choice for a noisy analog channel.
  • k below 3 is almost always too tight: the band is comparable to the noise itself and flapping survives.

The band is a signal-to-noise decision, not a physical-limit decision. The physical alarm point (a temperature ceiling, a pressure limit) anchors one threshold; the band width then places the other. For a rising alarm — you care about the signal going high — anchor T_high at the physical limit and drop T_low by k·σ below it, so the alarm arms exactly at the limit and clears only after the reading has recovered by a comfortable margin. Asymmetric bands follow when the rising and falling dynamics differ: a heater whose temperature rings on the way up but settles slowly on the way down wants a wider clear margin than set margin, so T_high sits at the limit and T_low is placed further below than a symmetric k·σ would suggest, biasing the latch toward staying armed until recovery is unambiguous.

Hysteresis band versus a single limit on a dithering signal Upper panel: a noisy signal-versus-time trace rising into and out of an alarm region. Two dashed horizontal lines mark T_high (the set point) and T_low (the clear point), separated by the hysteresis band of at least k sigma. A single midpoint limit is drawn between them; the noisy trace crosses that single limit many times, but crosses the full band only once going up and once coming down. Lower panel: the latched alarm state, a step that goes high only when the trace reaches T_high and returns low only when the trace falls to T_low, producing exactly one clean alarm episode instead of the many flaps a single limit would emit. SIGNAL vs TIME — single limit flaps, band latches once low high T_high (set) single limit T T_low (clear) band ≥ kσ LATCHED ALARM STATE — one episode, no chatter 0 1 arm @ T_high clear @ T_low time → — a single-limit comparator would toggle on every crossing of T
The dithering signal crosses the single limit T a dozen times but crosses the full band only once each way, so the latched alarm fires exactly one clean episode instead of chattering.

The band converts many boundary crossings into one latched episode: the state flips on at T_high and off at T_low, never on the midline.

A Latching HysteresisAlarm State Machine

The implementation is a two-state latch per channel. It holds armed across the dead band, exposes the transition (RAISED, CLEARED, or None) so the caller routes it rather than the alarm firing a side effect inline, and derives its thresholds from either explicit physical limits or a measured σ. Config is per channel because two detectors never share a noise floor.

from __future__ import annotations

import enum
import logging
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)


class Transition(enum.Enum):
    RAISED = "RAISED"
    CLEARED = "CLEARED"


@dataclass(frozen=True)
class HysteresisConfig:
    """Dual-threshold config for one channel. T_high arms, T_low clears."""
    channel: str
    t_high: float
    t_low: float

    def __post_init__(self) -> None:
        if self.t_low >= self.t_high:
            raise ValueError(
                f"{self.channel}: t_low ({self.t_low}) must be strictly "
                f"below t_high ({self.t_high}); a zero/negative band flaps."
            )

    @classmethod
    def from_noise(
        cls, channel: str, limit: float, sigma: float, k: float = 6.0
    ) -> "HysteresisConfig":
        """Anchor T_high at a rising physical ``limit`` and drop T_low by k*sigma."""
        if sigma <= 0:
            raise ValueError(f"{channel}: sigma must be positive, got {sigma}")
        return cls(channel=channel, t_high=limit, t_low=limit - k * sigma)

    @property
    def band(self) -> float:
        return self.t_high - self.t_low


@dataclass
class HysteresisAlarm:
    """Schmitt-trigger latch: armed persists across the dead band."""
    config: HysteresisConfig
    armed: bool = False
    _flap_guard: int = field(default=0, repr=False)

    def update(self, x: float) -> Transition | None:
        """Feed one sample; return a Transition only on an actual state change."""
        if x >= self.config.t_high and not self.armed:
            self.armed = True
            logger.info("%s RAISED at x=%.4f (T_high=%.4f)",
                        self.config.channel, x, self.config.t_high)
            return Transition.RAISED
        if x <= self.config.t_low and self.armed:
            self.armed = False
            logger.info("%s CLEARED at x=%.4f (T_low=%.4f)",
                        self.config.channel, x, self.config.t_low)
            return Transition.CLEARED
        # T_low < x < T_high, or same side as current state: hold the latch.
        return None

The whole point is the two guarded branches: RAISED fires only on the not self.armed edge, CLEARED only on the self.armed edge. A sample inside the band, or on the same side as the current state, returns None and mutates nothing — that is the latch holding. Because update returns a transition object rather than sending a notification, the caller composes it freely with a debounce stage or hands it to Metadata Injection Workflows to attach channel, run phase, and sample ID before an operator ever sees it.

Proving Zero Flaps Inside the Band

The verification that matters is adversarial: drive the alarm with noise centred inside the band and assert it never transitions. A correct latch emits zero transitions for any signal that stays between the thresholds, regardless of how violently it dithers.

import numpy as np

from hysteresis_alarm import HysteresisAlarm, HysteresisConfig, Transition


def test_no_flaps_within_band() -> None:
    rng = np.random.default_rng(42)
    sigma = 0.5
    cfg = HysteresisConfig.from_noise("rtd_0", limit=80.0, sigma=sigma, k=6.0)
    alarm = HysteresisAlarm(cfg)

    # Park the mean at the band midpoint; noise stays inside +/-3 sigma.
    midpoint = (cfg.t_high + cfg.t_low) / 2.0
    samples = rng.normal(midpoint, sigma, size=100_000)

    transitions = [t for x in samples if (t := alarm.update(float(x))) is not None]
    assert transitions == [], f"expected zero flaps, saw {len(transitions)}"
    assert alarm.armed is False


def test_single_clean_episode() -> None:
    cfg = HysteresisConfig("rtd_0", t_high=80.0, t_low=77.0)
    alarm = HysteresisAlarm(cfg)
    # Ramp up through T_high, dither in-band, ramp back through T_low.
    ramp = list(np.linspace(76, 82, 40)) + list(np.linspace(82, 75, 40))
    seq = [alarm.update(float(x)) for x in ramp]
    assert seq.count(Transition.RAISED) == 1
    assert seq.count(Transition.CLEARED) == 1

On a live rig, confirm the same property by logging every returned transition with its triggering x: a healthy channel shows one RAISED/CLEARED pair per genuine excursion, and the x on each RAISED is at or above T_high while each CLEARED is at or below T_low. If the log shows RAISED and CLEARED alternating within milliseconds at values that never reach the opposite threshold, the band is too narrow — measure σ again and rebuild the config with a larger k.

Failure Modes Specific to Amplitude Hysteresis

Band narrower than the noise — still flaps. If band < k·σ because σ was underestimated (measured while the instrument was warm and quiet, then deployed against a noisier cold start), the signal completes noise-driven round trips across both thresholds and the alarm chatters exactly as a single limit would. The signature is RAISED/CLEARED pairs with each x barely past its threshold and a mean sitting inside the band. Fix by re-measuring σ under worst-case operating noise and widening the band; do not reach for debounce, which masks the symptom while the amplitude fault remains.

Band too wide — missed real excursions. Over-widening to kill the last trace of flapping drops T_low so far that a genuine excursion which arms the alarm never recovers enough to clear it, or — for a symmetric two-sided design — a real transient smaller than the band passes undetected between the thresholds. The tell is an alarm that latches once and stays armed for the rest of the run, or a known fault injection that produces no transition. Size the band from σ, not by inflating it until chatter stops; if a legitimate short excursion is smaller than k·σ, the problem is signal-to-noise and belongs to Rolling Z-Score Anomaly Detection for Sensor Drift, not a fixed band.

Baseline drift walks the signal into a fixed band. Static T_high/T_low assume a stationary mean. Lamp aging, dark-current creep, or thermal drift can carry the baseline upward until it grazes T_high and re-introduces flapping against thresholds that were correct at commissioning. Diagnose by trending the in-band sample mean over hours: if it slides toward a threshold, the fix is not a wider band but a drift-tracking baseline feeding adaptive limits, at which point classification of the underlying fault should route through Error Code Categorization to distinguish drift from a step fault.

Quantized ADC steps straddling a threshold. A low-resolution converter emits discrete codes, and if a threshold falls between two adjacent codes with no code in the dead band, the reading toggles between the code just above T_high and the code just below T_low in a single least-significant-bit step — the band is effectively zero in code space even though it looks finite in engineering units. The signature is flapping whose x values are exactly two neighbouring quantized levels. Fix by ensuring the band spans at least two or three ADC codes (band ≥ 3·LSB) in addition to the k·σ rule, so quantization noise cannot cross it in one step.

← Back to Threshold Tuning & Alerting

References