Statistical Process Control Chart (X-bar/R) — lets-plot

A Statistical Process Control (SPC) chart displaying sample means (X-bar) and ranges (R) plotted over time against control limits. The chart includes a center line representing the process mean, Upper Control Limit (UCL) and Lower Control Limit (LCL) at ±3 sigma, and optional warning limits at ±2 sigma. Out-of-control points are highlighted to signal process instability. This is a fundamental tool in manufacturing quality control and Six Sigma methodology for monitoring process stability.

Statistical Process Control Chart (X-bar/R) rendered with lets-plot

Python source (lets-plot)

""" anyplot.ai
spc-xbar-r: Statistical Process Control Chart (X-bar/R)
Library: letsplot 4.10.1 | Python 3.13.14
Quality: 91/100 | Updated: 2026-06-20
"""

import os

import numpy as np
import pandas as pd
from lets_plot import *


LetsPlot.setup_html()

# Theme tokens (Imprint palette — theme-adaptive chrome)
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint palette — data colors (theme-independent)
DATA_COLOR = "#009E73"  # Imprint position 1 — always first series
OOC_COLOR = "#AE3030"  # Imprint position 5 — semantic red for out-of-control

# Data - CNC shaft diameter measurements (subgroups of n=5)
np.random.seed(42)
n_samples = 30
target_diameter = 25.0  # mm
process_std = 0.05  # mm

# Generate subgroup measurements
measurements = np.random.normal(target_diameter, process_std, (n_samples, 5))

# Inject mean shifts for X-bar out-of-control points
measurements[7] += 0.15  # sudden upward shift
measurements[18] -= 0.18  # sudden downward shift
measurements[24] += 0.12  # upward drift

# Inject extreme spread for R chart OOC at sample 12 (preserves mean, inflates range)
measurements[11, 0] -= 0.16
measurements[11, 4] += 0.16

sample_ids = np.arange(1, n_samples + 1)
sample_means = measurements.mean(axis=1)
sample_ranges = measurements.max(axis=1) - measurements.min(axis=1)

# Control chart constants for n=5 subgroups
A2 = 0.577
D3 = 0.0
D4 = 2.114

# X-bar chart limits
xbar_bar = sample_means.mean()
r_bar = sample_ranges.mean()
xbar_ucl = xbar_bar + A2 * r_bar
xbar_lcl = xbar_bar - A2 * r_bar
xbar_uwl = xbar_bar + (2 / 3) * A2 * r_bar
xbar_lwl = xbar_bar - (2 / 3) * A2 * r_bar

# R chart limits
r_ucl = D4 * r_bar
r_lcl = D3 * r_bar
r_uwl = r_bar + (2 / 3) * (r_ucl - r_bar)

# Classify in-control vs out-of-control
xbar_ooc = (sample_means > xbar_ucl) | (sample_means < xbar_lcl)
r_ooc = sample_ranges > r_ucl

# DataFrames
df_xbar = pd.DataFrame(
    {"sample": sample_ids, "mean": sample_means, "status": np.where(xbar_ooc, "Out of Control", "In Control")}
)

df_r = pd.DataFrame(
    {"sample": sample_ids, "range": sample_ranges, "status": np.where(r_ooc, "Out of Control", "In Control")}
)

# Limit line dataframes (each line = 2 rows: start and end sample)
xbar_limits = pd.DataFrame(
    {
        "sample": np.tile([1, n_samples], 5),
        "y": ([xbar_ucl] * 2 + [xbar_lcl] * 2 + [xbar_bar] * 2 + [xbar_uwl] * 2 + [xbar_lwl] * 2),
        "line": ["UCL"] * 2 + ["LCL"] * 2 + ["CL"] * 2 + ["UWL"] * 2 + ["LWL"] * 2,
    }
)

r_limits = pd.DataFrame(
    {
        "sample": np.tile([1, n_samples], 4),
        "y": [r_ucl] * 2 + [r_lcl] * 2 + [r_bar] * 2 + [r_uwl] * 2,
        "line": ["UCL"] * 2 + ["LCL"] * 2 + ["CL"] * 2 + ["UWL"] * 2,
    }
)

# Label annotations placed just past last data point (within x-axis range)
xbar_labels = pd.DataFrame(
    {"sample": [n_samples + 0.5] * 3, "y": [xbar_ucl, xbar_bar, xbar_lcl], "label": ["UCL", "CL", "LCL"]}
)

r_labels = pd.DataFrame({"sample": [n_samples + 0.5] * 3, "y": [r_ucl, r_bar, r_lcl], "label": ["UCL", "R̄", "LCL"]})

# Theme for top panel (X-bar chart — title here, no x-axis labels)
theme_top = theme(
    plot_title=element_text(size=16, color=INK, face="bold"),
    axis_title_y=element_text(size=12, color=INK),
    axis_text_y=element_text(size=10, color=INK_SOFT),
    axis_text_x=element_blank(),
    axis_title_x=element_blank(),
    panel_grid_major_x=element_blank(),
    panel_grid_minor=element_blank(),
    panel_grid_major_y=element_line(color=INK_SOFT, size=0.25),
    panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
    axis_line_x=element_line(color=INK_SOFT, size=0.6),
    axis_line_y=element_line(color=INK_SOFT, size=0.6),
    axis_ticks=element_line(color=INK_SOFT, size=0.3),
    legend_position="none",
    plot_margin=[20, 50, 5, 15],
)

