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.

Control chart zones A, B, and C with two flagged out-of-control patterns A control chart centered on a horizontal centerline. Symmetric shaded bands mark the sigma zones: Zone C spans zero to one sigma on each side of the centerline, Zone B spans one to two sigma, and Zone A spans two to three sigma, with the three-sigma control limits as the outer boundary. Two patterns are flagged. A single point sits above the upper three-sigma limit and is marked as a beyond-limit violation. Further along, a run of eight consecutive points all sit below the centerline and is bracketed as a one-sided run violation. The diagram shows that some signals are single extreme points while others are patterns that stay inside the limits. +3σ UCL Zone A Zone B Zone C center Zone C Zone B Zone A −3σ LCL beyond 3σ run of 8 below center — inside the limits
Zones A, B, and C are symmetric bands at two-to-three, one-to-two, and zero-to-one sigma. A lone point breaches the upper limit; a run of eight below the centerline never leaves the limits yet still signals a shifted process.

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 abs error 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.

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.