Detecting Stuck Sensor Values with Run-Length Checks

A stuck sensor is the most expensive failure in an environmental network because it is the one that looks healthiest. Data keeps arriving, at the right cadence, with values inside every plausible range, passing every completeness check — and the number has not changed since Tuesday. Statistical detectors are structurally blind to it, and a stuck reading can persist for weeks before someone notices a flat line on a chart. This guide implements the check that catches it: a run-length test on identical or near-identical consecutive values, tuned to each measurement’s real variability. It belongs at the cheap end of the gate chain described in anomaly detection methods for sensor networks.

Why It Needs Its Own Detector

Consider a temperature probe frozen at 18.3 °C. Every reading is physically plausible, so the range check passes. The change between consecutive readings is zero, so the rate-of-change check passes — it is looking for values that move too fast. The reading sits near the middle of the recent distribution, so its Z-score is approximately zero.

Then it gets worse. A rolling standard deviation computed over a window that is mostly constant collapses toward zero, so the Z-score denominator shrinks, and the next genuinely varying reading — from this sensor after it recovers, or from a neighbour if the statistics are pooled — produces an enormous Z-score. A stuck sensor does not merely evade detection; it actively degrades the detector around it.

The only signal that distinguishes stuck from stable is the duration of constancy relative to what that measurement does naturally, and duration is exactly what a per-reading detector cannot see.

A frozen sensor, and what the Z-score does about it Line chart of a temperature series that freezes at 18.3 degrees at hour six, with the rolling Z-score plotted alongside collapsing toward zero rather than rising — the detector going blind exactly when the fault begins. A frozen sensor, and what the Z-score does about it 0 10 20 0 6 8 10 12 sensor freezes °C and |Z| hours Reading (°C) Rolling |Z| of the reading
The reading stays plausible and the Z-score falls, so every statistical detector reports a healthier sensor than before it broke.

Production-Ready Implementation

# python 3.11 · pandas==2.2.2 · numpy==1.26.4
from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd

QC_GOOD, QC_SUSPECT, QC_STUCK = 1, 2, 3


@dataclass(frozen=True)
class StuckPolicy:
    """Run-length thresholds for one measurement type.

    tolerance is the change below which two readings count as 'the same'. It must
    be at or just above the sensor's quantisation step: a probe reporting to
    0.1 °C moves in steps of 0.1, so an exact-equality test would miss a sensor
    oscillating between two adjacent codes — the most common form of 'stuck'.
    """

    tolerance: float
    warn_intervals: int
    stuck_intervals: int
    zero_intervals: int | None = None      # tighter threshold for runs of exactly zero


def run_lengths(values: pd.Series, tolerance: float) -> np.ndarray:
    """Length of the constant run each reading belongs to, in intervals.

    A reading whose neighbours differ by more than `tolerance` has run length 1.
    """
    v = values.to_numpy(dtype=float)
    changed = np.ones(len(v), dtype=bool)
    changed[1:] = np.abs(np.diff(v)) > tolerance
    group = np.cumsum(changed)
    counts = np.bincount(group)
    return counts[group]


def flag_stuck(df: pd.DataFrame, policy: StuckPolicy, value_col: str = "value") -> pd.DataFrame:
    """Flag readings that belong to an implausibly long constant run."""
    out = df.copy()
    lengths = run_lengths(out[value_col], policy.tolerance)
    out["run_length"] = lengths

    is_zero_run = np.isclose(out[value_col].to_numpy(dtype=float), 0.0)
    zero_limit = policy.zero_intervals or policy.stuck_intervals

    stuck = ((lengths >= policy.stuck_intervals) |
             (is_zero_run & (lengths >= zero_limit)))
    warn = (~stuck) & (lengths >= policy.warn_intervals)

    out.loc[warn, "qc_flag"] = np.maximum(out.loc[warn, "qc_flag"], QC_SUSPECT)
    out.loc[stuck, "qc_flag"] = QC_STUCK
    return out

Two design choices carry the correctness. Using a tolerance rather than exact equality catches the sensor that dithers between two adjacent quantisation codes — which reads as “changing” to an equality test and is just as broken. And flagging the whole run rather than only its tail means the first reading of a stuck period is flagged too, which matters when the data is later filtered by flag rather than by time.

For a streaming path, the same test with O(1) state per sensor:

@dataclass
class StuckTracker:
    """Incremental run tracking for a live stream — one small object per sensor."""

    policy: StuckPolicy
    last_value: float | None = None
    run: int = 0

    def observe(self, value: float) -> int:
        if self.last_value is not None and abs(value - self.last_value) <= self.policy.tolerance:
            self.run += 1
        else:
            self.run = 1
        self.last_value = value
        if self.run >= self.policy.stuck_intervals:
            return QC_STUCK
        if self.run >= self.policy.warn_intervals:
            return QC_SUSPECT
        return QC_GOOD
Run-length policy by measurement, in intervals Matrix of six measurement types against the tolerance that counts two readings as the same, the warn threshold, the stuck threshold and the tighter limit applied to runs of exactly zero. Run-length policy by measurement, in intervals Tolerance Warn after Stuck after Zero-run limit Temperature (0.1 °C res.) 0.05 60 180 30 Relative humidity 0.25 45 120 20 PM2.5 0.5 20 60 15 Dissolved oxygen 0.005 12 40 8 Battery voltage 0.005 240 720 5 Rainfall disabled disabled disabled
Thresholds are in intervals rather than minutes, so one policy works across cadences. Rainfall is disabled: three dry weeks is a working gauge.

Parameter Tuning Guide

