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

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 ggplot2

R source (ggplot2)

#' anyplot.ai
#' spc-xbar-r: Statistical Process Control Chart (X-bar/R)
#' Library: ggplot2 3.5.1 | R 4.4.1
#' Quality: 88/100 | Created: 2026-06-20

library(ggplot2)
library(ragg)

set.seed(42)

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

IMPRINT_PALETTE <- c(
  "#009E73",  # 1 brand green  — in-control data
  "#C475FD",  # 2 lavender
  "#4467A3",  # 3 blue         — UCL / LCL lines
  "#BD8233",  # 4 ochre        — warning limits (+/-2 sigma)
  "#AE3030",  # 5 matte red    — out-of-control signal
  "#2ABCCD", "#954477", "#99B314"
)

# Data: CNC shaft diameter (mm), subgroup size n = 5
n_obs     <- 5L
n_samples <- 30L

# Standard SPC constants for n = 5
A2 <- 0.577
D3 <- 0.000
D4 <- 2.114

meas <- matrix(
  rnorm(n_samples * n_obs, mean = 25.0, sd = 0.080),
  nrow = n_samples, ncol = n_obs
)

# Inject assignable-cause events to demonstrate detection capability
meas[8,  ] <- meas[8,  ] + 0.300             # mean shift
meas[22, ] <- rnorm(n_obs, mean = 25.0, sd = 0.260)  # elevated range
meas[27, ] <- meas[27, ] + 0.285             # mean shift

xbar <- rowMeans(meas)
rng  <- apply(meas, 1L, function(row) max(row) - min(row))

xbar_bar <- mean(xbar)
rbar     <- mean(rng)

ucl_x <- xbar_bar + A2 * rbar
lcl_x <- xbar_bar - A2 * rbar
uwl_x <- xbar_bar + (2.0 / 3.0) * A2 * rbar  # +2 sigma warning
lwl_x <- xbar_bar - (2.0 / 3.0) * A2 * rbar  # -2 sigma warning

ucl_r <- D4 * rbar
lcl_r <- D3 * rbar  # = 0 for n <= 6

ooc_x <- xbar > ucl_x | xbar < lcl_x
ooc_r <- rng  > ucl_r

# Long-format data for faceting
CHART_X      <- "X-bar Chart (Shaft Diameter, mm)"
CHART_R      <- "R Chart (Sample Range, mm)"
CHART_LEVELS <- c(CHART_X, CHART_R)

df_all <- data.frame(
  sample_id = rep(seq_len(n_samples), 2L),
  value     = c(xbar, rng),
  status    = factor(
    ifelse(c(ooc_x, ooc_r), "Out of control", "In control"),
    levels = c("In control", "Out of control")
  ),
  chart = factor(
    c(rep(CHART_X, n_samples), rep(CHART_R, n_samples)),
    levels = CHART_LEVELS
  )
)

# Reference lines per facet (center line, bounds, warning limits)
ref_lines <- data.frame(
  chart = factor(c(
    rep(CHART_X, 5L), rep(CHART_R, 3L)
  ), levels = CHART_LEVELS),
  yval = c(xbar_bar, ucl_x, lcl_x, uwl_x, lwl_x,
            rbar, ucl_r, lcl_r),
  role = c("cl", "bound", "bound", "warn", "warn",
           "cl", "bound", "bound")
)

# Right-side text annotations (UCL / +2σ / CL / LCL; skip -2σ — too close to LCL)
N_RIGHT   <- n_samples + 2L
X_LIM_MAX <- n_samples + 8L  # reduced from +10 to cut wasted horizontal space

ann_data <- data.frame(
  chart = factor(
    c(rep(CHART_X, 4L), rep(CHART_R, 2L)),
    levels = CHART_LEVELS
  ),
  x     = N_RIGHT,
  y     = c(ucl_x, uwl_x, xbar_bar, lcl_x, ucl_r, rbar),
  label = c(
    sprintf("UCL = %.3f", ucl_x),
    "+2σ",
    sprintf("CL = %.3f",  xbar_bar),
    sprintf("LCL = %.3f", lcl_x),
    sprintf("UCL = %.3f", ucl_r),
    sprintf("CL = %.3f",  rbar)
  )
)

# OOC callout annotations — label each flagged sample with its number
ooc_x_ids <- which(ooc_x)
ooc_r_ids <- which(ooc_r)

