Detecting Step Changes After Sensor Maintenance

Drift is gradual; maintenance is not. A replaced filter, a cleaned optical chamber, a new calibration coefficient or a firmware update produces a discontinuity — a step in the series where the sensor’s relationship to reality changed in an instant. Every drift-correction method assumes continuity, so a step that is not detected gets smeared across the rolling window: for half a window before and after the event, the baseline is a blend of two different sensors and the corrected values are wrong on both sides. This guide detects those steps, distinguishes them from weather, and resets the baseline cleanly. It protects the methods in sensor drift correction algorithms in Python.

Difference First, Detect Second

The hardest part of step detection in environmental data is that the environment itself steps. A cold front drops temperature by six degrees in twenty minutes across an entire city; a change detector run on a raw series flags it, correctly identifying a change that has nothing to do with the instrument.

The fix is to detect on a differenced series: subtract a robust summary of comparable neighbours from the target before running the detector. A regional weather change moves target and neighbours together and cancels; a filter swap moves only the target and survives at full amplitude. The differenced series is also far more stationary than the raw one, which is what the detector’s statistics assume.

The neighbour set matters. Use sensors that share the target’s exposure — the same site classification rather than merely the nearest coordinates — because a street-canyon sensor differenced against a rooftop one produces a residual full of real, uncancelled variation.

Why detection runs on the differenced series Line chart of a target sensor and the median of its neighbours through a weather front and a filter swap, with the differenced series below showing that only the filter swap survives differencing. Why detection runs on the differenced series -5 -2.5 0 2.5 0 12 30 40 48 weather front — cancels filter swap — survives °C hours Target sensor Neighbour median Differenced (target − neighbours)
A change detector on the raw series flags every frontal passage. On the differenced series, only the instrument change is left.

Production-Ready Implementation

CUSUM is the right detector here: it accumulates small persistent deviations, which is exactly the shape of a step, and it has two interpretable parameters.

# 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


@dataclass(frozen=True)
class StepDetection:
    index: int
    direction: str          # 'up' or 'down'
    magnitude: float        # in measurement units
    confidence: float       # cumulative sum at detection, in sigma


def neighbour_difference(
    target: pd.Series, neighbours: pd.DataFrame, min_neighbours: int = 3
) -> pd.Series:
    """Target minus the robust centre of its comparable neighbours.

    The median (not the mean) so that one neighbour with its own step does not
    inject a mirror-image step into every other sensor's differenced series.
    """
    usable = neighbours.notna().sum(axis=1) >= min_neighbours
    centre = neighbours.median(axis=1)
    return (target - centre).where(usable)


def cusum_steps(
    series: pd.Series, *, threshold_sigma: float = 5.0, drift_sigma: float = 0.5,
    min_separation: int = 60,
) -> list[StepDetection]:
    """Two-sided CUSUM over a differenced series.

    threshold_sigma: cumulative deviation, in sigma, that declares a step.
    drift_sigma:     the slack term; deviations below this do not accumulate, so
                     genuine slow drift is ignored and only steps trigger.
    min_separation:  intervals to wait before another detection, so one step is
                     reported once rather than at every subsequent sample.
    """
    x = series.dropna().to_numpy(dtype=float)
    idx = series.dropna().index.to_numpy()
    if len(x) < 3 * min_separation:
        return []

    sigma = float(np.median(np.abs(np.diff(x)))) * 1.4826 or 1e-9   # robust scale
    mean = float(np.median(x))

    hi = lo = 0.0
    last_hit = -min_separation
    out: list[StepDetection] = []
    for i, value in enumerate(x):
        z = (value - mean) / sigma
        hi = max(0.0, hi + z - drift_sigma)
        lo = min(0.0, lo + z + drift_sigma)

        if i - last_hit < min_separation:
            continue
        if hi > threshold_sigma or lo < -threshold_sigma:
            before = float(np.median(x[max(0, i - min_separation):i]))
            after = float(np.median(x[i:i + min_separation]))
            out.append(StepDetection(
                index=int(idx[i]) if np.issubdtype(idx.dtype, np.integer) else i,
                direction="up" if hi > threshold_sigma else "down",
                magnitude=after - before,
                confidence=abs(hi if hi > threshold_sigma else lo),
            ))
            hi = lo = 0.0
            mean = after            # re-baseline after the step
            last_hit = i
    return out

Re-baselining mean after each detection is what allows several steps in one series to be found. A detector that keeps the original mean accumulates forever after the first step and reports the rest of the record as one continuous anomaly.

Resetting the drift baseline then becomes a matter of segmenting the series:

def segments_between_steps(n: int, steps: list[StepDetection]) -> list[slice]:
    """Index ranges over which a drift baseline may be computed continuously."""
    edges = [0, *(s.index for s in steps), n]
    return [slice(a, b) for a, b in zip(edges, edges[1:]) if b - a > 0]


