Skip to content

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.

Genuine steady state versus a frozen sensor: two traces that hard limits and spike filters cannot tell apart Two time-series traces are shown stacked. The top trace is a real steady-state process signal that wobbles with small continuous noise around a flat baseline. The bottom trace is a frozen sensor: a perfectly flat line holding one exact repeated value. A bracket beneath a run of consecutive identical samples on the frozen trace marks the detection window, labeled with the run length that crosses the stuck-at threshold. Flow (L/min) time → genuine steady state frozen sensor detection window: run length ≥ min_run → flagged stuck-at 12.4 L/min, repeated exactly

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_run across 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_sigma and mad divide-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