Measurement Resolution Tolerance Warn after Stuck after Zero-run limit
Temperature (°C) 0.1 0.05 60 intervals 180 30
Relative humidity (%) 0.5 0.25 45 120 20
PM2.5 (µg/m³) 1.0 0.5 20 60 15
Barometric pressure (hPa) 0.1 0.05 90 240 10
Dissolved oxygen (mg/L) 0.01 0.005 12 40 8
Water level (m) 0.001 0.0005 30 120 60
Rainfall (mm) 0.2 disabled disabled disabled
Battery (V) 0.01 0.005 240 720 5

Intervals, not minutes: the thresholds scale with the reporting cadence, so the same policy works for a one-minute and a fifteen-minute feed. Rainfall is disabled outright — a tipping-bucket gauge reporting zero for three dry weeks is a working gauge, and no run-length rule can distinguish that from a jammed bucket. Use a separate check that compares against neighbouring gauges during recorded rainfall instead.

Derive your own thresholds from the observed distribution rather than copying the table:

def suggest_thresholds(healthy: pd.Series, tolerance: float,
                       warn_pct: float = 99.0, stuck_pct: float = 99.9) -> tuple[int, int]:
    """Percentiles of the run-length distribution across known-good sensors."""
    lengths = run_lengths(healthy, tolerance)
    return int(np.percentile(lengths, warn_pct)), int(np.percentile(lengths, stuck_pct))
Which detector finds which failure Grouped bar chart of detection rate for four sensor failures across three detectors, showing that only the run-length check catches a frozen value. Which detector finds which failure 0 50 100 Spike Drift Frozen value Dither between codes detection rate (%) Z-score (%) Isolation Forest (%) Run-length check (%)
The last two columns are why this check earns its place in the gate chain: no statistical detector sees a value that never moves.

Verification and Testing

# python 3.11 · pandas==2.2.2 · pytest==8.2.0
import numpy as np
import pandas as pd

POLICY = StuckPolicy(tolerance=0.5, warn_intervals=20, stuck_intervals=60)


def _frame(values):
    return pd.DataFrame({"value": values, "qc_flag": [QC_GOOD] * len(values)})


def test_a_frozen_sensor_is_flagged_and_a_varying_one_is_not():
    rng = np.random.default_rng(3)
    healthy = _frame(list(rng.normal(18, 4, 200)))
    frozen = _frame(list(rng.normal(18, 4, 100)) + [18.3] * 100)

    assert (flag_stuck(healthy, POLICY)["qc_flag"] == QC_STUCK).sum() == 0
    assert (flag_stuck(frozen, POLICY)["qc_flag"] == QC_STUCK).sum() >= 60


def test_dithering_between_two_codes_still_counts_as_stuck():
    """Alternating 18.3/18.4 is a frozen sensor, not a varying one."""
    values = [18.3 if i % 2 else 18.4 for i in range(120)]
    assert (flag_stuck(_frame(values), POLICY)["qc_flag"] == QC_STUCK).sum() >= 60


def test_the_whole_run_is_flagged_not_just_the_tail():
    values = list(np.linspace(10, 20, 40)) + [20.0] * 80
    out = flag_stuck(_frame(values), POLICY)
    stuck_idx = out.index[out["qc_flag"] == QC_STUCK]
    assert stuck_idx.min() == 40           # flagged from the first repeated reading


def test_streaming_tracker_matches_the_batch_result():
    values = list(np.linspace(10, 20, 40)) + [20.0] * 80
    tracker = StuckTracker(POLICY)
    streamed = [tracker.observe(v) for v in values]
    batch = flag_stuck(_frame(values), POLICY)["qc_flag"].tolist()
    assert streamed[-1] == batch[-1] == QC_STUCK

The last test is worth keeping permanently. A batch implementation and a streaming one that disagree produce a dataset whose flags depend on how it was processed, which is the hardest class of inconsistency to debug months later.

Gotchas

Exact equality instead of a tolerance. A sensor whose last bit flickers reports 18.3, 18.4, 18.3 forever. Equality sees constant change; a tolerance sees the truth.

Thresholds in minutes rather than intervals. A policy written for one-minute data silently becomes fifteen times stricter when applied to a fifteen-minute feed, and every sensor on that feed gets flagged.

Running the check before resampling. On an irregular series, “consecutive readings” is not a fixed duration, so a run of twenty readings might be twenty minutes or two hours. Run this after resampling to a fixed grid.

Counting filled values in a run. An interpolated stretch is constant-ish by construction and will trigger the check. Exclude imputed rows, or the detector will flag your own gap filling as a hardware fault.


FAQ

Why do Z-score and Isolation Forest miss a stuck sensor?

Because a repeated in-range value is not an outlier — it sits at the centre of the distribution. Worse, a long run of identical readings shrinks the rolling standard deviation, so the Z-score of every subsequent reading grows and the detector starts flagging the healthy sensors around it instead.

How many repeats mean stuck rather than stable?

It depends entirely on the measurement’s natural variability at the sensor’s resolution. A 0.1 °C-resolution thermometer can legitimately report the same value for twenty minutes on a calm night; a 1 µg/m³ PM2.5 sensor repeating for twenty minutes is almost certainly frozen. Derive the threshold from the observed run-length distribution of healthy sensors, not from a round number.

What about a sensor stuck at zero?

Treat it as a separate case with a much tighter threshold. Zero is both a plausible reading and the value a failed ADC returns, so a run of zeros deserves suspicion sooner than a run of any other value — and for a rain gauge, where long runs of zero are the normal state, run-length checks must be disabled entirely.