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

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 Altair

Python source (Altair)

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

import os

import altair as alt
import numpy as np
import pandas as pd
from PIL import Image


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

# Imprint palette — semantic roles for SPC chart
BRAND = "#009E73"  # in-control data series (first series)
OOC_COLOR = "#AE3030"  # out-of-control — semantic matte-red (bad/error)
WARN_COLOR = "#DDCC77"  # warning limits — semantic amber (caution)

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

measurements = np.random.normal(target_diameter, process_std, (n_samples, n_per_sample))
measurements[7] += 0.15
measurements[16] -= 0.18
measurements[23] += 0.20

sample_means = measurements.mean(axis=1)
sample_ranges = measurements.max(axis=1) - measurements.min(axis=1)

# Control chart constants for n=5 (Shewhart)
A2, D3, D4 = 0.577, 0.0, 2.114

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_uwarn = xbar_bar + (2 / 3) * A2 * r_bar
xbar_lwarn = xbar_bar - (2 / 3) * A2 * r_bar

r_ucl = D4 * r_bar
r_lcl = D3 * r_bar
r_uwarn = r_bar + (2 / 3) * (r_ucl - r_bar)
r_lwarn = r_bar - (2 / 3) * (r_bar - r_lcl)

samples = np.arange(1, n_samples + 1)
x_domain = [0, n_samples + 1]

df_xbar = pd.DataFrame(
    {
        "sample": samples,
        "value": sample_means,
        "ucl": xbar_ucl,
        "lcl": xbar_lcl,
        "center": xbar_bar,
        "uwarn": xbar_uwarn,
        "lwarn": xbar_lwarn,
    }
)
df_xbar["ooc"] = (df_xbar["value"] > xbar_ucl) | (df_xbar["value"] < xbar_lcl)

df_range = pd.DataFrame(
    {
        "sample": samples,
        "value": sample_ranges,
        "ucl": r_ucl,
        "lcl": r_lcl,
        "center": r_bar,
        "uwarn": r_uwarn,
        "lwarn": r_lwarn,
    }
)
df_range["ooc"] = (df_range["value"] > r_ucl) | (df_range["value"] < r_lcl)

# Zone shading bands (±1σ and ±2σ)
xbar_zone_2s = pd.DataFrame({"y": [xbar_lwarn], "y2": [xbar_uwarn]})
xbar_zone_1s = pd.DataFrame({"y": [xbar_bar - (1 / 3) * A2 * r_bar], "y2": [xbar_bar + (1 / 3) * A2 * r_bar]})
r_zone_2s = pd.DataFrame({"y": [r_lwarn], "y2": [r_uwarn]})
r_zone_1s = pd.DataFrame({"y": [r_bar - (1 / 3) * (r_bar - r_lcl)], "y2": [r_bar + (1 / 3) * (r_ucl - r_bar)]})

# Inline label data
xbar_labels_df = pd.DataFrame(
    {
        "sample": [2] * 5,
        "y": [xbar_ucl, xbar_uwarn, xbar_bar, xbar_lwarn, xbar_lcl],
        "label": ["UCL", "+2σ", "CL", "−2σ", "LCL"],
        "ltype": ["limit", "warn", "center", "warn", "limit"],
    }
)
r_labels_df = pd.DataFrame(
    {
        "sample": [2] * 5,
        "y": [r_ucl, r_uwarn, r_bar, r_lwarn, r_lcl],
        "label": ["UCL", "+2σ", "CL", "−2σ", "LCL"],
        "ltype": ["limit", "warn", "center", "warn", "limit"],
    }
)

label_color_scale = alt.Scale(domain=["limit", "warn", "center"], range=[OOC_COLOR, WARN_COLOR, INK])

# --- X-bar Chart ---
xbar_zone2 = alt.Chart(xbar_zone_2s).mark_rect(color="#4467A3", opacity=0.07).encode(y="y:Q", y2="y2:Q")
xbar_zone1 = alt.Chart(xbar_zone_1s).mark_rect(color="#4467A3", opacity=0.14).encode(y="y:Q", y2="y2:Q")
xbar_line = (
    alt.Chart(df_xbar)
    .mark_line(color=BRAND, strokeWidth=2.5)
    .encode(
        x=alt.X(
            "sample:Q", scale=alt.Scale(domain=x_domain, nice=False), axis=alt.Axis(title="", tickMinStep=1, grid=False)
        ),
        y=alt.Y("value:Q", scale=alt.Scale(zero=False), axis=alt.Axis(title="X̄  (mm)")),
    )
)
xbar_pts = (
    alt.Chart(df_xbar[~df_xbar["ooc"]])
    .mark_point(color=BRAND, size=120, filled=True, stroke=PAGE_BG, strokeWidth=1)
    .encode(
        x="sample:Q",
        y="value:Q",
        tooltip=[alt.Tooltip("sample:Q", title="Sample"), alt.Tooltip("value:Q", title="X̄", format=".4f")],
    )
)
xbar_ooc = (
    alt.Chart(df_xbar[df_xbar["ooc"]])
    .mark_point(color=OOC_COLOR, size=240, filled=True, stroke=PAGE_BG, strokeWidth=1.5, shape="diamond")
    .encode(
        x="sample:Q",
        y="value:Q",
        tooltip=[alt.Tooltip("sample:Q", title="Sample"), alt.Tooltip("value:Q", title="X̄ (OOC)", format=".4f")],
    )
)
xbar_ucl_rule = alt.Chart(df_xbar).mark_rule(color=OOC_COLOR, strokeDash=[8, 4], strokeWidth=2).encode(y="ucl:Q")
xbar_lcl_rule = alt.Chart(df_xbar).mark_rule(color=OOC_COLOR, strokeDash=[8, 4], strokeWidth=2).encode(y="lcl:Q")
xbar_cl_rule = alt.Chart(df_xbar).mark_rule(color=INK, strokeWidth=2.5).encode(y="center:Q")
xbar_uwarn_rule = (
    alt.Chart(df_xbar).mark_rule(color=WARN_COLOR, strokeDash=[4, 4], strokeWidth=1.5, opacity=0.85).encode(y="uwarn:Q")
)
xbar_lwarn_rule = (
    alt.Chart(df_xbar).mark_rule(color=WARN_COLOR, strokeDash=[4, 4], strokeWidth=1.5, opacity=0.85).encode(y="lwarn:Q")
)
xbar_labels = (
    alt.Chart(xbar_labels_df)
    .mark_text(align="left", dx=5, dy=-13, fontSize=13, fontWeight="bold")
    .encode(x="sample:Q", y="y:Q", text="label:N", color=alt.Color("ltype:N", scale=label_color_scale, legend=None))
)