def baseline_per_segment(values: pd.Series, steps: list[StepDetection],
                         window: int = 720) -> pd.Series:
    """Rolling baseline that restarts at every confirmed step.

    Without the reset, the window spanning a step averages two different sensors
    and the correction is wrong on both sides for half a window in each
    direction.
    """
    out = pd.Series(index=values.index, dtype=float)
    for seg in segments_between_steps(len(values), steps):
        chunk = values.iloc[seg]
        out.iloc[seg] = chunk.rolling(window, min_periods=window // 2).mean()
    return out
What produces a step, and whether anyone wrote it down Matrix of six causes of a step change with typical magnitude, direction, and how reliably each appears in a maintenance log. What produces a step, and whether anyone wrote it down Magnitude Direction In the log? Filter replacement (PM2.5) 3–12 µg/m³ down usually Optical chamber cleaning 2–8 µg/m³ down usually Recalibration varies either always Firmware averaging change 1–5 % of reading either rarely Enclosure moved 0.5–2 °C either rarely Partial inlet blockage 5–20 µg/m³ down never
The bottom row is why detection is needed alongside the log: a blockage is never recorded and is otherwise indistinguishable from a sensor losing sensitivity.

Parameter Tuning Guide

Measurement Neighbour sigma threshold_sigma drift_sigma min_separation Smallest step found
Temperature 0.4 °C 5.0 0.5 720 (12 h) ~0.3 °C
Relative humidity 3.0 % 5.0 0.5 720 ~2 %
PM2.5 5.5 µg/m³ 6.0 0.7 1440 (24 h) ~4 µg/m³
Dissolved oxygen 0.35 mg/L 5.0 0.5 96 (24 h) ~0.3 mg/L
Barometric pressure 0.6 hPa 4.0 0.4 288 (24 h) ~0.4 hPa
Cause of step Typical magnitude Direction Recorded in the log?
Filter replacement (PM2.5) 3–12 µg/m³ down usually
Optical chamber cleaning 2–8 µg/m³ down usually
Recalibration varies with the new coefficients either always
Firmware averaging change 1–5% of reading either rarely
Enclosure moved or reoriented 0.5–2 °C either rarely
Partial inlet blockage 5–20 µg/m³ down, gradual onset never

The last row is the one detection earns its keep on: a blockage is never logged, presents as a step with a slightly soft edge, and is otherwise indistinguishable from a sensor that has simply become less sensitive.

A baseline that resets at the step, and one that does not Line chart of a rolling baseline through a step change, comparing a continuous window that blends the two levels for half a window either side with a segmented baseline that restarts cleanly at the detected step. A baseline that resets at the step, and one that does not 20 20.5 21 21.5 0 18 24 30 48 confirmed step °C hours around the step Reading Continuous rolling baseline Baseline reset at the step
Without the reset, the correction is wrong on both sides for half a window in each direction — six hours of bad data around every service visit.

Verification and Testing

# python 3.11 · numpy==1.26.4 · pandas==2.2.2 · pytest==8.2.0
def _series_with_step(n=3000, step_at=1500, magnitude=1.6, seed=11):
    rng = np.random.default_rng(seed)
    x = rng.normal(0, 0.4, n)
    x[step_at:] += magnitude
    return pd.Series(x)


def test_detects_a_step_close_to_where_it_was_injected():
    steps = cusum_steps(_series_with_step(), threshold_sigma=5.0, min_separation=200)
    assert len(steps) == 1
    assert abs(steps[0].index - 1500) < 250
    assert abs(steps[0].magnitude - 1.6) < 0.4


def test_a_shared_weather_change_is_cancelled_by_differencing():
    rng = np.random.default_rng(5)
    weather = np.concatenate([np.zeros(1500), np.full(1500, -6.0)])   # a front
    target = pd.Series(weather + rng.normal(0, 0.4, 3000))
    neighbours = pd.DataFrame({
        f"n{i}": weather + rng.normal(0, 0.4, 3000) for i in range(4)
    })

    assert len(cusum_steps(target)) >= 1                       # raw series: false positive
    assert cusum_steps(neighbour_difference(target, neighbours)) == []


def test_baseline_resets_rather_than_smearing_across_the_step():
    values = pd.Series(np.concatenate([np.full(1500, 20.0), np.full(1500, 21.6)]))
    steps = [StepDetection(index=1500, direction="up", magnitude=1.6, confidence=8.0)]
    baseline = baseline_per_segment(values, steps, window=360)
    assert abs(baseline.iloc[1560] - 21.6) < 0.05              # no blend from before the step

The middle test is the one to run against real data as well as synthetic: replay a week containing a known frontal passage and confirm the differenced series produces no detections. If it does, the neighbour set is not comparable enough.

Gotchas

Detecting on the raw series. Every weather transition becomes a step. Difference first.

A min_separation shorter than the drift window. Two detections inside one baseline window make the segments too short to compute a baseline at all, and the correction silently stops emitting.

Trusting the maintenance log alone. Logged dates are often the date of the paperwork, not of the visit. Use the detected step as the authoritative boundary and the log as corroboration.

Retroactively correcting data before a step. Readings before a recalibration were produced under the old coefficients and were correct under them. Close the old validity interval and open a new one instead of rewriting history.


FAQ

Why not just use the maintenance log to find step changes?

Use it as the primary source and detect anyway. Logs miss unrecorded interventions, record the wrong date, and never capture the failures that produce steps without anyone touching the sensor — a partly blocked inlet, a shifted enclosure, a firmware update that changed the averaging. Detection confirms the log and catches what it missed.

How do I stop weather being detected as a step change?

Difference the target against nearby sensors before running the detector. A cold front moves every sensor together, so it cancels in the difference; a filter swap moves one sensor, so it survives. Running a change detector on the raw series flags every weather transition in the record.

What should happen when a step is confirmed?

Close the current calibration validity interval at the step, open a new one, and reset the drift baseline so it does not average across the discontinuity. Do not retroactively adjust the readings before the step — they were correct under the coefficients in force at the time.