Is the residual white?
whitenessSlide autocorrelation into the residual and watch the autocorrelation bars leave their band while Ljung-Box and Durbin-Watson flip from white to not white.
Residual & innovation analysis · model-based fault detection and isolation
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:
Does correlation survive in the residual, the fingerprint of a dynamic the model never captured?
Are a Kalman filter's innovations zero-mean and exactly as large as its own covariance claims?
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.
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.
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.
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).
Inject a fault and watch which structured residuals fire. The firing pattern is matched against the signature matrix to name the fault.
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.
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.
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.
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.
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)
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.
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.
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.
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.
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
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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
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).
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.
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.
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.
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
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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]])
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.
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 for | Kind |
|---|---|---|
| Does the model explain the data (residual white)? | Ljung-Box | whiteness |
| A fast one-number whiteness screen? | Durbin-Watson | whiteness |
| Does the residual linger on one side (bias)? | runs test | whiteness |
| Are a Kalman filter's innovations the right size? | NIS | innovation |
| Is the filter's covariance honest (a design check)? | NEES | innovation |
| Are the innovations correlated in time? | innovation whiteness | innovation |
| Build a residual that ignores the unknown state? | parity-space residual | parity / isolation |
| Which sensor or actuator failed? | structured residuals | parity / isolation |
Heuristics that hold across most model-based FDI work, before the method-specific detail.
| Scenario | Pick & why | Kind |
|---|---|---|
| Model or regression residual, anything unmodeled? | Ljung-Boxpools autocorrelations into one chi-square | whiteness |
| Fast lag-one whiteness screen | Durbin-Watsonone number near 2 when white | whiteness |
| Residual drifts to one side of zero | runs testtoo few sign runs flags a bias | whiteness |
| Kalman innovations too large for their band | NISchi-square consistency, no truth needed | innovation |
| Is the filter overconfident before deployment? | NEESMonte Carlo check against ground truth | innovation |
| Innovations correlated in time | innovation whitenessLjung-Box on the innovation sequence | innovation |
| Residual that cancels the unknown state | parity-space residualproject onto the parity space | parity / isolation |
| Identify which sensor or actuator failed | structured residualsmatch the observed firing signature | parity / isolation |
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.
| Method | Tests | Needs truth? | Output | Key assumption |
|---|---|---|---|---|
| Residual ACF / runs | whiteness, bias | no | bars vs band, runs z | stationary residual |
| Ljung-Box | whiteness (pooled lags) | no | Q + p-value | stationary residual |
| Durbin-Watson | lag-1 autocorrelation | no | statistic near 2 | screen only |
| Kalman innovation | prediction error | no | residual + covariance | known model, Q, R |
| NIS | innovation magnitude | no | chi-square vs band | trustworthy S |
| NEES | covariance honesty | yes (Monte Carlo) | chi-square vs band | design-time truth |
| Parity residual | state-free detection | no | residual vector | known LTI model |
| Structured residuals | isolation | no | fault signature match | distinct signatures |
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
# 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
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.
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.
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.
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.
# 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
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.
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.
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.
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.
# 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
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).
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.
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.
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.
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.
# see scripts/residuals.py
glr = glr_detector(residual, sigma=sigma_healthy, m_max=40)
fault = glr["glr"] > glr["threshold"] # spikes after an abrupt bias
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.
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 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.
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.
# 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|
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.
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.
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.
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.
# 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
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.
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.
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.
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.
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.
# 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
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.
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.
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.
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.
# 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
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.
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.
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.
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.
# 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
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.
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.
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.
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.
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.
# see scripts/residuals.py
cp = cumulative_periodogram(residual)
cp["white"] # False if the curve bows outside the band
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.
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.
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.
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.
# see scripts/residuals.py
at = arch_test(residual, lags=5)
at["heteroscedastic"], at["pvalue"] # variance change in the residual
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.
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.
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.
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.
# see scripts/residuals.py
cu = cusum_residual(residual)
cu["stable"] # False if the path breaches the boundary (drift)
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.
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.
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.
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.
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.
# 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
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.
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.
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.
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.
# 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
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.
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.
Link resolves when the zip sits beside this file. Run python scripts/residuals.py for the self-test on synthetic residual and innovation data.