← Back to Autonomy

Residual & innovation analysis · model-based fault detection and isolation

Reading the Residual

A field guide to the tests that decide whether a model still fits its system: whether the residual is white, whether a filter's innovations are honest, and which fault a residual bank points to.

Model-based fault detection turns a model into a watchdog. A correct model produces a residual, the gap between what it predicts and what the sensors report, that is indistinguishable from noise: white, zero-mean, and no larger than the model's own uncertainty allows. A fault breaks one of those promises. The methods below split by which promise they guard:

Question 1
Is the residual white?

Does correlation survive in the residual, the fingerprint of a dynamic the model never captured?

Question 2
Is the filter consistent?

Are a Kalman filter's innovations zero-mean and exactly as large as its own covariance claims?

Question 3
Which fault?

Given a bank of residuals, which sensor or actuator does the firing pattern point to?

Each card gives the idea in plain words, a system-health example, a figure, and runnable Python. Five live explorers let you break a residual and watch the tests react, tune a matched detector, and trade off a load-aware threshold, and a worked case study runs the whole chain on one fault. A closing "going further" set adds the generation half (observers, unknown-input and observer banks), robust thresholds, fault-size estimation, sharper tests, and data-driven residuals. A decision map keys every method back to the question it answers.

Interactive explorers

Three live tools, one per family. Dial autocorrelation into a residual and watch the whiteness tests flip, mismatch a filter's covariance and watch the NIS leave its band, or inject a fault and watch the structured residuals isolate it.

A

Is the residual white?

whiteness

Slide autocorrelation into the residual and watch the autocorrelation bars leave their band while Ljung-Box and Durbin-Watson flip from white to not white.

B

Is the filter consistent?

innovation

Mismatch the filter's predicted innovation size against reality and watch the windowed NIS sit inside its band, ride above it (overconfident), or below it (conservative).

C

Which fault? Isolate by signature

parity / isolation

Inject a fault and watch which structured residuals fire. The firing pattern is matched against the signature matrix to name the fault.

Whiteness tests, is the residual white?

A correctly modeled residual is white noise: zero mean, no correlation from one sample to the next. Any structure that survives means the model missed something, an unmodeled dynamic or a fault. These tests decide whether that structure is there.

01

Residual autocorrelation and runs

whiteness

The sample autocorrelation is the direct picture of whiteness: a white residual stays inside a narrow band at every lag, while leftover dynamics or a fault push bars past it. The runs test adds a complementary check on sign.

In plain words

The autocorrelation function measures how much a residual at one time resembles itself a few samples later. For genuine white noise every lag should be near zero, scattered inside a band of roughly plus or minus two over the square root of the sample count; a bar that pokes out means the residual carries memory the model did not capture. The runs test looks at the same question from the side of sign: it counts how many times the residual switches sides of its median, because a residual that lingers on one side, too few runs, betrays a slow bias or drift even when each value looks ordinary. Read together they catch both correlated dynamics and standing offsets.

Health-management example

An observer for a thermal model leaves a residual that should be white. After a heat-exchanger fouls, the residual develops a slow positive lobe: its lag-one autocorrelation climbs past the band and the runs test sees a long stay above the median, both pointing to an unmodeled, fault-driven dynamic.

2026-06-20T15:53:05.647681 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
import numpy as np

def residual_acf(r, nlags=20):
    r = np.asarray(r, float) - np.mean(r); n = len(r)
    c0 = r @ r / n
    acf = [ (r[:n-k] @ r[k:]) / n / c0 for k in range(1, nlags+1) ]
    band = 1.96 / np.sqrt(n)          # white-noise 95% band
    return np.array(acf), band

acf, band = residual_acf(resid, 20)
not_white = np.any(np.abs(acf) > band)
Practical note

Estimate the band from the healthy residual length, and read the autocorrelation and the runs test together, since one catches correlated dynamics and the other a standing bias. A residual built from a model fitted to the same data will look whiter than it is, so validate on held-out healthy data. A few bars just past the band at random lags is expected; a coherent lobe at low lags is the real signal.

Box & Pierce (1970), JASA 65; Brockwell & Davis (1991), Time Series: Theory and Methods.

02

Ljung-Box test

whiteness

Pools the squared autocorrelations up to a chosen lag into one chi-square statistic, turning the whole autocorrelation picture into a single accept-or-reject of whiteness. The standard portmanteau whiteness test.

In plain words

Looking at each lag by eye is informal; the Ljung-Box test makes it a hypothesis test. It sums the squared autocorrelations from lag one up to a chosen horizon, each weighted to account for the shrinking sample at longer lags, into a single statistic Q. Under the null that the residual is white, Q follows a chi-square distribution whose degrees of freedom are the number of lags pooled, less any parameters already fitted to the data. A large Q, a small p-value, rejects whiteness: the residual carries correlation somewhere in those lags, which for a model-based detector is the headline alarm that the model no longer fits.

Health-management example

A Kalman filter's innovation channel is tested every window with Ljung-Box over ten lags. Through healthy operation Q sits well below its chi-square limit; once an actuator starts to lag, the innovations become correlated, Q climbs past the limit, and the detector flags an unmodeled dynamic.

2026-06-20T15:53:05.772545 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
import numpy as np
from scipy import stats

def ljung_box(r, lags=10, n_params=0):
    r = np.asarray(r, float) - np.mean(r); n = len(r)
    c0 = r @ r / n; Q = 0.0
    for k in range(1, lags+1):
        rho = (r[:n-k] @ r[k:]) / n / c0
        Q += rho*rho / (n - k)
    Q *= n * (n + 2)
    dof = max(1, lags - n_params)
    return Q, 1 - stats.chi2.cdf(Q, dof)

Q, p = ljung_box(resid, lags=10)   # p < 0.05 rejects whiteness
Practical note

Choose the number of lags to cover the dynamics you fear without diluting the statistic, a common choice is around ten to twenty for short memory. Subtract the number of fitted parameters from the degrees of freedom when the residual comes from an identified model, or the test runs optimistic. It assumes a stationary residual; a changing operating point will show as false correlation, so test within a regime.

Ljung & Box (1978), Biometrika 65; Box, Jenkins & Reinsel (2008), Time Series Analysis.

03

Durbin-Watson

whiteness

A single number that screens for lag-one autocorrelation: near 2 for a white residual, toward 0 for positive correlation, toward 4 for negative. The cheapest whiteness check.

In plain words