ooc_callouts <- data.frame(
  chart = factor(
    c(rep(CHART_X, length(ooc_x_ids)), rep(CHART_R, length(ooc_r_ids))),
    levels = CHART_LEVELS
  ),
  sample_id = c(ooc_x_ids, ooc_r_ids),
  y         = c(xbar[ooc_x_ids], rng[ooc_r_ids]),
  label     = paste0("n=", c(ooc_x_ids, ooc_r_ids))
)

title_str <- "spc-xbar-r · r · ggplot2 · anyplot.ai"

p <- ggplot(df_all, aes(x = sample_id, y = value)) +
  # Center lines — solid neutral ink
  geom_hline(
    data      = ref_lines[ref_lines$role == "cl", ],
    aes(yintercept = yval),
    color     = INK, linewidth = 0.9, linetype = "solid"
  ) +
  # Control limits UCL / LCL — dashed blue
  geom_hline(
    data      = ref_lines[ref_lines$role == "bound", ],
    aes(yintercept = yval),
    color     = IMPRINT_PALETTE[3], linewidth = 0.7, linetype = "dashed"
  ) +
  # Warning limits +/-2 sigma — dotted ochre (X-bar chart only)
  geom_hline(
    data      = ref_lines[ref_lines$role == "warn", ],
    aes(yintercept = yval),
    color     = IMPRINT_PALETTE[4], linewidth = 0.5, linetype = "dotted"
  ) +
  # Data line (always brand green)
  geom_line(color = IMPRINT_PALETTE[1], linewidth = 0.85, alpha = 0.85) +
  # Points colored and shaped by control status
  geom_point(aes(color = status, size = status, shape = status), alpha = 0.92) +
  scale_color_manual(
    values = c("In control" = IMPRINT_PALETTE[1], "Out of control" = IMPRINT_PALETTE[5]),
    name   = NULL
  ) +
  scale_size_manual(
    values = c("In control" = 2.0, "Out of control" = 3.5),
    guide  = "none"
  ) +
  scale_shape_manual(
    values = c("In control" = 16L, "Out of control" = 18L),
    guide  = "none"
  ) +
  # OOC callout labels — sample number above each flagged diamond
  geom_text(
    data     = ooc_callouts,
    aes(x = sample_id, y = y, label = label),
    color    = IMPRINT_PALETTE[5],
    size     = 2.5,
    vjust    = -1.4,
    fontface = "bold"
  ) +
  # Right-side limit annotations
  geom_text(
    data  = ann_data,
    aes(x = x, y = y, label = label),
    hjust = 0, size = 2.8, color = INK_SOFT
  ) +
  facet_wrap(~ chart, ncol = 1L, scales = "free_y") +
  scale_x_continuous(
    breaks = seq(5L, n_samples, 5L),
    limits = c(1L, X_LIM_MAX)
  ) +
  scale_y_continuous(expand = expansion(mult = c(0.08, 0.15))) +
  labs(x = "Sample Number", y = NULL, title = title_str) +
  theme_minimal(base_size = 8) +
  theme(
    plot.background   = element_rect(fill = PAGE_BG, color = PAGE_BG),
    panel.background  = element_rect(fill = PAGE_BG, color = NA),
    panel.grid.major  = element_line(color = INK_MUTED, linewidth = 0.25),
    panel.grid.minor  = element_blank(),
    panel.border      = element_rect(color = INK_SOFT, fill = NA, linewidth = 0.4),
    strip.background  = element_rect(fill = ELEVATED_BG, color = INK_SOFT, linewidth = 0.4),
    strip.text        = element_text(color = INK, size = 9, face = "bold",
                                     margin = margin(t = 4, b = 4)),
    axis.title.x      = element_text(color = INK, size = 10),
    axis.text         = element_text(color = INK_SOFT, size = 8),
    legend.position   = "bottom",
    legend.background = element_rect(fill = ELEVATED_BG, color = INK_SOFT, linewidth = 0.3),
    legend.text       = element_text(color = INK_SOFT, size = 8),
    legend.key        = element_rect(fill = NA),
    plot.title        = element_text(color = INK, size = 12, face = "plain"),
    plot.margin       = margin(t = 14, r = 6, b = 10, l = 14)
  )

# Save
ggsave(
  filename = sprintf("plot-%s.png", THEME),
  plot     = p,
  device   = ragg::agg_png,
  width    = 8,
  height   = 4.5,
  units    = "in",
  dpi      = 400
)

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

Other implementations