Control charts in Python
The task: monitor a measured characteristic over time and get a defensible answer to "is this process stable?". This page shows the three-line version, then explains why the usual hand-rolled approach computes the wrong limits.
The three-line version
import shewhart as sw
r = sw.imr(df, value="torque", rules="nelson")
print(r.summary())
For subgrouped data (several measurements per batch or time slot):
r = sw.xbar_r(df, value="torque", subgroup="batch")
For counts (defective units, complaints, defects):
r = sw.p_chart(df, defectives="rejects", size="inspected")
r = sw.c_chart(df, defects="scratches")
Why mean ± 3*std is not a control chart
The most common hand-rolled pattern looks like this:
# wrong, but everywhere:
ucl = x.mean() + 3 * x.std()
Two problems, both of which change the limits:
- Wrong sigma. Control limits use the within-process variation,
estimated from short-term differences (the moving range for individuals,
R-bar or S-bar for subgroups), not the overall standard deviation. If the
process drifts,
x.std()absorbs the drift and inflates the limits, which is precisely the signal a chart exists to catch. For individuals the correct estimate ismean(|x[i] - x[i-1]|) / d2(2)withd2(2) = 2/sqrt(pi) = 1.12838. - Phase confusion. Limits are estimated once from a reference period (Phase I) and then frozen; new data is judged against the frozen limits (Phase II). Recomputing limits from each new batch silently moves the goalposts every week.
shewhart treats both as first-class:
# Phase I: estimate and freeze
sw.imr(df_2025, value="torque").baseline.save("line3_baseline.json")
# Phase II: judge new data against the frozen baseline
r = sw.imr(df_this_week, value="torque", limits="line3_baseline.json")
sys.exit(0 if r.ok else 1)
Choosing a chart
| Data | Chart |
|---|---|
| one measurement per period | sw.imr |
| n measurements per subgroup, n <= 8 or so | sw.xbar_r |
| larger subgroups | sw.xbar_s |
| subgroups of differing sizes | sw.xbar_s (stair-step limits) |
| defective units out of n inspected | sw.p_chart (varying n) or sw.np_chart (constant n) |
| defect counts per unit of opportunity | sw.c_chart (constant) or sw.u_chart (varying) |
| small sustained shifts matter | sw.ewma |
Variable subgroup sizes
When subgroups have different sizes (a common case once you subgroup by a
time window), sw.xbar_s estimates sigma once from the pooled
within-subgroup variance and draws each subgroup's limits for its own size:
r = sw.xbar_s(df, value="torque", subgroup="shift")
r.table[["n", "mean_lcl", "mean_ucl", "stdev_lcl", "stdev_ucl"]]
The limits become a stair-step, so the scalar limit keys are absent from
r.stats (they live per row in the table). sw.xbar_r still needs equal
sizes; ranges and the average-range estimator assume a constant n.
Calendar windows are the common source of differing sizes. On a
DatetimeIndex, subgroup="W" (or "ME", "QE") groups each row into its
week or month, and the weeks rarely hold the same number of points:
r = sw.xbar_s(df, value="torque", subgroup="W") # one subgroup per week
Where the numbers come from
Constants like d2 and the limit factors are computed from their defining integrals and verified against Montgomery's tables and NIST reference data in the validation suite. The run rules follow Nelson (1984) and the Western Electric handbook; see Nelson and Western Electric rules.