Western Electric Zone Rules with pandas

The four Western Electric rules are the conservative core of run-rule detection: one beyond-limit test and three zone-density tests that add sensitivity to small shifts without the false-alarm load of a larger battery. This guide implements all four with pandas rolling windows, building from a single signed zone score rather than a stack of separate boolean columns. It is the four-rule companion within out-of-control rule detection with Nelson and Western Electric rules, and it deliberately takes a different code path from the mask-per-rule Nelson build so you can see both idioms.

The four rules, as stated in the Western Electric Statistical Quality Control Handbook (1956):

  1. One point beyond Zone A (beyond three sigma).
  2. Two of three consecutive points in Zone A or beyond, on the same side of center.
  3. Four of five consecutive points in Zone B or beyond, on the same side of center.
  4. Eight points in a row on one side of the centerline.

Prerequisites

  • Python 3.9+ with numpy and pandas installed (pip install numpy pandas).
  • The plotted statistic as a pandas.Series in production sequence.
  • A frozen centerline mu and plotted-statistic sigma from a stable Phase I baseline. Source them from the calculation that produced your control limits — for subgroup charts, the X-Bar R chart implementation — so the zones and limits agree. Never re-estimate sigma from the series under test.
  • A monotonic, gap-checked index; rolling windows assume adjacency means production adjacency.
  • Agreement on the one-sided run length. Western Electric uses eight; Nelson uses nine. Do not run both against the same chart.

Step 1 — Encode each point as a signed zone score

Instead of separate boolean columns per zone, collapse each point to a single signed integer that names its band: +3/-3 beyond Zone A, +2/-2 in Zone A, +1/-1 in Zone B, 0 in Zone C. Every rule below is then a rolling test over this one column, which is easier to log and to audit than a wide boolean frame.

from __future__ import annotations

import numpy as np
import pandas as pd


def signed_zone_score(series: pd.Series, mu: float, sigma: float) -> pd.Series:
    """Map each point to a signed zone code against frozen limits.

    Returns integers: +/-3 beyond 3 sigma, +/-2 Zone A, +/-1 Zone B, 0 Zone C.
    The sign encodes the side of center; the magnitude encodes the band.
    """
    if not isinstance(series, pd.Series):
        raise TypeError("series must be a pandas Series in production order.")
    if sigma <= 0 or not np.isfinite(sigma) or not np.isfinite(mu):
        raise ValueError("mu must be finite and sigma a positive finite Phase I estimate.")

    z = (series.astype(float) - mu) / sigma
    magnitude = np.select(
        [z.abs() >= 3, z.abs() >= 2, z.abs() >= 1],
        [3, 2, 1],
        default=0,
    )
    return pd.Series(magnitude * np.sign(z), index=series.index, name="zone_score").astype(int)

The sign is the key trick: any "same side" test reduces to checking that the scores share a sign, and any band test reduces to a magnitude threshold.

Step 2 — Rule 1: one point beyond Zone A

A magnitude of three means the point reached or passed three sigma on either side.

def we_rule_1(zone: pd.Series) -> pd.Series:
    """WE Rule 1: a single point beyond 3 sigma (zone magnitude 3)."""
    return (zone.abs() >= 3).rename("WE1_BEYOND_3S")

Step 3 — Rule 2: two of three in Zone A, same side

Count, over a rolling window of three, how many points are in Zone A or beyond and above center; separately, how many are in Zone A or beyond and below. The rule fires when either count reaches two. Splitting by side is essential — one point high in Zone A and one low in Zone A is not the pattern.

def we_rule_2(zone: pd.Series) -> pd.Series:
    """WE Rule 2: 2 of 3 consecutive points in Zone A or beyond, same side."""
    hi = (zone >= 2).rolling(3).sum() >= 2   # upper Zone A or beyond
    lo = (zone <= -2).rolling(3).sum() >= 2  # lower Zone A or beyond
    return (hi | lo).rename("WE2_2OF3_ZONE_A")

Because zone >= 2 already encodes both "Zone A" (score 2) and "beyond" (score 3) on the upper side, the "or beyond" clause is free — no separate test needed.

Step 4 — Rule 3: four of five in Zone B, same side

Same structure, wider band and window. "Zone B or beyond" on the upper side is any score of at least one; on the lower side, at most minus one. The rule fires when four of the last five points meet the threshold on one side.

def we_rule_3(zone: pd.Series) -> pd.Series:
    """WE Rule 3: 4 of 5 consecutive points in Zone B or beyond, same side."""
    hi = (zone >= 1).rolling(5).sum() >= 4   # upper Zone B, A, or beyond
    lo = (zone <= -1).rolling(5).sum() >= 4  # lower Zone B, A, or beyond
    return (hi | lo).rename("WE3_4OF5_ZONE_B")

