Detecting Stuck-At and Frozen Sensor Faults in Telemetry
A coolant-flow sensor on a die-casting cell reports 12.4 L/min on every single scan for six hours after its impeller seizes. The value sits well inside the physical envelope, never approaches a hard limit, and never deviates from its own rolling median — so every filter built in outlier detection methods so far passes it clean, including the Hampel filter and the Z-score filter. A stuck-at or frozen sensor is the mirror image of a spike: instead of one abnormal sample surrounded by normal ones, every sample is individually plausible and the fault is in the absence of change across many samples. This page builds the run-length, variance, and entropy tests that catch that absence, and the machine-state logic that keeps a genuinely idle asset from being flagged as a broken transducer.
Both prior filters in this section share a hidden assumption: a fault reveals itself as a deviation from the local baseline. A spike filter and a robust Z-score both compute a spread statistic — MAD, standard deviation — across a window, and both need that spread to be nonzero to have anything to compare against. A truly frozen channel drives MAD and variance to (near) zero, which is precisely the condition the outlier detection index flags as the divide-by-zero edge case those filters must guard against — but guarding against a crash is not the same as detecting the fault that caused it. Catching a flatline needs tests built around constancy itself, not deviation from it.
Why amplitude and spike filters miss a flatline Permalink to this section
Hard limits pass a stuck-at fault because the frozen value is, by construction, a value the sensor has reported validly before — it is inside the calibrated range, nowhere near a saturation rail. Rate-of-change filters pass it because Δvalue is exactly zero, which is the most physically plausible derivative a filter can see, not the least. And a rolling MAD or standard-deviation filter passes it because the sample under test never disagrees with its own window: every neighbor is identical, so the “deviation” term in a Z-score or Hampel comparison is zero divided by a robust-sigma estimate that is itself collapsing toward zero. Production code typically clamps that denominator to avoid an inf or NaN score, and the clamp is exactly what silently converts “sensor is dead” into “no anomaly detected.” The fix is not a better clamp; it is a test that asks a different question — how long has this channel gone without changing — rather than how far the current sample sits from its neighbors.
Repeated-value run-length detection Permalink to this section
The most direct test counts consecutive samples that are identical within the transmitter’s own reporting resolution. Comparing raw floats with exact equality is unreliable — two IEEE 754 doubles computed through slightly different code paths rarely land on the same bit pattern even when the physical value has not moved — so the comparison quantizes to the sensor’s stated precision first:
from __future__ import annotations
import pandas as pd
def flag_stuck_at_runs(
series: pd.Series,
resolution: float,
min_run: int,
) -> pd.Series:
"""
Flag every sample inside a run of `min_run` or more consecutive readings
that are identical after quantizing to the transmitter's reporting
resolution (its LSB / stated precision, e.g. 0.1 L/min).
Quantizing first avoids two failure directions: comparing raw floats
with `==` almost never matches after arithmetic, while a tolerance that
is too fine never fires because normal ADC dither still moves the last
reported digit on a healthy channel.
"""
if min_run < 2:
raise ValueError("min_run must be >= 2 to define a run")
if resolution <= 0:
raise ValueError("resolution must be a positive quantization step")
quantized = (series / resolution).round()
changed = quantized.diff().abs().gt(1e-9)
changed.iloc[0] = True # the first sample always starts a new run
run_id = changed.cumsum()
run_length = run_id.groupby(run_id).transform("size")
return run_length >= min_run
min_run is a duration decision disguised as a sample count: at 1 Hz, min_run = 300 means “flag after five minutes of zero change,” which is a reasonable bar for a flow sensor but far too aggressive for a slow-moving tank-level signal that can legitimately hold steady for that long. Size min_run from the fastest process dynamic the sensor is supposed to track, not from a single global default.
Rolling-variance and entropy tests Permalink to this section
Run-length detection is exact but binary — a sensor drifting by one quantization step every few hundred samples (a common symptom of a failing but not fully dead transducer) never triggers it. A rolling-variance test complements it by watching the spread collapse toward the sensor’s own noise floor, and a rolling entropy test goes further by measuring how many distinct values actually appear inside the window, not just their spread:
import numpy as np
import pandas as pd
def flag_low_variance_windows(
series: pd.Series,
window: int,
std_floor: float,
) -> pd.Series:
"""Flag windows whose rolling std has collapsed below the sensor's own
healthy noise floor (established from historian data, not guessed)."""
rolling_std = series.rolling(window=window, min_periods=window).std(ddof=0)
return rolling_std < std_floor
def rolling_shannon_entropy(series: pd.Series, window: int, resolution: float) -> pd.Series:
"""
Shannon entropy, in bits, of the quantized values inside each rolling
window. A healthy analog channel spreads its readings across several
quantization bins; a frozen or heavily aliased channel collapses toward
a single bin and entropy approaches zero.
"""
bins = (series / resolution).round()
def _entropy(x: np.ndarray) -> float:
_, counts = np.unique(x, return_counts=True)
p = counts / counts.sum()
return float(-(p * np.log2(p)).sum())
return bins.rolling(window=window, min_periods=window).apply(_entropy, raw=True)
Entropy is the more general test: a sensor cycling between exactly two values (a common symptom of a comparator fault or a bit stuck at one end of a noisy ADC) has near-zero variance around its mean but nonzero run length either way, and a pure run-length test can miss it entirely while entropy correctly reports roughly one bit instead of the several bits a healthy noisy channel produces. Run these two tests together — run-length for the cheap, exact case, entropy for the slow-bleed case — the same layered-by-cost discipline the parent outlier detection methods page applies to hard limits before rate-of-change before rolling statistics.
Separating genuine steady state from a dead sensor with machine state Permalink to this section
A flatline is only a fault when the process should be varying. An idle line’s coolant loop genuinely holds a constant flow, or zero flow, for the entire idle period, and flagging that as a stuck-at fault floods the CMMS with tickets for a sensor that is working exactly as expected. The disambiguation needs a second, corroborating signal — canonical machine state, or another sensor that should move in lockstep — the same requirement for a stable equipment identity that PLC tag standardization exists to guarantee upstream:
from dataclasses import dataclass
@dataclass
class FrozenSensorVerdict:
is_frozen: bool
is_legitimate_idle: bool
confidence: float
def classify_flatline(
run_flagged: bool,
variance_flagged: bool,
machine_state: str,
corroborating_signal_active: bool,
) -> FrozenSensorVerdict:
"""
Resolve a flatline flag using machine-state context and a corroborating
signal (e.g. pump run-status or drive current for a flow sensor).
A flatline while the asset is IDLE/STOPPED and the corroborating signal
is also quiet is genuine steady state. The same flatline while the
asset is RUNNING and the corroborating signal is active means the
process should be moving but the transducer is not reporting it move.
"""
flatline = run_flagged and variance_flagged
if not flatline:
return FrozenSensorVerdict(is_frozen=False, is_legitimate_idle=False, confidence=0.0)
if machine_state in ("IDLE", "STOPPED") and not corroborating_signal_active:
return FrozenSensorVerdict(is_frozen=False, is_legitimate_idle=True, confidence=0.9)
return FrozenSensorVerdict(is_frozen=True, is_legitimate_idle=False, confidence=0.85)
Requiring both the run-length flag and the variance flag before consulting state avoids paging out on a single noisy test, and feeding the verdict into downtime classification rather than raw sensor state keeps a dead-transducer alarm from ever masquerading as machine downtime — that resolution belongs to the event-to-downtime mapping stage, not to the sensor layer.
Gotchas & anti-patterns Permalink to this section
- Quantizing to a tolerance finer than the sensor’s own resolution. A tolerance tighter than the transmitter’s LSB never matches, because normal ADC dither moves the last digit on every healthy scan — the run-length test then never fires, even on a genuinely dead channel.
- A single global
min_runacross all sensor types. Five minutes of no change is a fault on a fast flow loop and routine on a tank-level signal. Size the threshold from each signal’s own process dynamics. - Skipping the machine-state corroboration step. Flagging every flatline as a fault without checking whether the asset is legitimately idle turns every planned stop into a nuisance maintenance ticket.
- Relying on variance alone. A channel oscillating between two fixed values has near-zero variance but is not frozen in the run-length sense; pair variance with entropy or run-length rather than trusting one test in isolation.
- Forgetting that Hampel and Z-score guards mask this exact fault. The
robust_sigmaandmaddivide-by-zero clamps in the spike filters on this site are correctness fixes for those filters, not detectors of the condition that triggered them — a frozen sensor needs its own test, not a side effect of another filter’s guard clause.
Quick reference Permalink to this section
| Symptom | Test | Typical trigger | Action |
|---|---|---|---|
| Identical value every scan | Run-length on quantized value | run_length >= min_run (size from process dynamics) |
Flag sensor_frozen, suppress from statistics |
| Slow collapse toward one value | Rolling variance vs healthy floor | rolling_std < std_floor |
Escalate to entropy test before flagging |
| Oscillates between 2 fixed values | Rolling Shannon entropy | entropy < ~1 bit over window |
Flag sensor_degraded, schedule inspection |
| Flatline during a planned stop | Machine-state + corroborating signal | IDLE/STOPPED and corroborator quiet |
Suppress — genuine steady state, not a fault |
| Flatline while asset is RUNNING | Machine-state + corroborating signal | RUNNING and corroborator active |
Confirm sensor_frozen, route to maintenance |
Related Permalink to this section
- Outlier Detection Methods — parent overview of the layered detection stack
- Z-Score Filtering for Vibration Anomalies — the deviation-based sibling whose MAD guard this fault hides behind
- Hampel Filter for Real-Time Spike Removal — the spike-replacement filter that a flatline passes clean through
- Event-to-Downtime Mapping — where a confirmed frozen-sensor verdict gets attributed correctly, separate from real downtime