xbar_chart = (
    xbar_zone2
    + xbar_zone1
    + xbar_line
    + xbar_pts
    + xbar_ooc
    + xbar_ucl_rule
    + xbar_lcl_rule
    + xbar_cl_rule
    + xbar_uwarn_rule
    + xbar_lwarn_rule
    + xbar_labels
).properties(width=620, height=160)

# --- R Chart ---
r_zone2 = alt.Chart(r_zone_2s).mark_rect(color="#4467A3", opacity=0.07).encode(y="y:Q", y2="y2:Q")
r_zone1 = alt.Chart(r_zone_1s).mark_rect(color="#4467A3", opacity=0.14).encode(y="y:Q", y2="y2:Q")
r_line = (
    alt.Chart(df_range)
    .mark_line(color=BRAND, strokeWidth=2.5)
    .encode(
        x=alt.X(
            "sample:Q",
            scale=alt.Scale(domain=x_domain, nice=False),
            axis=alt.Axis(title="Sample Number", tickMinStep=1, grid=False),
        ),
        y=alt.Y("value:Q", scale=alt.Scale(zero=False), axis=alt.Axis(title="Range R (mm)")),
    )
)
r_pts = (
    alt.Chart(df_range[~df_range["ooc"]])
    .mark_point(color=BRAND, size=120, filled=True, stroke=PAGE_BG, strokeWidth=1)
    .encode(
        x="sample:Q",
        y="value:Q",
        tooltip=[alt.Tooltip("sample:Q", title="Sample"), alt.Tooltip("value:Q", title="Range", format=".4f")],
    )
)
r_ooc = (
    alt.Chart(df_range[df_range["ooc"]])
    .mark_point(color=OOC_COLOR, size=240, filled=True, stroke=PAGE_BG, strokeWidth=1.5, shape="diamond")
    .encode(
        x="sample:Q",
        y="value:Q",
        tooltip=[alt.Tooltip("sample:Q", title="Sample"), alt.Tooltip("value:Q", title="Range (OOC)", format=".4f")],
    )
)
r_ucl_rule = alt.Chart(df_range).mark_rule(color=OOC_COLOR, strokeDash=[8, 4], strokeWidth=2).encode(y="ucl:Q")
r_lcl_rule = (
    alt.Chart(df_range).mark_rule(color=OOC_COLOR, strokeDash=[4, 4], strokeWidth=1.5, opacity=0.5).encode(y="lcl:Q")
)
r_cl_rule = alt.Chart(df_range).mark_rule(color=INK, strokeWidth=2.5).encode(y="center:Q")
r_uwarn_rule = (
    alt.Chart(df_range)
    .mark_rule(color=WARN_COLOR, strokeDash=[4, 4], strokeWidth=1.5, opacity=0.85)
    .encode(y="uwarn:Q")
)
r_lwarn_rule = (
    alt.Chart(df_range)
    .mark_rule(color=WARN_COLOR, strokeDash=[4, 4], strokeWidth=1.5, opacity=0.85)
    .encode(y="lwarn:Q")
)
r_labels = (
    alt.Chart(r_labels_df)
    .mark_text(align="left", dx=5, dy=-13, fontSize=13, fontWeight="bold")
    .encode(x="sample:Q", y="y:Q", text="label:N", color=alt.Color("ltype:N", scale=label_color_scale, legend=None))
)

r_chart = (
    r_zone2
    + r_zone1
    + r_line
    + r_pts
    + r_ooc
    + r_ucl_rule
    + r_lcl_rule
    + r_cl_rule
    + r_uwarn_rule
    + r_lwarn_rule
    + r_labels
).properties(width=620, height=160)

# Combined chart — title length-scaled fontSize (baseline 67 chars, default 16px)
title_text = "CNC Shaft Diameter Monitoring · spc-xbar-r · python · altair · anyplot.ai"
_n = len(title_text)
title_fontsize = max(11, round(16 * 67 / _n)) if _n > 67 else 16

combined = alt.vconcat(xbar_chart, r_chart, spacing=15).properties(
    background=PAGE_BG,
    title=alt.Title(title_text, fontSize=title_fontsize, anchor="middle", offset=10, fontWeight="bold", color=INK),
)

chart = (
    combined.configure_view(fill=PAGE_BG, strokeWidth=0)
    .configure_axis(
        domainColor=INK_SOFT,
        tickColor=INK_SOFT,
        gridColor=INK,
        gridOpacity=0.15,
        gridDash=[2, 4],
        labelColor=INK_SOFT,
        titleColor=INK,
        labelFontSize=10,
        titleFontSize=12,
    )
    .configure_title(color=INK)
)

# Save PNG then pad to exact 3200×1800
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

TW, TH = 3200, 1800
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds {TW}×{TH}. Shrink chart width/height and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

chart.save(f"plot-{THEME}.html")

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

Other implementations