Out-of-Control Rule Detection: Nelson and Western Electric Rules for Automated SPC
A single point beyond three sigma is the crudest out-of-control signal a control chart can raise, and on its own it misses most real process changes. Sustained one-sided runs, slow trends, and hugging patterns all live inside the limits, invisible to a bare threshold test. This guide specifies how to automate the Nelson and Western Electric run rules in Python as part of automated control chart generation and calculation, turning a plotted series into a structured stream of alarm codes.
Run-rule detection is the layer that reads pattern, not just position. It divides the space around the centerline into probability zones, evaluates a battery of pattern tests over rolling windows, and emits a machine-readable violation for every rule that fires. Get the statistics right and the chart catches a 1.5-sigma drift long before any point punches through a limit; get the false-alarm arithmetic wrong and the same battery buries the operator in noise. Both outcomes are engineered here, deliberately.
What Breaks Without This
A chart that only tests for points outside three sigma has an in-control average run length of roughly 370 samples — it takes, on average, 370 in-control points before it cries wolf once. That sounds safe until a small sustained shift arrives: a 1-sigma mean shift on an individuals chart may take a hundred or more points to push a single value past a limit, so the process runs off-target for a full shift before the bare test notices. The signal was there the whole time, encoded as a long run of points sitting on one side of the centerline, but nobody was reading runs.
The opposite failure is self-inflicted. Bolt on all eight Nelson rules plus all four Western Electric rules, evaluate them independently, and the alarm rates add up. Each rule carries its own Type I error, and because the tests fire on overlapping windows the combined in-control average run length collapses far below the 370 of the single test — often to well under 100. Operators learn that the chart alarms constantly, stop investigating, and the whole detection layer becomes decorative. Effective automation is the discipline of choosing the smallest rule set that catches the shifts you actually care about, then wiring each firing to a documented reaction rather than a shrug.
Statistical Specification
Every run rule is defined relative to the standard deviation of the plotted statistic, not raw engineering tolerance. The chart space is partitioned into three symmetric zones on each side of the centerline. With centerline $\mu$ (the frozen grand mean or subgroup-mean centerline) and process sigma $\sigma$ (the estimate of the plotted statistic's standard deviation), the boundaries are:
$$\text{Zone C: } \mu \pm 1\sigma \qquad \text{Zone B: } \mu \pm 2\sigma \qquad \text{Zone A: } \mu \pm 3\sigma$$
Zone C is the band from the centerline out to $\mu \pm 1\sigma$, Zone B runs from $\mu \pm 1\sigma$ to $\mu \pm 2\sigma$, and Zone A runs from $\mu \pm 2\sigma$ to $\mu \pm 3\sigma$. The outer edge of Zone A is the control limit itself:
$$\text{UCL} = \mu + 3\sigma \qquad \text{LCL} = \mu - 3\sigma$$
The single non-negotiable rule of automation is that $\mu$ and $\sigma$ are frozen from a stable Phase I baseline, not recomputed from the window under test. The rules ask "how does this data behave relative to the established process?" — a question that has no meaning if the yardstick moves with the data. On a subgroup chart, $\sigma$ for the plotted mean is $\bar{R}/(d_2\sqrt{n})$, which is exactly the quantity that folds into the $A_2$ constant; source it from the same X-Bar R chart implementation that produced the limits, never re-estimate it from live data. Feeding rules a rolling sigma is the classic way to hide a real shift, the same trap described under limit chasing.
A standardized (z-score) transform makes the zone tests uniform regardless of the underlying units:
$$z_i = \frac{x_i - \mu}{\sigma}$$
After this transform, Zone A is $2 \le |z| < 3$, Zone B is $1 \le |z| < 2$, Zone C is $|z| < 1$, and a beyond-limit point is $|z| \ge 3$. Every rule below reduces to counting how points fall across these integer bands.
The rule sets at a glance
Nelson's eight rules (Nelson, 1984) and the Western Electric four core rules (Western Electric Statistical Quality Control Handbook, 1956) overlap heavily; Nelson is effectively an extension. The table summarizes the patterns each detects.
| Rule | Pattern | Typical process cause |
|---|---|---|
| Nelson 1 / WE 1 | 1 point beyond Zone A ($ | z |
| Nelson 2 | 9 points in a row on one side of center | Sustained mean shift, new material lot |
| Nelson 3 | 6 points in a row steadily increasing or decreasing | Tool wear, bath depletion, thermal drift |
| Nelson 4 | 14 points in a row alternating up and down | Overadjustment, two alternating fixtures/heads |
| Nelson 5 / WE 2 | 2 of 3 consecutive points in Zone A or beyond, same side | Moderate mean shift beginning |
| Nelson 6 / WE 3 | 4 of 5 consecutive points in Zone B or beyond, same side | Smaller sustained shift |
| Nelson 7 | 15 points in a row within Zone C (either side) | Stratification, reduced variation, wrong limits |
| Nelson 8 | 8 points in a row with none in Zone C | Mixture of two process streams |
| WE 4 | 8 points in a row on one side of center | Sustained mean shift (WE's shorter run) |
Nelson 1 and WE 1 are the same beyond-limit test. Nelson 2 uses a run of nine while WE 4 uses a run of eight for the one-sided run — pick one convention per chart, do not run both, or you double-count the same signal.
When to Use vs Alternatives
The choice of rule set is a false-alarm budget, not a matter of taste.
- Western Electric four rules are the conservative default for most Shewhart charts. They add sensitivity to small and moderate shifts over the bare limit test while keeping the combined in-control average run length in a defensible range. Choose them when operators are new to run rules or when alarm fatigue is already a concern.
- Nelson eight rules add trend (rule 3), oscillation (rule 4), stratification (rule 7), and mixture (rule 8) detection. Enable the extra four only where those failure modes are physically plausible — rule 3 on a tool-wear characteristic, rule 4 where two heads alternate. Enabling all eight everywhere is how the compound false-alarm rate collapses.
- EWMA or CUSUM replace run rules entirely when the mission is fast detection of small sustained shifts. They accumulate evidence across points rather than testing fixed patterns, and they carry a single tunable false-alarm rate instead of a stack of them. Reach for them when you find yourself enabling Nelson 5 and 6 purely to catch drift.
Whichever set you pick, the rules are evaluated once, downstream of frozen limits, and the resulting violations flow two ways: into the dynamic Plotly control chart rendering layer as overlay markers so an operator sees which points fired which rule, and into the alerting layer so a signal becomes a documented action. The two long-form implementations below cover each rule family in full: implementing Nelson rules in Python builds all eight as vectorized masks, and Western Electric zone rules with pandas implements the four core rules with rolling windows.
Production-Ready Python Implementation
The RuleEngine below evaluates a configurable subset of rules against a series and a frozen limit set, returning a boolean DataFrame — one column per rule, one row per observation, True where that rule fires at that point. Every rule is vectorized; there are no per-point Python loops over the data. Violations are mapped to short, stable alarm codes so downstream systems key off the code, not the prose.
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
# Stable alarm codes: downstream ticketing/OCAP keys off these, never off prose.
ALARM_CODES: dict[str, str] = {
"R1_BEYOND_3S": "One point beyond 3 sigma",
"R2_RUN_9": "Nine points in a row on one side of center",
"R3_TREND_6": "Six points steadily increasing or decreasing",
"R4_ALT_14": "Fourteen points alternating up and down",
"R5_2OF3_ZONE_A": "Two of three consecutive points in Zone A or beyond",
"R6_4OF5_ZONE_B": "Four of five consecutive points in Zone B or beyond",
"R7_15_IN_C": "Fifteen points in a row within Zone C",
"R8_8_OUTSIDE_C": "Eight points in a row with none in Zone C",
}
@dataclass(frozen=True)
class FrozenLimits:
"""Phase I baseline. mu and sigma refer to the PLOTTED statistic."""
mu: float
sigma: float
def __post_init__(self) -> None:
if not np.isfinite(self.mu) or not np.isfinite(self.sigma):
raise ValueError("mu and sigma must be finite.")
if self.sigma <= 0:
raise ValueError("sigma must be positive; check the Phase I estimate.")
@dataclass
class RuleEngine:
"""Vectorized Nelson / Western Electric run-rule evaluator.
Rules run against FROZEN limits, never against statistics recomputed
from the data under test. Enable only the rules whose failure modes are
physically plausible for the characteristic, to protect the combined
false-alarm rate (each active rule shortens the in-control run length).
"""
limits: FrozenLimits
rules: tuple[str, ...] = field(
default_factory=lambda: tuple(ALARM_CODES.keys())
)
def evaluate(self, series: pd.Series) -> pd.DataFrame:
"""Return a boolean DataFrame: one column per enabled rule.
Parameters
----------
series : pd.Series
The plotted statistic in production sequence (e.g. subgroup means).
Raises
------
TypeError
If ``series`` is not a pandas Series.
ValueError
If an unknown rule code is requested.
"""
if not isinstance(series, pd.Series):
raise TypeError("series must be a pandas Series in production order.")
unknown = set(self.rules) - set(ALARM_CODES)
if unknown:
raise ValueError(f"Unknown rule codes: {sorted(unknown)}")
x = series.astype(float)
z = (x - self.limits.mu) / self.limits.sigma # standardized distance
# Side and absolute-zone helpers, all vectorized.
above = z > 0 # side of the centerline
in_a = z.abs() >= 2 # Zone A or beyond (either side)
in_b = z.abs() >= 1 # Zone B or beyond
in_c = z.abs() < 1 # within Zone C
out = pd.DataFrame(index=series.index)
dispatch = {
"R1_BEYOND_3S": lambda: z.abs() >= 3,
"R2_RUN_9": lambda: self._same_side_run(above, 9),
"R3_TREND_6": lambda: self._monotonic_run(x, 6),
"R4_ALT_14": lambda: self._alternating_run(x, 14),
"R5_2OF3_ZONE_A": lambda: self._k_of_n_same_side(in_a, above, 2, 3),
"R6_4OF5_ZONE_B": lambda: self._k_of_n_same_side(in_b, above, 4, 5),
"R7_15_IN_C": lambda: in_c.rolling(15).sum() >= 15,
"R8_8_OUTSIDE_C": lambda: (~in_c).rolling(8).sum() >= 8,
}
for code in self.rules:
out[code] = dispatch[code]().fillna(False).astype(bool)
return out
@staticmethod
def _same_side_run(above: pd.Series, n: int) -> pd.Series:
"""True where n consecutive points share a side of the centerline."""
run_hi = above.rolling(n).sum() >= n
run_lo = (~above).rolling(n).sum() >= n
return run_hi | run_lo
@staticmethod
def _monotonic_run(x: pd.Series, n: int) -> pd.Series:
"""True where n points strictly increase or strictly decrease."""
d = x.diff()
up = (d > 0).rolling(n - 1).sum() >= (n - 1)
dn = (d < 0).rolling(n - 1).sum() >= (n - 1)
return up | dn
@staticmethod
def _alternating_run(x: pd.Series, n: int) -> pd.Series:
"""True where the last n points strictly alternate direction."""
d = np.sign(x.diff())
flip = (d * d.shift(1)) < 0 # direction reversed at each step
return flip.rolling(n - 2).sum() >= (n - 2)
@staticmethod
def _k_of_n_same_side(zone: pd.Series, above: pd.Series,
k: int, n: int) -> pd.Series:
"""True where at least k of n consecutive points fall in ``zone``
on the SAME side of center."""
hi = (zone & above).rolling(n).sum() >= k
lo = (zone & ~above).rolling(n).sum() >= k
return hi | lo
def violations_to_codes(flags: pd.DataFrame) -> pd.Series:
"""Collapse a boolean rule DataFrame to one comma-joined code string per row.
Empty string where no rule fired. Suitable for logging or joining onto
the plotted frame before handing off to the alerting layer.
"""
def row_codes(row: pd.Series) -> str:
return ",".join(code for code, fired in row.items() if fired)
return flags.apply(row_codes, axis=1)
The engine treats mu and sigma as immutable inputs, which is what keeps evaluation honest. Note that rolling-window rules (2, 5, 6, 7, 8) naturally emit False for the leading points where the window is not yet full — that is correct, not a bug, and it mirrors the warm-up handling used elsewhere in the pipeline.
Validation and Testing
- Golden-pattern fixtures. For each enabled rule, construct the minimal series that should trip exactly that rule and assert it fires on the correct row and no other rule fires. A run of nine points at $+0.5\sigma$ must trip R2 and nothing else.
- Off-by-one boundaries. Nelson 2 fires at nine, not eight; a series of exactly eight one-sided points must leave R2
False. Test the boundary length and length minus one for every count-based rule. - Frozen-limit invariance. Evaluate the same series against the correct frozen limits and against limits re-estimated from the series; assert the results differ. If they match, sigma is being recomputed from the data somewhere and the detection is compromised.
- False-alarm budget. Feed a long in-control standard-normal series (
np.random.default_rng(0).standard_normal(5000)) through the enabled set and measure the empirical in-control average run length. Compare it to the single-test baseline of about 370; if it has collapsed below your tolerance, disable rules. - Sign symmetry. Mirror any fixture about the centerline and assert the same rule fires. An asymmetric result means a sign or
abserror in a zone test.
Failure Modes and Edge Cases
| Symptom | Root cause | Fix |
|---|---|---|
| Chart alarms almost constantly | All 8 Nelson + 4 WE rules enabled; compounded Type I error | Enable the smallest justified set; never run both the run-of-8 and run-of-9 conventions together |
| A known shift never fires | Sigma recomputed from the window under test, widening with the shift | Freeze mu and sigma from Phase I; pass them as immutable inputs |
| Trend rule fires on flat noisy data | Ties counted as increases via >= instead of strict > |
Use strict > / < in the monotonic test; ties break the run |
| Run rules fire on shuffled data | Series not in production sequence | Assert a monotonic, gap-checked index before evaluation |
| Same event raises two alarm codes | Overlapping conventions (Nelson 1 and WE 1, or runs of 8 and 9) | Choose one code per pattern; deduplicate before handing off to alerting |
| Leading rows all flagged or all blank unexpectedly | Rolling windows filled or not filled as expected | Confirm warm-up rows return False; do not backfill window results |
Compliance Notes
- AIAG SPC Reference Manual (2nd ed.) presents the Western Electric zone tests as the standard supplementary run rules and ties each firing to a required reaction. Automated detection must reference the frozen Phase I limits the manual's Phase I / Phase II split defines.
- Nelson (1984), Journal of Quality Technology is the original specification of the eight rules; cite it as the authority for the counts (9, 6, 14, 15, 8) rather than a spreadsheet copy.
- IATF 16949, Clause 9.1.1.1 requires a documented reaction to every out-of-control signal — a detected rule violation that triggers nothing is a non-conformance, which is why the out-of-control action plans (OCAP) layer must consume this output.
- NIST/SEMATECH e-Handbook, Section 6.3.2 documents the run and zone tests and the average-run-length reasoning behind budgeting them.
Frequently Asked Questions
Should I run both Nelson and Western Electric rules on the same chart?
No. They overlap almost completely — Nelson 1 and WE 1 are identical, and the one-sided run rule differs only in length (nine vs eight). Running both double-counts the same signals and inflates the false-alarm rate for nothing. Pick one convention per chart, document it, and stay consistent so downstream alarm codes remain unambiguous.
Why must the limits be frozen instead of recalculated per window?
The rules answer a question about how new data behaves relative to an established process. If sigma is re-estimated from the same window being tested, a genuine shift inflates that sigma, the zones widen with it, and the pattern hides inside its own enlarged limits. Freeze mu and sigma from a stable Phase I baseline and pass them as fixed inputs so the yardstick never moves with the data.
How much does adding rules raise the false-alarm rate?
Substantially and non-linearly. Each rule carries its own Type I error, and because they fire on overlapping windows the combined in-control average run length drops well below the single-test value of about 370 — often under 100 with the full eight enabled. Treat rule selection as a false-alarm budget: add a rule only when its specific failure mode is physically plausible for the characteristic.
What sigma do I use for the zone boundaries?
The standard deviation of the plotted statistic, not the raw individual readings and not the tolerance. On an X-bar chart that is the standard deviation of the subgroup mean, which equals R-bar divided by the product of d2 and the square root of n — the same quantity folded into the A2 constant. Source it from the calculation that produced the control limits so the zones and the limits are mutually consistent.
What happens after a rule fires?
A firing is only the detection half. Each violation code must trigger a documented reaction — investigate, adjust, contain, or escalate — defined in an out-of-control action plan. Route the alarm codes into the alerting and reaction layer so a signal reliably produces an action rather than a note nobody reads.
Related
- Implementing Nelson rules in Python — all eight rules as vectorized pandas masks, step by step
- Western Electric zone rules with pandas — the four core rules built on rolling windows
- Dynamic Plotly control chart rendering — overlay violation markers on the live chart
- X-Bar R chart implementation — the source of the frozen limits and sigma the rules run against
- Out-of-control action plans (OCAP) — turn a detected signal into a documented reaction
For the alerting and reaction layer that consumes these signals, see SPC Alerting and OCAP Response. For the surrounding calculation pipeline, see Automated Control Chart Generation and Calculation.