The Durbin-Watson statistic compares the sum of squared successive differences of the residual to the sum of squared residuals. When the residual is white, consecutive values are unrelated and the statistic lands near 2. Positive autocorrelation, the common case where the residual changes slowly, makes successive differences small and pulls the statistic toward 0; negative autocorrelation, where it alternates, pushes it toward 4. It only sees lag one, so it is a screen rather than a verdict, but it is instant to compute and a value far from 2 is a reliable early warning that the residual is not white.

Health-management example

A controller's tracking-error residual is logged with a one-number Durbin-Watson summary on each batch. A drop from about 2 toward 1 over successive batches flags creeping positive autocorrelation, prompting a fuller Ljung-Box test that confirms an unmodeled lag.

2026-06-20T15:53:05.854851 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
import numpy as np

def durbin_watson(r):
    r = np.asarray(r, float)
    d = np.sum(np.diff(r)**2) / np.sum(r**2)
    return d, 1 - d/2          # statistic, implied lag-1 correlation

dw, rho1 = durbin_watson(resid)   # dw near 2 => white
Practical note

Treat Durbin-Watson as a screen, not a verdict: it sees only lag one, so confirm a value far from 2 with Ljung-Box across more lags. The classical critical values assume a regression residual with fixed regressors; for a filter innovation use it descriptively and lean on the chi-square tests for the formal decision.

Durbin & Watson (1950, 1951), Biometrika 37 and 38.

Innovation consistency, is the filter telling the truth?

An observer or Kalman filter turns a model into a residual generator: the innovation, measurement minus prediction. Under the correct model it is zero-mean, white, and exactly as large as the filter's own covariance says. These checks test that promise, and are the core of observer-based detection.

04

Kalman innovations

innovation

The innovation is the one-step prediction error of a Kalman filter, and under the correct model it is a zero-mean white sequence with a known covariance. It is the residual that observer-based FDI watches.

In plain words

A Kalman filter predicts the next measurement, then corrects its state when the real measurement arrives. The gap between the two, the innovation, is what the filter could not anticipate. If the model and noise statistics are right, that gap should be unpredictable: zero on average, uncorrelated in time, and scattered with exactly the covariance the filter computes from its own uncertainty and the measurement noise. That makes the innovation a self-calibrating residual, it carries its own yardstick, so a fault that the model does not include shows up as innovations that are biased, correlated, or simply too large for their predicted band, without any need for the true state.

Health-management example

A state estimator tracks battery state of charge from current and voltage. While healthy, the voltage innovation sits inside its predicted band; when the sensor begins to degrade, the innovations spread well beyond that band, the first observable sign of the fault.

2026-06-20T15:53:05.959229 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
import numpy as np

def kalman_step(x, P, y, F, H, Q, R):
    x = F @ x;  P = F @ P @ F.T + Q          # predict
    nu = y - H @ x                            # innovation
    S = H @ P @ H.T + R                       # innovation covariance
    K = P @ H.T @ np.linalg.inv(S)
    x = x + K @ nu;  P = (np.eye(len(x)) - K @ H) @ P
    return x, P, nu, S                        # nu, S drive detection
Practical note

The innovation is only white and correctly scaled if the model and the noise covariances Q and R are right, so estimate Q and R from healthy data first; a wrong R will make the innovations look inconsistent with no fault present. Check zero-mean, whiteness, and magnitude separately, since a bias, an unmodeled dynamic, and a covariance error leave different fingerprints.

Kalman (1960), J. Basic Eng. 82; Mehra & Peschon (1971), Automatica 7 (innovations approach).

05

NIS: normalized innovation squared

innovation

The Mahalanobis length of each innovation in its own predicted covariance. It follows a chi-square with as many degrees of freedom as outputs, so it tests innovation consistency without ever needing the true state.

In plain words

The innovation sequence carries its own covariance, so the natural consistency check is to normalize each innovation by that covariance: the normalized innovation squared is the innovation times the inverse of its predicted covariance times the innovation. Under the correct model this is a chi-square random variable with degrees of freedom equal to the number of measurements, so its average over a window should sit inside a tight chi-square band around that value. When the filter is right, the NIS stays in the band; when a fault makes the innovations larger than predicted, or a wrong noise model makes them inconsistent, the windowed NIS steps out. Because it uses only measured quantities, NIS is the workhorse online consistency monitor.

Health-management example

A windowed NIS runs on a navigation filter. A creeping scale-factor error on one sensor inflates the innovations beyond what the filter expects, and the averaged NIS climbs out of its upper chi-square bound while every individual reading still looks plausible.

2026-06-20T15:53:06.078574 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
import numpy as np
from scipy import stats

def nis(innov, S):                 # innov: (n, m), S: (n, m, m)
    n, m = innov.shape
    vals = np.array([innov[k] @ np.linalg.inv(S[k]) @ innov[k]
                     for k in range(n)])
    lo = stats.chi2.ppf(0.025, n*m) / n
    hi = stats.chi2.ppf(0.975, n*m) / n
    return vals, (lo, hi)          # consistent if mean within band
Practical note

NIS needs no ground truth, which makes it the online monitor, but it does need a trustworthy innovation covariance, so it is only as good as the filter's Q and R. Average over a window rather than reacting to a single sample, and set the band from the chi-square for that window. A persistent NIS above the band is a fault or a model error; a persistent NIS below it means the filter is conservative.

Bar-Shalom, Li & Kirubarajan (2001), Estimation with Applications to Tracking and Navigation.

06

NEES: normalized estimation error squared

innovation

The Mahalanobis length of the true estimation error in the filter's reported covariance. It needs ground truth, so it is the Monte Carlo design check that the covariance is honest, not over- or under-confident.

In plain words

NIS asks whether the innovations match the filter's expectation; NEES asks the deeper question of whether the filter's state covariance is itself believable. It is the true estimation error, the difference between the real state and the estimate, normalized by the filter's reported covariance. Because it needs the true state, it is computed in simulation over many Monte Carlo runs during design and tuning. Averaged across runs it should fall inside a chi-square band around the state dimension. Sitting above the band means the filter is overconfident, its covariance too small, the dangerous case where it will ignore a real fault; sitting below means it is conservative and sluggish. NEES is how you certify a filter before it ever sees a fault.

Health-management example

Before deployment, a tracking filter is run over sixty Monte Carlo trajectories and its averaged NEES plotted against the chi-square band. A version with the process noise set too low rides above the band, exposing an overconfident covariance that is corrected before it can mask a fault in the field.

2026-06-20T15:53:07.209325 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
import numpy as np
from scipy import stats