# Theme for bottom panel (R chart — x-axis labels here)
theme_bottom = theme(
    axis_title=element_text(size=12, color=INK),
    axis_text=element_text(size=10, color=INK_SOFT),
    panel_grid_major_x=element_blank(),
    panel_grid_minor=element_blank(),
    panel_grid_major_y=element_line(color=INK_SOFT, size=0.25),
    panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
    axis_line_x=element_line(color=INK_SOFT, size=0.6),
    axis_line_y=element_line(color=INK_SOFT, size=0.6),
    axis_ticks=element_line(color=INK_SOFT, size=0.3),
    legend_position="none",
    plot_margin=[5, 50, 20, 15],
)

# X-bar chart
xbar_plot = (
    ggplot()
    + geom_line(
        data=xbar_limits[xbar_limits["line"].isin(["UWL", "LWL"])],
        mapping=aes(x="sample", y="y", group="line"),
        linetype="dotted",
        color=INK_MUTED,
        size=0.8,
    )
    + geom_line(
        data=xbar_limits[xbar_limits["line"].isin(["UCL", "LCL"])],
        mapping=aes(x="sample", y="y", group="line"),
        linetype="dashed",
        color=INK_SOFT,
        size=1.0,
    )
    + geom_line(
        data=xbar_limits[xbar_limits["line"] == "CL"],
        mapping=aes(x="sample", y="y"),
        linetype="solid",
        color=INK,
        size=0.9,
    )
    + geom_line(data=df_xbar, mapping=aes(x="sample", y="mean"), color=DATA_COLOR, size=1.5)
    + geom_point(
        data=df_xbar[df_xbar["status"] == "In Control"],
        mapping=aes(x="sample", y="mean"),
        color=DATA_COLOR,
        fill=PAGE_BG,
        size=5,
        shape=21,
        stroke=1.5,
    )
    + geom_point(
        data=df_xbar[df_xbar["status"] == "Out of Control"],
        mapping=aes(x="sample", y="mean"),
        color=OOC_COLOR,
        fill=OOC_COLOR,
        size=6,
        shape=21,
        stroke=1.5,
    )
    + geom_text(
        data=xbar_labels,
        mapping=aes(x="sample", y="y", label="label"),
        size=4,
        color=INK_SOFT,
        fontface="bold",
        hjust=0,
    )
    + scale_x_continuous(breaks=list(range(1, n_samples + 1, 5)) + [n_samples], limits=[0.5, n_samples + 4.5])
    + scale_y_continuous(expand=[0.12, 0])
    + labs(title="spc-xbar-r · python · letsplot · anyplot.ai", y="X̄ (Sample Mean, mm)", x="")
    + theme_top
    + ggsize(800, 260)
)

# R chart
r_plot = (
    ggplot()
    + geom_line(
        data=r_limits[r_limits["line"] == "UWL"],
        mapping=aes(x="sample", y="y"),
        linetype="dotted",
        color=INK_MUTED,
        size=0.8,
    )
    + geom_line(
        data=r_limits[r_limits["line"].isin(["UCL", "LCL"])],
        mapping=aes(x="sample", y="y", group="line"),
        linetype="dashed",
        color=INK_SOFT,
        size=1.0,
    )
    + geom_line(
        data=r_limits[r_limits["line"] == "CL"], mapping=aes(x="sample", y="y"), linetype="solid", color=INK, size=0.9
    )
    + geom_line(data=df_r, mapping=aes(x="sample", y="range"), color=DATA_COLOR, size=1.5)
    + geom_point(
        data=df_r[df_r["status"] == "In Control"],
        mapping=aes(x="sample", y="range"),
        color=DATA_COLOR,
        fill=PAGE_BG,
        size=5,
        shape=21,
        stroke=1.5,
    )
    + geom_point(
        data=df_r[df_r["status"] == "Out of Control"],
        mapping=aes(x="sample", y="range"),
        color=OOC_COLOR,
        fill=OOC_COLOR,
        size=6,
        shape=21,
        stroke=1.5,
    )
    + geom_text(
        data=r_labels, mapping=aes(x="sample", y="y", label="label"), size=4, color=INK_SOFT, fontface="bold", hjust=0
    )
    + scale_x_continuous(breaks=list(range(1, n_samples + 1, 5)) + [n_samples], limits=[0.5, n_samples + 4.5])
    + scale_y_continuous(expand=[0.15, 0])
    + labs(x="Sample Number", y="R (Sample Range, mm)")
    + theme_bottom
    + ggsize(800, 190)
)

# Add tooltip layers for HTML interactivity (lets-plot distinctive feature)
xbar_interactive = xbar_plot + geom_point(
    data=df_xbar,
    mapping=aes(x="sample", y="mean"),
    size=5,
    alpha=0.01,
    tooltips=layer_tooltips().line("Sample|@sample").line("X̄|@mean").line("Status|@status").format("mean", ".4f"),
)

r_interactive = r_plot + geom_point(
    data=df_r,
    mapping=aes(x="sample", y="range"),
    size=5,
    alpha=0.01,
    tooltips=layer_tooltips().line("Sample|@sample").line("Range|@range").line("Status|@status").format("range", ".4f"),
)

# Combine top (X-bar, 260) + bottom (R, 190) = 800×450 logical → 3200×1800 at scale=4
combined = gggrid([xbar_plot, r_plot], ncol=1)
combined_interactive = gggrid([xbar_interactive, r_interactive], ncol=1)

# Save
ggsave(combined, f"plot-{THEME}.png", scale=4, path=".")
ggsave(combined_interactive, f"plot-{THEME}.html", path=".")

Part of Statistical Process Control Chart (X-bar/R) on anyplot.ai.

Other implementations