Code Library
Quality & InspectionPythonPython 3.8+ · matplotlibMIT licenseIntermediate

X-bar and R control chart from a CSV (Python + matplotlib)

Every quality tech eventually rebuilds this from scratch: subgroup a run of measurements, compute the mean and range of each subgroup, work out control limits from the standard published SPC constants, and plot both series against those limits. This script does exactly that — no statistics library, just the textbook A2/D3/D4 constants for subgroup sizes 2 through 10 — and flags which subgroups actually fall outside the X-bar limits.

Before you run it

  • pip install matplotlib
  • A CSV of subgroup measurements: one row per subgroup, one column per sample, no header, all subgroups the same size (2–10 samples)

The code

GitHub
"""Plot an X-bar and R control chart from a CSV of subgroup measurements.

Each row is one subgroup; each column is one sample within it.
No header row. Example (n=5 samples per subgroup):
    20.02,19.98,20.01,20.00,19.99
    20.05,20.01,19.99,20.03,20.02
    ...

Usage:  python xbar_r_chart.py measurements.csv --out chart.png
"""

import argparse
import csv

import matplotlib.pyplot as plt

# Standard SPC constants for X-bar/R charts, subgroup size 2-10
# (A2 for X-bar limits, D3/D4 for R limits)
CONSTANTS = {
    2: (1.880, 0, 3.267),
    3: (1.023, 0, 2.575),
    4: (0.729, 0, 2.282),
    5: (0.577, 0, 2.114),
    6: (0.483, 0, 2.004),
    7: (0.419, 0.076, 1.924),
    8: (0.373, 0.136, 1.864),
    9: (0.337, 0.184, 1.816),
    10: (0.308, 0.223, 1.777),
}


def read_subgroups(path):
    with open(path, newline="") as fh:
        return [[float(v) for v in row if v.strip()] for row in csv.reader(fh) if row]


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("csv_file", help="CSV of subgroup measurements, no header")
    ap.add_argument("--out", default="xbar_r_chart.png")
    args = ap.parse_args()

    subgroups = read_subgroups(args.csv_file)
    n = len(subgroups[0])
    if n not in CONSTANTS:
        raise SystemExit(f"No published A2/D3/D4 constants for subgroup size {n} "
                          f"(supported: {sorted(CONSTANTS)})")
    if any(len(g) != n for g in subgroups):
        raise SystemExit("Every subgroup must have the same number of samples")

    a2, d3, d4 = CONSTANTS[n]

    means = [sum(g) / n for g in subgroups]
    ranges = [max(g) - min(g) for g in subgroups]

    x_bar_bar = sum(means) / len(means)   # grand mean - the chart's centerline
    r_bar = sum(ranges) / len(ranges)     # average range - the R chart's centerline

    ucl_x = x_bar_bar + a2 * r_bar
    lcl_x = x_bar_bar - a2 * r_bar
    ucl_r = d4 * r_bar
    lcl_r = d3 * r_bar

    out_of_control = [i + 1 for i, m in enumerate(means) if m > ucl_x or m < lcl_x]

    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(9, 7), sharex=True)

    ax1.plot(range(1, len(means) + 1), means, "o-", color="#1d4ed8")
    ax1.axhline(x_bar_bar, color="black", linewidth=1)
    ax1.axhline(ucl_x, color="#dc2626", linestyle="--", linewidth=1)
    ax1.axhline(lcl_x, color="#dc2626", linestyle="--", linewidth=1)
    ax1.set_title(f"X-bar chart (n={n}, centerline={x_bar_bar:.4f})")
    ax1.set_ylabel("Subgroup mean")

    ax2.plot(range(1, len(ranges) + 1), ranges, "o-", color="#0891b2")
    ax2.axhline(r_bar, color="black", linewidth=1)
    ax2.axhline(ucl_r, color="#dc2626", linestyle="--", linewidth=1)
    if lcl_r > 0:
        ax2.axhline(lcl_r, color="#dc2626", linestyle="--", linewidth=1)
    ax2.set_title(f"R chart (centerline={r_bar:.4f})")
    ax2.set_xlabel("Subgroup")
    ax2.set_ylabel("Range")

    fig.tight_layout()
    fig.savefig(args.out, dpi=150)

    print(f"X-bar-bar={x_bar_bar:.4f}  UCL={ucl_x:.4f}  LCL={lcl_x:.4f}")
    print(f"R-bar={r_bar:.4f}  UCL_R={ucl_r:.4f}  LCL_R={lcl_r:.4f}")
    if out_of_control:
        print(f"Out-of-control subgroups: {out_of_control}")
    else:
        print("No subgroups outside the X-bar control limits.")
    print(f"Chart saved to {args.out}")


if __name__ == "__main__":
    main()

What you get

$ python xbar_r_chart.py measurements.csv --out chart.png
X-bar-bar=20.0084  UCL=20.0263  LCL=19.9904
R-bar=0.0311  UCL_R=0.0658  LCL_R=0.0000
Out-of-control subgroups: [7, 15, 17]
Chart saved to chart.png

How it works

  • A2/D3/D4 are the standard published SPC constants indexed by subgroup size — this is table lookup, not statistics computed from scratch, which is exactly how the calculation is done on the shop floor.
  • Control limits come from the range, not a computed standard deviation — x_bar_bar ± A2 * r_bar for the X-bar chart, D4 * r_bar / D3 * r_bar for the R chart. That's the classical X-bar/R method, distinct from X-bar/S charts which use subgroup standard deviations instead.
  • D3 is 0 for every subgroup size through 6 — meaning there is no lower control limit on the R chart until subgroups get larger than 6. A range chart can't meaningfully flag "too little variation" at typical shop-floor subgroup sizes.
  • Two independent charts share one x-axis (sharex=True) on purpose: an out-of-control point on the X-bar chart and a point on the R chart line up directly above each other, which is how you'd actually read the pair.

Gotchas & honest limits

  • A control chart will occasionally flag a point that isn't a real problem — under a stable process, any single subgroup still has roughly a 0.3% chance of landing outside 3-sigma-equivalent limits by pure chance, and with 20+ subgroups that adds up. Investigate a flagged point for an assignable cause; don't assume every flag is a defect.
  • These constants only cover subgroup sizes 2–10 — larger subgroups use different (smaller) A2/D3/D4 values from the same standard tables; extend the dict if you need them.
  • This is the X-bar/R method (uses ranges) — X-bar/S charts (using subgroup standard deviations) are the better choice once subgroup size exceeds about 10, where range becomes a less efficient estimator of spread.
  • The CSV format is positional and unforgiving — every row must have exactly the same number of columns, with no header row.

Goes deeper

Want this adapted to your shop — or built into a real tool?

Samples are the free 80%. The last 20% is the part I do for a living.

Get in touch
Home
Blog
Tools
Code
Email
LinkedIn
Résumé