def nees(err, P):                  # err: (n, nx) true - estimate
    n, nx = err.shape
    vals = np.array([err[k] @ np.linalg.inv(P[k]) @ err[k]
                     for k in range(n)])
    lo = stats.chi2.ppf(0.025, n*nx) / n
    hi = stats.chi2.ppf(0.975, n*nx) / n
    return vals, (lo, hi)          # Monte Carlo consistency check
Practical note

NEES requires the true state, so it lives in simulation: run many Monte Carlo trajectories and average. Above the band means an overconfident covariance, the dangerous case that will mask faults, so tune Q and R until NEES sits inside before deployment. It certifies the filter, not the field data, so pair it with NIS once the filter is running.

Bar-Shalom, Li & Kirubarajan (2001); Mehra & Peschon (1971), Automatica 7.

Parity space, which fault?

Detection says something is wrong; isolation says what. Parity relations build residuals that depend only on noise and faults, not on the unknown state, and structuring them so each is blind to certain faults gives every fault a distinct signature to match.

07

Parity-space residuals

parity / isolation

Projects a window of inputs and outputs onto the left null space of the observability matrix, producing a residual that cancels the unknown state and so depends only on noise and faults. The algebraic route to a residual.

In plain words

Over a short window, the stacked outputs of a linear system equal the observability matrix times the initial state plus a known term in the inputs. The state is unknown, but the part of the output space that the observability matrix cannot reach, its left null space, is not affected by the state at all. Projecting the measured window onto that parity space therefore removes the state entirely and leaves a residual built only from noise and any fault. With no fault the parity residual hovers at zero within the noise; a sensor or actuator fault drives it away from zero along a direction fixed by which signal failed, which is what makes isolation possible.

Health-management example

A redundant set of three sensors watches a two-state process, one more measurement than the state needs. The extra degree of freedom is a parity relation: while healthy its residual sits at zero, and when one sensor develops a bias the parity residual jumps cleanly, independent of where the process happens to be.

2026-06-20T15:53:07.304675 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
import numpy as np

def parity_matrix(A, C, s):
    nx = A.shape[0]; O, Ap = [], np.eye(nx)
    for _ in range(s+1):
        O.append(C @ Ap); Ap = Ap @ A
    O = np.vstack(O)                       # extended observability
    U, sv, _ = np.linalg.svd(O)
    rank = int(np.sum(sv > 1e-9*sv[0]))
    return U[:, rank:].T                   # rows annihilate O

W = parity_matrix(A, C, s=3)
residual = W @ Y_window                    # zero when healthy
Practical note

The parity window order trades detectability against noise: a longer window gives more parity relations but accumulates noise, so choose it from the fault directions you need to see. The construction assumes a known, linear, time-invariant model; for nonlinear or scheduled systems, linearize per operating point. Parity and observer residuals are mathematically equivalent, so pick the better-conditioned one.

Chow & Willsky (1984), IEEE Trans. Automat. Control 29; Gertler (1998), Fault Detection and Diagnosis in Engineering Systems.

08

Structured residuals and isolation

parity / isolation

Designs a bank of residuals each insensitive to a chosen subset of faults, so every fault lights up a unique pattern. Isolation becomes reading off which fault signature the firing pattern matches.

In plain words

A single residual can detect a fault but cannot name it. Structured residuals solve that by design: each residual in a bank is made blind to some faults and sensitive to others, so that the set of residuals that fire, the firing pattern, is different for every fault. Those intended patterns are collected as columns of an incidence or signature matrix, one column per candidate fault. When residuals exceed their thresholds, the observed firing pattern is compared to the columns, and the closest-matching column names the fault. As long as the design gives every fault a distinct column, isolation is unambiguous; if two faults share a column they cannot be told apart, which the signature matrix makes obvious in advance.

Health-management example

Three structured residuals are designed so a sensor-one fault fires residuals one and three, a sensor-two fault fires two and three, and an actuator fault fires one and two. An observed pattern of residuals two and three firing matches only the sensor-two column, isolating the fault to that sensor.

2026-06-20T15:53:07.379661 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
import numpy as np

def isolate(fired, signature):     # signature: residuals x faults (0/1)
    sig = np.asarray(signature); fired = np.asarray(fired)
    scores = sig.shape[0] - np.sum(np.abs(sig - fired[:, None]), axis=0)
    best = int(np.argmax(scores))
    unique = np.sum(scores == scores[best]) == 1
    return best, unique            # which fault, and whether it is unambiguous

# columns: sensor1=[1,0,1], sensor2=[0,1,1], actuator=[1,1,0]
fault, ok = isolate([0,1,1], [[1,0,1],[0,1,1],[1,1,0]])
Practical note

Isolation is only as good as the design: check in advance that every fault you care about has a distinct column in the signature matrix, since two faults that share a column can be detected but never told apart. Set residual thresholds from healthy data, and prefer robust or directional residual designs when disturbances would otherwise corrupt the firing pattern.

Gertler (1998), Fault Detection and Diagnosis; Isermann (2006), Fault-Diagnosis Systems; Ding (2013), Model-Based Fault Diagnosis Techniques.

Decision map

Start from the question you actually have. The badge marks whether the method tests whiteness, innovation consistency, or isolates a fault.

If you are asking...Reach forKind
Does the model explain the data (residual white)?Ljung-Boxwhiteness
A fast one-number whiteness screen?Durbin-Watsonwhiteness
Does the residual linger on one side (bias)?runs testwhiteness
Are a Kalman filter's innovations the right size?NISinnovation
Is the filter's covariance honest (a design check)?NEESinnovation
Are the innovations correlated in time?innovation whitenessinnovation
Build a residual that ignores the unknown state?parity-space residualparity / isolation
Which sensor or actuator failed?structured residualsparity / isolation

From residual to named fault

residual from observer / KF / parity is the residual white? no yes unmodeled dynamics Ljung-Box, Durbin-Watson innovations the right size? no yes covariance / sensor fault NIS, NEES which fault? match the firing signature structured / parity residuals Healthy means white, zero-mean, and correctly scaled; isolation then needs distinct signatures.

A working order for model-based FDI

  1. Build a residual generator: an observer, an unknown-input observer, a Kalman filter, or a parity relation (from the model or learned from healthy data).
  2. Calibrate on healthy data: estimate the noise covariances and confirm the residual is white and zero-mean.
  3. Test whiteness online with Ljung-Box and Durbin-Watson, the cumulative periodogram for tones, and the ARCH test for a variance change.
  4. Decide with a matched detector: the generalized likelihood ratio for an abrupt fault, a load-adaptive or set-membership threshold for robustness.
  5. Estimate the fault size with an augmented Kalman or PI observer, or run an interacting-multiple-model bank to detect and isolate at once.
  6. Isolate with structured residuals or an observer scheme: match the firing pattern to a column of the fault signature matrix.
