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 = 4puts 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.kbelow 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.
T a dozen times but crosses the full kσ 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.
Related
- Threshold Tuning & Alerting — the parent section covering adaptive baselines, sigma multipliers, and the alerting state machine this latch plugs into.
- Debouncing Instrument Alarms with Time Windows — the time-domain complement that suppresses short-lived spikes after amplitude hysteresis decides the crossing.
- Rolling Z-Score Anomaly Detection for Sensor Drift — for a moving baseline where a fixed dual threshold cannot stay correct.
- Metadata Injection Workflows — enrich each raised or cleared transition with channel and run context before dispatch.
- Error Code Categorization — classify an underlying wire or drift fault the latch surfaces into retryable versus hard failure.
← Back to Threshold Tuning & Alerting
References
- NIST/SEMATECH e-Handbook of Statistical Methods — control charts — sizing limits from process sigma and subgroup statistics.
- Python
dataclassesdocumentation —frozen,field, and__post_init__semantics used in the per-channel config.