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

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 Bokeh

Python source (Bokeh)

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

import sys


# Remove script directory from sys.path so 'bokeh.py' doesn't shadow the installed bokeh package
if sys.path and sys.path[0] not in ("", None):
    sys.path.pop(0)

import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.layouts import column
from bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Label, Legend, LegendItem, Range1d, Span
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


# Theme-adaptive chrome tokens
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"

# Imprint palette — semantic roles for SPC signals
CLR_IN_CONTROL = "#009E73"  # position 1 — in-control (green = OK)
CLR_OOC = "#AE3030"  # position 5 — out-of-control (red = error)
CLR_WARNING = "#DDCC77"  # amber anchor — ±2σ warning limits

# === Data: CNC shaft diameter measurements (subgroups of 5) ===
np.random.seed(42)
n_samples = 30
subgroup_size = 5
target_diameter = 25.0  # mm

measurements = np.random.normal(target_diameter, 0.05, (n_samples, subgroup_size))
measurements[8] += 0.15  # process shift up at sample 9
measurements[17] -= 0.18  # process shift down at sample 18
measurements[24] += 0.20  # process shift up at sample 25

sample_ids = np.arange(1, n_samples + 1)
sample_means = measurements.mean(axis=1)
sample_ranges = measurements.max(axis=1) - measurements.min(axis=1)
sample_ranges[12] *= 3.5  # abnormal range spike at sample 13

# SPC constants for subgroup size n=5
A2, D3, D4 = 0.577, 0.0, 2.114

x_bar_bar = sample_means.mean()
r_bar = sample_ranges.mean()

# X-bar chart limits (UCL/LCL at ±3σ, warning limits at ±2σ)
ucl_xbar = x_bar_bar + A2 * r_bar
lcl_xbar = x_bar_bar - A2 * r_bar
uwl_xbar = x_bar_bar + (2 / 3) * A2 * r_bar
lwl_xbar = x_bar_bar - (2 / 3) * A2 * r_bar

# R chart limits (LCL=0 for n≤6 since D3=0)
ucl_r = D4 * r_bar
lcl_r = D3 * r_bar
uwl_r = r_bar + (2 / 3) * (ucl_r - r_bar)
lwl_r = max(0.0, r_bar - (2 / 3) * (r_bar - lcl_r))

# Identify out-of-control points
ooc_xbar = (sample_means > ucl_xbar) | (sample_means < lcl_xbar)
ooc_r = (sample_ranges > ucl_r) | (sample_ranges < lcl_r)

# ColumnDataSources
src_xbar_ok = ColumnDataSource(
    data={"x": sample_ids[~ooc_xbar], "y": sample_means[~ooc_xbar], "status": ["In Control"] * int((~ooc_xbar).sum())}
)
src_xbar_ooc = ColumnDataSource(
    data={"x": sample_ids[ooc_xbar], "y": sample_means[ooc_xbar], "status": ["Out of Control"] * int(ooc_xbar.sum())}
)
src_xbar_line = ColumnDataSource(data={"x": sample_ids, "y": sample_means})

src_r_ok = ColumnDataSource(
    data={"x": sample_ids[~ooc_r], "y": sample_ranges[~ooc_r], "status": ["In Control"] * int((~ooc_r).sum())}
)
src_r_ooc = ColumnDataSource(
    data={"x": sample_ids[ooc_r], "y": sample_ranges[ooc_r], "status": ["Out of Control"] * int(ooc_r.sum())}
)
src_r_line = ColumnDataSource(data={"x": sample_ids, "y": sample_ranges})

# Hover tools
hover_xbar = HoverTool(
    tooltips=[
        ("Sample", "@x"),
        ("X̄", "@y{0.000} mm"),
        ("Status", "@status"),
        ("UCL", f"{ucl_xbar:.3f}"),
        ("CL (X̄̄)", f"{x_bar_bar:.3f}"),
        ("LCL", f"{lcl_xbar:.3f}"),
    ]
)
hover_r = HoverTool(
    tooltips=[
        ("Sample", "@x"),
        ("Range", "@y{0.000} mm"),
        ("Status", "@status"),
        ("UCL", f"{ucl_r:.3f}"),
        ("R̄", f"{r_bar:.3f}"),
    ]
)

# === Canvas: 3200 × 1800 — two panels (895 each) + 10px spacing ===
W, H = 3200, 1800
CH = 895  # 895 + 10 spacing + 895 = 1800
x_range = Range1d(start=0.0, end=n_samples + 4.5)
label_x = n_samples + 0.8

# Label style for control-limit annotations
lbl = {"text_font_size": "24pt", "text_alpha": 0.9, "text_font_style": "bold"}