# common stack
pip install numpy scipy
# ljung_box, kalman_filter, nis, parity_matrix, luenberger_observer, unknown_input_observer, glr_detector, augmented_fault_kalman, imm_filter, cumulative_periodogram, dd_parity, reconstruction_residual ship in the kit engine

Rules of thumb

Heuristics that hold across most model-based FDI work, before the method-specific detail.

  1. A healthy residual is white and zero-mean. Engineer the generator so that holds, and then any departure, in correlation, mean, or magnitude, is a fault signal rather than noise.
  2. Whiteness and magnitude are different faults. Ljung-Box and Durbin-Watson catch time-correlation from unmodeled dynamics; NIS catches innovations that are the wrong size from a sensor or covariance fault. Run both, because each is blind to the other's case.
  3. Estimate the noise covariances from healthy data. A wrong measurement covariance R or process covariance Q makes the innovation consistency tests misfire with no fault present, so calibrate them before trusting an alarm.
  4. NIS is the online monitor, NEES is the design check. NIS uses only measured innovations; NEES needs the true state and so lives in Monte Carlo simulation during tuning.
  5. Judge a residual on a window, not a sample. One large innovation is noise; a sustained departure from the band is a fault. Set the band from the chi-square for the window length.
  6. Design for isolation, do not hope for it. Make each structured residual blind to a subset of faults so every fault leaves a unique firing signature, and verify the signature columns are distinct before deployment.
  7. Parity and observer residuals are duals. They span the same space; choose whichever exposes the fault directions you need and is numerically better conditioned.
  8. Validate whiteness on healthy data first. A residual generator fitted to the data it is tested on always looks whiter than it is, so confirm on a held-out healthy record.
Pick by scenario
ScenarioPick & whyKind
Model or regression residual, anything unmodeled?Ljung-Boxpools autocorrelations into one chi-squarewhiteness
Fast lag-one whiteness screenDurbin-Watsonone number near 2 when whitewhiteness
Residual drifts to one side of zeroruns testtoo few sign runs flags a biaswhiteness
Kalman innovations too large for their bandNISchi-square consistency, no truth neededinnovation
Is the filter overconfident before deployment?NEESMonte Carlo check against ground truthinnovation
Innovations correlated in timeinnovation whitenessLjung-Box on the innovation sequenceinnovation
Residual that cancels the unknown stateparity-space residualproject onto the parity spaceparity / isolation
Identify which sensor or actuator failedstructured residualsmatch the observed firing signatureparity / isolation

At a glance

The evaluators split by what they test: whether the residual is white, whether a filter's innovations are the right size, and which fault a residual bank points to.

MethodTestsNeeds truth?OutputKey assumption
Residual ACF / runswhiteness, biasnobars vs band, runs zstationary residual
Ljung-Boxwhiteness (pooled lags)noQ + p-valuestationary residual
Durbin-Watsonlag-1 autocorrelationnostatistic near 2screen only
Kalman innovationprediction errornoresidual + covarianceknown model, Q, R
NISinnovation magnitudenochi-square vs bandtrustworthy S
NEEScovariance honestyyes (Monte Carlo)chi-square vs banddesign-time truth
Parity residualstate-free detectionnoresidual vectorknown LTI model
Structured residualsisolationnofault signature matchdistinct signatures

Worked case study: a degrading sensor in an observer

A two-state process is watched by three sensors through a Kalman filter. At sample 200, sensor 2 begins to degrade, its noise inflating to several times its healthy level, while the process and the other sensors stay sound. Every number below is computed by the engine on the same 360-sample record.

Step 1

Generate the residual

The filter turns the model into a residual generator. Through healthy operation its innovations are zero-mean and sit inside their predicted covariance, exactly the white, correctly scaled sequence the theory promises. That clean baseline is what makes a later departure meaningful.

Step 2

Detect with NIS

The normalized innovation squared is consistent before the fault, averaging 2.58 against a chi-square expectation of 3 and well inside its upper band of 4.16. Once sensor 2 degrades, the innovations grow beyond what the filter predicts and the windowed NIS jumps to an average of 13.14, far outside the band, flagging the fault at sample 200 with no knowledge of the true state.

2026-06-20T15:53:06.078574 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
Step 3

Read the whiteness tests, and learn from their silence

Running Ljung-Box on the faulty channel is instructive: its p-value moves only from 0.125 to 0.054, and Durbin-Watson stays near 2 at 1.85. The residual is still white. That is correct, not a failure: a noise-variance fault makes the innovations larger but no more correlated in time, so the whiteness tests stay quiet while NIS fires. It is the cleanest argument for running both, since a whiteness test alone would have missed this fault and a magnitude test alone would miss a pure unmodeled dynamic.

Step 4

Isolate the fault

Normalizing each sensor's own innovation channel localizes the fault: after the change the channels read 0.86, 2.62, and 0.88 in standardized units, so only the second sensor's residual fires. In the structured-residual picture that firing pattern matches a single column of the signature matrix, isolating the fault to sensor 2, uniquely.

2026-06-20T15:53:07.379661 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
Verdict

What the chain delivered

A filter generated a white, self-scaled residual; NIS detected a degraded sensor the moment its innovations outgrew their predicted band; the whiteness tests stayed silent and, in doing so, told us the fault was a variance change rather than an unmodeled dynamic; and the per-channel structured residual isolated it to sensor 2. The reflex order for model-based FDI is: generate a residual, test whiteness and magnitude together, then isolate by signature. Detection says something changed; the pattern of which tests fire says what.

Going further

The opening sections evaluate and isolate a residual; this set builds it (observers, unknown-input and observer banks), makes the decision robust (matched detection, adaptive and guaranteed thresholds), estimates the fault size (augmented and PI observers, multiple models), sharpens the tests, and learns the residual from data. Two more explorers first.

D

GLR detection: fault size vs delay

going further

A step fault of adjustable size hits a white residual at a fixed onset. Watch the generalized likelihood ratio climb past its threshold, sooner for a large fault, perhaps never for a small one.

E

Adaptive vs fixed threshold

going further

The healthy residual grows with load. Raise the adaptive sensitivity and watch a load-scaled threshold suppress the false alarms a fixed threshold suffers at high load.

Residual generation: the other half

The opening sections evaluate a residual; these build it. A residual is only as good as its design, sensitive to faults and blind to disturbances, and the observer is the workhorse generator alongside the parity space.

Observer-based residual generation

going further