Note that a point exactly in Zone C has score 0, so it satisfies neither >= 1 nor <= -1 and correctly breaks the count on both sides.

Step 5 — Rule 4: eight in a row on one side

The one-sided run test. Side is just the sign of the score, treating a Zone C point by its sign too (a point above center but inside Zone C still counts as "above"). To classify side independent of band, take the sign of the underlying deviation rather than the zone magnitude, because a point can sit in Zone C (score 0) yet still be on a definite side.

def we_rule_4(series: pd.Series, mu: float, n: int = 8) -> pd.Series:
    """WE Rule 4: n consecutive points on one side of center (default 8)."""
    above = (series.astype(float) - mu) > 0
    run_hi = above.rolling(n).sum() >= n
    run_lo = (~above).rolling(n).sum() >= n
    return (run_hi | run_lo).rename("WE4_RUN_8")

Rule 4 takes the raw series and mu directly rather than the zone score, precisely because the zone score is 0 inside Zone C and would lose the side information the run test needs.

Assemble the four into one frame:

def all_western_electric_rules(series: pd.Series, mu: float, sigma: float) -> pd.DataFrame:
    """Evaluate the four core Western Electric rules against frozen limits.

    Returns a boolean DataFrame, one column per rule, warm-up rows False.
    """
    zone = signed_zone_score(series, mu, sigma)
    frame = pd.concat(
        [
            we_rule_1(zone),
            we_rule_2(zone),
            we_rule_3(zone),
            we_rule_4(series, mu),
        ],
        axis=1,
    )
    return frame.fillna(False).astype(bool)

Verification

Prove each rule in isolation with mu = 0, sigma = 1, so the series equals its own z-score, then assert the correct rule fires on the correct row without cross-fires.

import pandas as pd

# Rule 2: two of three points in upper Zone A (2.2, 2.4 sigma) fires on the 3rd.
s2 = pd.Series([2.2, 0.3, 2.4])
r2 = all_western_electric_rules(s2, mu=0.0, sigma=1.0)
assert r2["WE2_2OF3_ZONE_A"].iloc[-1]
assert not r2["WE1_BEYOND_3S"].any()          # nothing reached 3 sigma

# Rule 3: four of five points in lower Zone B or beyond.
s3 = pd.Series([-1.2, -1.5, 0.2, -1.1, -1.3])
r3 = all_western_electric_rules(s3, mu=0.0, sigma=1.0)
assert r3["WE3_4OF5_ZONE_B"].iloc[-1]

# Rule 4: eight consecutive points below center fires on the 8th, not the 7th.
s4 = pd.Series([-0.4] * 8)
r4 = all_western_electric_rules(s4, mu=0.0, sigma=1.0)
assert r4["WE4_RUN_8"].iloc[-1]
assert not r4["WE4_RUN_8"].iloc[6]            # seven is not enough

# Rule 1: a lone point at exactly 3 sigma.
r1 = all_western_electric_rules(pd.Series([0.0, 3.0, 0.1]), 0.0, 1.0)
assert r1["WE1_BEYOND_3S"].tolist() == [False, True, False]

# Same-side guard: one high and one low Zone A point must NOT trip rule 2.
mixed = all_western_electric_rules(pd.Series([2.3, 0.1, -2.3]), 0.0, 1.0)
assert not mixed["WE2_2OF3_ZONE_A"].any()

print("all Western Electric rule fixtures verified")

The last assertion is the one people miss: two Zone A points on opposite sides of the centerline are not a rule-2 signal, and the same-side split is what keeps the mask honest.

Root-cause table

Symptom Cause Fix
Rule 2 fires on opposite-side Zone A points Zone A counted without splitting by side of center Count zone >= 2 and zone <= -2 separately; fire if either reaches two
Rule 4 misses a run that hugs the centerline Side inferred from the zone score, which is 0 inside Zone C Classify side from the raw deviation series - mu, not the zone magnitude
Rule 3 undercounts near the boundary Zone C points (score 0) wrongly credited to a side Zone C has no sign in the band test; require magnitude at least one to count
A known shift never fires Sigma re-estimated from the window under test Freeze mu and sigma from Phase I and pass them as fixed inputs
Rule 1 and a Nelson rule both flag the same point Western Electric and Nelson sets run together Choose one set per chart; the beyond-limit and one-sided run tests overlap
Leading rows never fire Rolling window not yet full during warm-up Expected; provide enough history or accept the warm-up gap, do not backfill

For the zone model, the false-alarm budget behind choosing four rules over eight, and the alarm-code mapping downstream, see out-of-control rule detection with Nelson and Western Electric rules.