# === X-bar chart (top panel) ===
p_xbar = figure(
    width=W,
    height=CH,
    title="spc-xbar-r · python · bokeh · anyplot.ai",
    x_range=x_range,
    y_axis_label="X̄ (Sample Mean, mm)",
    toolbar_location=None,
    min_border_top=110,
    min_border_left=180,
    min_border_right=150,
    min_border_bottom=10,
)
p_xbar.add_tools(hover_xbar)

# Zone fills: Zone C (inner ±2σ) in green, Zone B (±2σ–±3σ) in amber
p_xbar.add_layout(BoxAnnotation(bottom=lwl_xbar, top=uwl_xbar, fill_color=CLR_IN_CONTROL, fill_alpha=0.07))
p_xbar.add_layout(BoxAnnotation(bottom=uwl_xbar, top=ucl_xbar, fill_color=CLR_WARNING, fill_alpha=0.08))
p_xbar.add_layout(BoxAnnotation(bottom=lcl_xbar, top=lwl_xbar, fill_color=CLR_WARNING, fill_alpha=0.08))

# Data line and markers
p_xbar.line("x", "y", source=src_xbar_line, line_width=3.0, line_color=CLR_IN_CONTROL, line_alpha=0.75)
glyph_xbar_ok = p_xbar.scatter("x", "y", source=src_xbar_ok, size=16, color=CLR_IN_CONTROL, alpha=0.9)
glyph_xbar_ooc = p_xbar.scatter(
    "x", "y", source=src_xbar_ooc, size=24, color=CLR_OOC, marker="diamond", line_color=INK, line_width=1.5
)

# Control limit lines
p_xbar.add_layout(Span(location=ucl_xbar, dimension="width", line_color=CLR_OOC, line_dash="dashed", line_width=3.0))
p_xbar.add_layout(Span(location=lcl_xbar, dimension="width", line_color=CLR_OOC, line_dash="dashed", line_width=3.0))
p_xbar.add_layout(Span(location=x_bar_bar, dimension="width", line_color=INK, line_width=3.0))
p_xbar.add_layout(
    Span(location=uwl_xbar, dimension="width", line_color=CLR_WARNING, line_dash="dotted", line_width=2.0)
)
p_xbar.add_layout(
    Span(location=lwl_xbar, dimension="width", line_color=CLR_WARNING, line_dash="dotted", line_width=2.0)
)

# Limit annotations (right side, in data coordinates)
p_xbar.add_layout(Label(x=label_x, y=ucl_xbar, text=f"UCL={ucl_xbar:.3f}", text_color=CLR_OOC, **lbl))
p_xbar.add_layout(Label(x=label_x, y=lcl_xbar, text=f"LCL={lcl_xbar:.3f}", text_color=CLR_OOC, **lbl))
p_xbar.add_layout(Label(x=label_x, y=x_bar_bar, text=f"X̄̄={x_bar_bar:.3f}", text_color=INK, **lbl))
amber_lbl = {**lbl, "background_fill_color": ELEVATED_BG, "background_fill_alpha": 0.85}
p_xbar.add_layout(Label(x=label_x, y=uwl_xbar, text="+2σ", text_color=CLR_WARNING, **amber_lbl))
p_xbar.add_layout(Label(x=label_x, y=lwl_xbar, text="−2σ", text_color=CLR_WARNING, **amber_lbl))

# Legend
legend_xbar = Legend(
    items=[
        LegendItem(label="In Control", renderers=[glyph_xbar_ok]),
        LegendItem(label="Out of Control", renderers=[glyph_xbar_ooc]),
    ],
    location="top_left",
    label_text_font_size="34pt",
    label_text_color=INK_SOFT,
    border_line_color=INK_SOFT,
    background_fill_color=ELEVATED_BG,
    background_fill_alpha=0.9,
    padding=16,
    spacing=10,
)
p_xbar.add_layout(legend_xbar)

# X-bar styling
p_xbar.title.text_font_size = "50pt"
p_xbar.title.text_color = INK
p_xbar.title.text_font_style = "bold"
p_xbar.yaxis.axis_label_text_font_size = "42pt"
p_xbar.yaxis.major_label_text_font_size = "34pt"
p_xbar.yaxis.axis_label_text_color = INK
p_xbar.yaxis.major_label_text_color = INK_SOFT
p_xbar.yaxis.axis_line_color = INK_SOFT
p_xbar.yaxis.major_tick_line_color = INK_SOFT
p_xbar.yaxis.minor_tick_line_color = None
p_xbar.xaxis.visible = False
p_xbar.ygrid.grid_line_color = INK
p_xbar.ygrid.grid_line_alpha = 0.12
p_xbar.xgrid.grid_line_alpha = 0.0
p_xbar.outline_line_color = None
p_xbar.background_fill_color = PAGE_BG
p_xbar.border_fill_color = PAGE_BG