A Luenberger observer estimates the state from the model and the measurements; the leftover output error is the residual. By placing the error-dynamics poles, the designer fixes how fast it settles, and the fault detection filter shapes which faults appear in which directions.

In plain words

The parity space builds a residual from a window of data; an observer builds one recursively. It runs a model of the plant in parallel with the real system, correcting its state estimate by a gain times the output error, and that output error, the innovation, is the residual. Pole placement sets the eigenvalues of the estimation-error dynamics, trading settling speed against noise sensitivity. With the model correct the residual is near zero plus noise; a fault the observer does not model drives it away from zero. The Beard-Jones fault detection filter is the same idea with the gain chosen so that each fault forces the residual into a fixed, known direction, which turns detection into isolation.

Health-management example

A thermal model of a motor is run as an observer. In health the output residual sits in a tight band; when a cooling fault develops, the unmodeled heat drives the residual out of the band, and because the filter was designed with structured gains, the residual moves along the direction assigned to cooling faults.

2026-06-20T15:53:07.570680 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
L = luenberger_observer(A, C, poles=[0.2, 0.25])
res = observer_residual(A, B, C, L, u, y)["residual"]
# residual ~ noise under the model, steps away at a fault
Practical note

Pole placement is a tradeoff: fast poles reject disturbances quickly but amplify noise, slow poles smooth noise but lag a fault. The model must be good, since modelling error looks like a permanent fault. For multi-output systems the fault detection filter's directional design needs the fault directions to be distinguishable, which is a structural property to check before relying on isolation.

Luenberger (1971), IEEE TAC 16; Beard (1971) and Jones (1973); Chen & Patton (1999), Robust Model-Based FDI.

Unknown input observer

going further

An observer designed so its residual is decoupled from a disturbance that enters through a known direction. The residual then responds to faults but not to the disturbance, the classic route to a robust residual that does not false-alarm on a modelled nuisance input.

In plain words

A plain observer reacts to everything the model omits, including disturbances that are not faults, so it false-alarms whenever a known disturbance acts. The unknown input observer removes that sensitivity by construction. Given the direction through which the disturbance enters the state, it transforms the estimation so the disturbance is annihilated, leaving error dynamics that the designer can still stabilize by pole placement. The residual is then provably insensitive to that disturbance while remaining sensitive to faults in other directions. The price is an existence condition, the disturbance must be observable in a particular sense, rank of the output times the disturbance direction equal to the disturbance dimension, and detectability of the decoupled pair.

Health-management example

A vehicle thermal observer is disturbed by ambient temperature swings that are not faults. Designing an unknown input observer that decouples the ambient direction leaves a residual flat through the swings, so when a sensor bias appears the residual rises against a quiet background instead of being lost in disturbance-driven noise.

2026-06-20T15:53:07.678966 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py (needs rank(C E) = rank(E))
uio = unknown_input_observer(A, B, C, E, poles=[0.2, 0.25])
res = uio_residual(uio, u, y)["residual"]
# flat under the disturbance E, responsive to faults
Practical note

The decoupling only holds for the disturbance directions you build in, so the disturbance model must be right; an unmodelled disturbance still leaks through. The existence conditions can fail, in which case no full-order unknown input observer exists and you fall back to an approximate or optimal robust design. Decoupling can also reduce fault sensitivity, so check the fault is still detectable after decoupling.

Chen & Patton (1999), Robust Model-Based FDI; Darouach et al. (1994), IEEE TAC 39.

Dedicated and generalized observer schemes

going further

A bank of observers, each driven by a different subset of the sensors, so a fault in one sensor disturbs every observer except the one that ignores it. The pattern of which residuals fire isolates the faulty sensor, turning a bank of detectors into an isolator.

In plain words

One residual detects; isolation needs several with a known fault-to-residual pattern. The generalized observer scheme builds that pattern structurally. It runs one observer per sensor, each using all the outputs except its own, so observer i is by construction insensitive to a fault in sensor i but sensitive to faults in any other sensor. When sensor i fails, every observer except the i-th sees the corrupted signal and its residual fires, while observer i stays clean, so the one quiet residual names the fault. The dedicated observer scheme is the dual arrangement, each observer driven by a single sensor. Both convert a set of observers into a structured-residual isolator without a hand-built signature table.

Health-management example

A three-sensor drive has a bank of three observers, each excluding one sensor. A bias on sensor two lifts the residuals of the observers that use sensor two while the observer that excludes it stays in band, so the clean observer points directly at sensor two as the fault.

2026-06-20T15:53:07.774807 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
gos = generalized_observer_scheme(A, B, C, poles=[0.2, 0.25], u=u, y=y)
gos["norms"]      # residual norm per observer
gos["cleanest"]   # the observer that excludes the faulty sensor
Practical note

Isolation needs the faults to be structurally distinguishable; sensors that always appear together cannot be told apart by exclusion. Each observer must be detectable from its reduced sensor set, which can fail when too many sensors are dropped. The scheme isolates sensor faults cleanly but actuator and process faults need the dual or a mixed design.

Clark (1978), IEEE TAES; Frank (1990), Automatica 26; Chen & Patton (1999).

Robust evaluation and thresholds

A residual still has to be turned into a decision. These three sharpen that step: a matched detector for an abrupt fault, a threshold that follows the operating point, and a deterministic bound for guaranteed detection.

Generalized likelihood ratio detection

going further

A matched detector for an abrupt change of unknown size and onset in the residual mean. It maximizes the log-likelihood ratio over all recent onset times, giving a statistic that is quiet under the no-fault model and spikes once a bias appears, the residual-domain bridge to your change-detection methods.

In plain words

A fixed threshold on the raw residual ignores that a fault is often a structured change, a step in the mean, not just a single large sample. The generalized likelihood ratio test exploits that structure. For every possible onset time in a recent window it computes how much more likely the data are under a hypothesized step than under no change, and takes the maximum, so it is matched to a step of any size starting at any time. The statistic stays near zero while the residual is white and rises sharply once a sustained bias accumulates, with a chi-square threshold from the likelihood theory. It is the same sequential-detection logic as the CUSUM in your change-detection work, specialized to a residual with known noise covariance.

Health-management example

An observer residual on a sensor channel is white until a slow bias begins. The raw residual barely clears its band on any single sample, but the generalized likelihood ratio, accumulating the consistent offset since its onset, climbs past its threshold and declares the fault with an estimate of when it started.

2026-06-20T15:53:07.906641 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
glr = glr_detector(residual, sigma=sigma_healthy, m_max=40)
fault = glr["glr"] > glr["threshold"]   # spikes after an abrupt bias
Practical note

