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):
- One point beyond Zone A (beyond three sigma).
- Two of three consecutive points in Zone A or beyond, on the same side of center.
- Four of five consecutive points in Zone B or beyond, on the same side of center.
- Eight points in a row on one side of the centerline.
Prerequisites
- Python 3.9+ with
numpyandpandasinstalled (pip install numpy pandas). - The plotted statistic as a
pandas.Seriesin production sequence. - A frozen centerline
muand 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 |
Related
- Implementing Nelson rules in Python — the eight-rule superset built as separate masks
- X-Bar R chart implementation — the source of the frozen centerline and sigma these rules test against
- SPC Alerting and OCAP Response — where a fired rule becomes a documented reaction
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.