# === R chart (bottom panel) ===
p_r = figure(
    width=W,
    height=CH,
    x_range=p_xbar.x_range,
    x_axis_label="Sample Number",
    y_axis_label="R (Sample Range, mm)",
    toolbar_location=None,
    min_border_top=10,
    min_border_left=180,
    min_border_right=150,
    min_border_bottom=160,
)
p_r.add_tools(hover_r)

# Zone fills for R chart (asymmetric — LCL=0 for n≤6)
p_r.add_layout(BoxAnnotation(bottom=lwl_r, top=uwl_r, fill_color=CLR_IN_CONTROL, fill_alpha=0.07))
p_r.add_layout(BoxAnnotation(bottom=uwl_r, top=ucl_r, fill_color=CLR_WARNING, fill_alpha=0.08))

# Data
p_r.line("x", "y", source=src_r_line, line_width=3.0, line_color=CLR_IN_CONTROL, line_alpha=0.75)
glyph_r_ok = p_r.scatter("x", "y", source=src_r_ok, size=16, color=CLR_IN_CONTROL, alpha=0.9)
glyph_r_ooc = p_r.scatter(
    "x", "y", source=src_r_ooc, size=24, color=CLR_OOC, marker="diamond", line_color=INK, line_width=1.5
)

# Control limits for R chart (skip LCL Span since lcl_r=0 overlaps x-axis)
p_r.add_layout(Span(location=ucl_r, dimension="width", line_color=CLR_OOC, line_dash="dashed", line_width=3.0))
p_r.add_layout(Span(location=r_bar, dimension="width", line_color=INK, line_width=3.0))
p_r.add_layout(Span(location=uwl_r, dimension="width", line_color=CLR_WARNING, line_dash="dotted", line_width=2.0))
if lwl_r > 0:
    p_r.add_layout(Span(location=lwl_r, dimension="width", line_color=CLR_WARNING, line_dash="dotted", line_width=2.0))

# Limit annotations for R chart
p_r.add_layout(Label(x=label_x, y=ucl_r, text=f"UCL={ucl_r:.3f}", text_color=CLR_OOC, **lbl))
p_r.add_layout(Label(x=label_x, y=r_bar, text=f"R̄={r_bar:.3f}", text_color=INK, **lbl))
p_r.add_layout(Label(x=label_x, y=uwl_r, text="+2σ", text_color=CLR_WARNING, **amber_lbl))
if lwl_r > 0:
    p_r.add_layout(Label(x=label_x, y=lwl_r, text="−2σ", text_color=CLR_WARNING, **amber_lbl))

# Legend for R chart
legend_r = Legend(
    items=[
        LegendItem(label="In Control", renderers=[glyph_r_ok]),
        LegendItem(label="Out of Control", renderers=[glyph_r_ooc]),
    ],
    location="top_left",
    label_text_font_size="34pt",
    label_text_color=INK_SOFT,
    border_line_color=INK_SOFT,
    background_fill_color=ELEVATED_BG,
    background_fill_alpha=0.9,
    padding=16,
    spacing=10,
)
p_r.add_layout(legend_r)

# R chart styling
p_r.xaxis.axis_label_text_font_size = "42pt"
p_r.yaxis.axis_label_text_font_size = "42pt"
p_r.xaxis.major_label_text_font_size = "34pt"
p_r.yaxis.major_label_text_font_size = "34pt"
p_r.xaxis.axis_label_text_color = INK
p_r.yaxis.axis_label_text_color = INK
p_r.xaxis.major_label_text_color = INK_SOFT
p_r.yaxis.major_label_text_color = INK_SOFT
p_r.xaxis.axis_line_color = INK_SOFT
p_r.yaxis.axis_line_color = INK_SOFT
p_r.xaxis.major_tick_line_color = INK_SOFT
p_r.yaxis.major_tick_line_color = INK_SOFT
p_r.xaxis.minor_tick_line_color = None
p_r.yaxis.minor_tick_line_color = None
p_r.ygrid.grid_line_color = INK
p_r.ygrid.grid_line_alpha = 0.12
p_r.xgrid.grid_line_alpha = 0.0
p_r.outline_line_color = None
p_r.background_fill_color = PAGE_BG
p_r.border_fill_color = PAGE_BG

# === Layout and Export ===
layout = column(p_xbar, p_r, spacing=10)

# Write interactive HTML; strip default browser body margins for correct viewport fill
output_file(f"plot-{THEME}.html")
save(layout)
html_path = Path(f"plot-{THEME}.html")
html_content = html_path.read_text()
html_content = html_content.replace("<body>", f'<body style="margin:0;padding:0;background:{PAGE_BG};">', 1)
html_path.write_text(html_content)

# Screenshot via headless Chrome (Selenium — export_png not available on this system)
# Use CDP setDeviceMetricsOverride to guarantee an exact W×H viewport (DPR=1),
# bypassing the window-chrome overhead that --window-size alone leaves.
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
    "--force-device-scale-factor=1",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
driver.get(f"file://{html_path.resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

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

Other implementations