It assumes the no-fault residual covariance is known, so estimate it from healthy data and re-check it. The search over onsets costs computation that grows with the window, so cap the window to the longest delay you can tolerate. It is tuned for abrupt changes; a very gradual drift is better matched by a different alternative hypothesis.

Willsky & Jones (1976), IEEE TAC 21; Basseville & Nikiforov (1993), Detection of Abrupt Changes.

Adaptive thresholds

going further

A threshold that scales with the operating point. Model uncertainty makes the healthy residual grow with load, so a fixed threshold misses faults at low load or false-alarms at high load; an adaptive threshold tracks the load and holds the false-alarm rate roughly constant.

In plain words

In practice the no-fault residual is never exactly zero, because the model is never exact, and its spread typically grows with the input or load, the larger the excitation the larger the modelling error. A single fixed threshold cannot serve the whole envelope: set it for high load and small faults at low load slip through, set it for low load and normal high-load operation trips it. An adaptive threshold makes the bound a function of the operating point, widening with load by an amount tuned to the model uncertainty, so the effective false-alarm rate stays roughly constant across conditions. It is the simplest form of the robustness that the unknown input observer pursues structurally.

Health-management example

A drivetrain observer's residual swells during hard acceleration purely from modelling error. A fixed threshold flags every aggressive maneuver; a threshold that rises with torque demand stays quiet through normal aggressive driving and still catches a genuine fault that exceeds the load-scaled bound.

2026-06-20T15:53:08.018630 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
at = adaptive_threshold(residual, u=load, base=0.2, k_u=0.15)
alarm = at["alarm"]      # |residual| > base + k_u*|load|
Practical note

The threshold is only as honest as the uncertainty model behind it; calibrate the load scaling on healthy data across the full operating envelope, not one condition. Too aggressive a scaling hides real faults at high load, so bound how far it can widen. It addresses false alarms from known operating-point effects, not from unmodelled disturbances, which need a decoupling design.

Emami-Naeini et al. (1988), Automatica 24; Ding (2013), Model-Based Fault Diagnosis Techniques.

Set-membership (interval) residuals

going further

A deterministic alternative to statistical thresholds. Given bounded process and measurement disturbances, it propagates a guaranteed envelope for the residual; while the measurement stays inside, no fault is possible, so an exceedance is a guaranteed detection rather than a probable one.

In plain words

Statistical thresholds accept a false-alarm rate and a missed-detection rate; sometimes you want a guarantee instead. Set-membership detection provides one by replacing probability distributions with hard bounds. If the process and measurement disturbances are known to lie within stated limits, the no-fault residual must lie within an envelope that those limits imply, computed by propagating the bounds through the observer error dynamics to a steady-state interval. As long as the measurement stays inside the envelope, the data are consistent with the healthy model and no fault has occurred; the moment it steps outside, a fault is certain, not merely likely. The cost is conservatism, the bound is wide, so only sufficiently large faults are guaranteed to be caught.

Health-management example

A battery-pack model with bounded sensor error and bounded current disturbance yields a guaranteed voltage-residual envelope. Through normal operation the residual stays strictly inside; when a cell fault pushes it past the envelope, the detection is certain, which is valuable for a safety case that cannot rest on a probabilistic threshold.

2026-06-20T15:53:08.126800 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
iv = interval_observer(A, B, C, L, u, y, w_bound=0.06, v_bound=0.15)
alarm = iv["alarm"]      # leaving iv["envelope"] is a guaranteed detection
Practical note

The guarantee is only as good as the disturbance bounds; an optimistic bound gives false confidence, a loose one makes the envelope so wide that small faults are missed. The steady-state envelope here is conservative; tighter zonotopic or interval-observer methods reduce the wrapping but need more machinery and nonnegativity conditions. Use it where a guarantee matters more than sensitivity.

Milanese et al. (1996), Bounding Approaches to System Identification; Puig (2010), Int. J. Adapt. Control Signal Process.

Beyond detect and isolate: fault estimation

Detection says a fault exists, isolation says where; estimation says how large. These three reconstruct the fault signal itself, the step that lets you accommodate a fault rather than only flag it.

Fault-as-state estimation

going further

Estimate a fault's magnitude by adding it to the state vector as an extra, slowly-varying state and running a Kalman filter on the enlarged model. The estimated fault state tracks an additive bias, turning detection into identification with no new machinery.

In plain words

The most direct way to estimate a fault is to make it part of what the filter estimates. Augment the state vector with the fault, model it as nearly constant (a slow random walk), and write down how it enters the dynamics or the measurements. A Kalman filter on this augmented model then estimates the fault alongside the true state, so its dedicated component reads off the fault magnitude in real time. It reuses the entire Kalman machinery, including the innovation as a residual, and degrades gracefully, the fault estimate is smoothed by the filter rather than jumping on every sample. The assumption is that the fault is slow relative to the dynamics, which holds for biases, drifts, and developing faults but not for instantaneous jumps.

Health-management example

An actuator develops a constant offset. Augmenting the state with that offset and filtering, the augmented Kalman filter's fault state climbs from zero and settles at the true bias within a few time constants, so the controller can be told not just that the actuator is faulty but by how much to compensate.

2026-06-20T15:53:08.243726 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
af = augmented_fault_kalman(A, B, C, Q, R, fault_dir=B.ravel(), y=y, u=u)
af["fault"]      # estimated fault magnitude over time
Practical note

The fault must be observable from the augmented model; if it is not, the estimate drifts. The random-walk variance on the fault state tunes the speed-versus-noise tradeoff, like any process noise. It assumes a slow fault, so for abrupt faults pair it with a jump detector and reset, and watch that a persistent modelling error is not absorbed into the fault estimate.

Friedland (1969), IEEE TAC 14; Gillijns & De Moor (2007), Automatica 43.

PI and sliding-mode observers for reconstruction

going further

A proportional-integral observer adds an integral of the output error that accumulates into a fault estimate, reconstructing an additive fault online. The sliding-mode observer reconstructs it instead from the equivalent output-error injection, both recovering the fault signal rather than merely flagging it.

In plain words

A standard observer uses the output error proportionally to correct the state. A proportional-integral observer adds a second loop that integrates that error into an explicit fault estimate, so a persistent residual is driven into a reconstructed fault signal instead of biasing the state, much as integral action in a controller cancels a steady offset. The result tracks a developing fault, a drift or a ramp, in real time. The sliding-mode observer reaches the same goal by a different mechanism: it forces the estimation error to zero with a switching injection, and the average of that injection, the equivalent output-error injection, reconstructs the fault. Both turn the residual into an estimate of the fault itself, the basis for fault-tolerant control.

Health-management example

A slowly ramping sensor fault is reconstructed by a proportional-integral observer whose integral channel follows the ramp almost exactly, so downstream logic can subtract the reconstructed bias and keep using the sensor instead of discarding it.

2026-06-20T15:53:08.346585 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
pio = pi_observer(A, B, C, L, Kf=[0, 0.18], fault_dir=B.ravel(), u=u, y=y)
pio["fault"]     # reconstructed fault signal
Practical note

The integral and proportional gains trade reconstruction speed against noise amplification and must be tuned together for stability. The sliding-mode variant needs care with chattering, usually a smoothed switching law. Both assume the fault enters through a known direction and that the system remains observable with the fault present.

Beale & Shafai (1989), Int. J. Control; Edwards, Spurgeon & Patton (2000), Automatica 36.

Interacting multiple model

going further

A bank of Kalman filters, one per hypothesized mode, healthy and one or more faulty, mixed each step by a Markov transition matrix. The posterior probability of each mode both detects and isolates: a rising probability for a faulty model names the fault, tying this to your Bayesian toolkit.

In plain words

When a system can be in one of several regimes, healthy or various faulty ones, a single model cannot serve all of them. The interacting multiple model estimator runs a Kalman filter for each candidate mode and maintains a probability over which mode is active, governed by a Markov chain of mode transitions. At each step it mixes the filters' estimates according to those probabilities, updates each filter, and reweights the mode probabilities by how well each predicted the new measurement. The output is a soft, continuously updated belief over modes, so the probability of a faulty mode rising toward one is simultaneously a detection and an isolation, and the estimate stays good across the switch because the filters are blended rather than chosen abruptly.

Health-management example

A sensor degrades partway through a run. An interacting multiple model with a healthy mode and a high-noise faulty mode keeps almost all probability on the healthy mode until the degradation begins, then shifts decisively to the faulty mode, both flagging the fault and identifying it as the sensor-noise regime.

2026-06-20T15:53:08.550293 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
m0 = dict(F=A, H=C, Q=Q, R=R_healthy, x0=x0, P0=P0)
m1 = dict(F=A, H=C, Q=Q, R=R_faulty,  x0=x0, P0=P0)
imm = imm_filter(y, [m0, m1], Pi=[[0.97, 0.03], [0.03, 0.97]], u=u, B=B)
imm["mode_prob"]    # P(faulty mode) rises at the fault
Practical note

The mode set must contain the faults you expect; an unmodelled fault is forced into the nearest mode. The transition matrix encodes how often modes switch, and a too-sticky or too-jumpy choice blurs the result. Modes that fit the data equally well cannot be told apart, so design the faulty models to be distinguishable from healthy operation.

Blom & Bar-Shalom (1988), IEEE TAC 33; Mazor et al. (1998), IEEE TAES 34.

Sharper whiteness and consistency tests

The opening whiteness tests catch linear autocorrelation; these catch what they miss, periodic structure in the frequency domain, a change in variance, and a slow drift in the mean.

Cumulative periodogram

going further

Bartlett's whiteness test in the frequency domain. The normalized cumulative sum of the residual's periodogram follows the diagonal for white noise; periodic or colored structure makes it bow away beyond a Kolmogorov band, catching narrowband content the autocorrelation can smear.

In plain words

The autocorrelation tests look at the residual in the time domain and can blur a concentrated periodic component across several lags. The cumulative periodogram looks in the frequency domain instead. White noise has a flat spectrum, so the running cumulative sum of its periodogram rises in a straight diagonal line from zero to one across frequency. A periodic or colored residual has its power concentrated at some frequencies, so the cumulative curve climbs steeply there and bows away from the diagonal, and a Kolmogorov-type band says how far it may stray under whiteness before the departure is significant. It is especially good at exposing a single tonal component, a once-per-revolution leakage, that a time-domain test would dilute.

Health-management example

A residual that passes the Ljung-Box test still hides a faint tone at the shaft rate. Its cumulative periodogram shows a sharp step at that frequency, climbing well outside the band, revealing a periodic disturbance the lag-based test averaged away.

2026-06-20T15:53:08.654134 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
cp = cumulative_periodogram(residual)
cp["white"]      # False if the curve bows outside the band
Practical note

It assumes the rest of the residual is otherwise white, so a broadband-colored residual will fail for reasons other than a tone. The band is asymptotic, so it needs a reasonable record length. It is a complement to, not a replacement for, the time-domain whiteness tests; use them together.

Bartlett (1955), Stochastic Processes; Priestley (1981), Spectral Analysis and Time Series.

ARCH / heteroscedasticity test

going further

Engle's test for a time-varying residual variance. A residual can pass every mean-whiteness test yet have its variance cluster or grow, the signature of an intermittent or developing fault; regressing the squared residual on its own lags exposes it.

In plain words

The whiteness tests check that the residual has no correlation in its mean, but they say nothing about its variance, which can change over time even when the mean stays white. Such conditional heteroscedasticity, volatility that clusters, is itself a fault signature: an intermittent fault, a loosening part, or a developing wear process inflates the residual's spread in bursts. Engle's test detects it by regressing the squared residual on its own recent past; if the squared residual is predictable from its history, its variance is time-varying, and the test's statistic, the sample size times the regression's goodness of fit, is large with a small p-value. It catches a class of faults the mean-based tests are blind to.

Health-management example

A bearing residual stays zero-mean and uncorrelated, so the whiteness tests pass, but its variance swells in bursts as a spall intermittently loads. The ARCH test, seeing the squared residual cluster, returns a tiny p-value and flags the changing variance the other tests missed.

2026-06-20T15:53:08.833742 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
at = arch_test(residual, lags=5)
at["heteroscedastic"], at["pvalue"]   # variance change in the residual
Practical note

It assumes the mean is already white, so remove any mean structure first or the test conflates the two. The number of lags sets which timescale of volatility you probe. A significant result says the variance is time-varying, not why, so follow it with a look at when the bursts occur.

Engle (1982), Econometrica 50; Bollerslev (1986), J. Econometrics 31.

CUSUM of residuals (structural stability)

going further

The Brown-Durbin-Evans cumulative-sum test for a slow drift in the residual mean. The running sum of standardized residuals stays within a widening boundary if the model holds throughout; crossing it signals that the mean drifted, a slow fault or a model that has stopped fitting.

In plain words

A fault need not be abrupt; a residual mean can creep away from zero so gradually that no single sample looks wrong and a whiteness test on a short window sees nothing. Accumulating the residual exposes the creep. The Brown-Durbin-Evans test forms the cumulative sum of standardized residuals and compares its path to a pair of boundary lines that widen with time to keep a constant false-alarm rate under stability. While the residual is genuinely zero-mean the cumulative sum wanders like a bounded random walk and stays inside; once a persistent bias sets in, the sum drifts steadily and eventually breaches a boundary, dating the loss of fit. It is the model-validation counterpart of the CUSUM change detectors, watching for structural drift rather than an abrupt jump.

Health-management example

A model fitted on fresh hardware slowly degrades as a component ages. Each window's residual looks acceptable, but the cumulative sum drifts monotonically and breaches its boundary after weeks, flagging that the model no longer represents the aged system and should be re-identified.

2026-06-20T15:53:08.936657 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
cu = cusum_residual(residual)
cu["stable"]     # False if the path breaches the boundary (drift)
Practical note

The standardization assumes the no-fault residual is zero-mean with an estimated variance; a biased baseline trips it immediately. It is tuned for slow drift, so an abrupt jump is better served by a CUSUM or generalized likelihood ratio detector. Reading where the path breaches dates the drift but only approximately.

Brown, Durbin & Evans (1975), J. R. Stat. Soc. B 37; Ploberger & Kramer (1992), Econometrica 60.

Data-driven residual generation

When no first-principles model is at hand, the residual can be learned from healthy data. These two close the loop: parity relations identified from data, and a reconstruction residual from a learned subspace.

Data-driven parity space

going further

Parity relations identified directly from healthy input-output data rather than from a state-space model. The left null space of a stacked data matrix gives generators that annihilate normal data, so applied to new data they yield a residual that grows under a fault, the model-free counterpart to the parity space.

In plain words

The classical parity space needs a state-space model to compute the relations that should hold among inputs and outputs. When such a model is unavailable or untrustworthy, the same relations can be learned from data. Stacking windows of inputs and outputs from healthy operation into a matrix, its dominant directions describe how normal data move, and its smallest directions, the left null space, are combinations that are essentially constant, zero up to noise, for any healthy window. Those combinations are the data-driven parity relations: apply them to a fresh window and the result stays near zero in health and departs under a fault. It is identification and residual design in one, closely related to subspace system identification, and it inherits the parity space's clean fault sensitivity without ever writing down a model.

Health-management example

A subsystem with no reliable physical model is logged through healthy operation. The learned parity relations produce a residual that hovers near zero on validation data and jumps cleanly when a sensor later biases, giving model-based-quality detection from data alone.

2026-06-20T15:53:09.043578 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
train = dd_parity(U_healthy, Y_healthy, s=2)
test = dd_parity(U_new, Y_new, s=2, W=train["W"], mean=train["mean"])
test["residual"]      # near zero in health, jumps at a fault
Practical note

The relations are only valid in the operating regime the training data covered, so a benign change of operating point can look like a fault; train across the full envelope or schedule by regime. It needs genuinely healthy, sufficiently exciting training data. The window order sets how much dynamics the relations capture, like the parity-window length.

Ding (2014), Data-Driven Design of Fault Diagnosis Systems; Qin (2012), Annu. Rev. Control 36.

Reconstruction residuals (PCA / autoencoder)

going further

A residual from a learned model of normal data: project onto the healthy subspace and reconstruct, and the reconstruction error is the residual. Small for normal operation, large when a fault pushes data off the learned manifold, it is the squared-prediction-error of the multivariate monitoring world.

In plain words

If the healthy data live near a low-dimensional surface, a model of that surface gives a residual for free. Principal component analysis finds the leading linear subspace of the healthy data; projecting a new sample onto it and reconstructing leaves an error, the part of the sample that does not lie on the healthy manifold. That reconstruction error, the squared-prediction-error or Q statistic, is small for normal operation and large when a fault moves the data off the manifold, so it is a residual built entirely from data. An autoencoder generalizes the idea to a nonlinear manifold, learning an encoder and decoder whose reconstruction error plays the same role, but the linear version already connects this directly to the Q statistic in your multivariate-monitoring toolkit.

Health-management example

Several correlated sensors normally move together on a two-dimensional subspace. When one drifts and breaks the correlation, the sample no longer reconstructs from the learned subspace, its reconstruction error jumps past the healthy control limit, and the off-manifold fault is flagged even though each sensor alone looks plausible.

2026-06-20T15:53:09.149950 image/svg+xml Matplotlib v3.10.8, https://matplotlib.org/
# see scripts/residuals.py
rc = reconstruction_residual(X_train_healthy, X_test, k=2)
fault = rc["spe"] > rc["limit"]      # off-manifold data exceeds the Q limit
Practical note

The number of retained components sets the split between signal and residual; too many and faults hide in the kept subspace, too few and normal variation inflates the residual. It assumes the fault leaves the learned manifold, so a fault that moves data along it is invisible to the reconstruction error and needs a complementary score. Standardize features and validate the control limit out of sample.

Jackson & Mudholkar (1979), Technometrics 21; Qin (2003), J. Chemometrics 17.

References

  1. Box & Pierce (1970), JASA 65; Brockwell & Davis (1991), Time Series: Theory and Methods.
  2. Ljung & Box (1978), Biometrika 65; Box, Jenkins & Reinsel (2008), Time Series Analysis.
  3. Durbin & Watson (1950, 1951), Biometrika 37 and 38.
  4. Kalman (1960), J. Basic Eng. 82; Mehra & Peschon (1971), Automatica 7 (innovations approach).
  5. Bar-Shalom, Li & Kirubarajan (2001), Estimation with Applications to Tracking and Navigation.
  6. Bar-Shalom, Li & Kirubarajan (2001); Mehra & Peschon (1971), Automatica 7.
  7. Chow & Willsky (1984), IEEE Trans. Automat. Control 29; Gertler (1998), Fault Detection and Diagnosis in Engineering Systems.
  8. Gertler (1998), Fault Detection and Diagnosis; Isermann (2006), Fault-Diagnosis Systems; Ding (2013), Model-Based Fault Diagnosis Techniques.

Take it into your editor

The same content is packaged as a VS Code Copilot skill in the progressive-disclosure layout: a routing SKILL.md, per-topic reference docs, a tested residuals.py engine that implements every method with numpy and scipy only, and Copilot integration files (a residual-analysis chat mode, a /diagnose prompt, and path-scoped instructions). Drop the folder into your kit, or point Copilot at the skill directly.

residual-analysis-kit.zip
SKILL.md · references · residuals.py · Copilot agent + prompt

Link resolves when the zip sits beside this file. Run python scripts/residuals.py for the self-test on synthetic residual and innovation data.