How to Decide Between an X-Bar R and an X-Bar S Chart in Python

Picking between the X-bar R and X-bar S chart is not a style preference you tune later — it is a routing decision driven by one number, the subgroup size n, and getting it wrong corrupts every limit and capability index downstream. Feed a subgroup of fifteen readings to a range-based chart and the range throws away the thirteen values between the minimum and maximum; the resulting σ̂ is biased low, the control limits compress, and genuine drift hides until it breaches specification. This how-to belongs to the X-bar S chart for large subgroups workflow inside the wider SPC fundamentals and control chart taxonomy: it gives a reproducible way to route data at n = 9/10, justify the cutoff with estimator efficiency, and build a runtime guard so an ETL change can never silently misroute a subgroup.

The rule the rest of this page implements is short: route n = 2–9 to the X-bar R chart, route n ≥ 10 to X-bar S, and send n = 1 streams to an I-MR chart. The steps below turn that rule into code that refuses to proceed when the observed n and the chosen estimator disagree.

Routing variable data to I-MR, X-bar R, or X-bar S by subgroup size, and why the cutoff sits at n = 9/10 Left panel: one tidy subgroup enters a count-n node that branches three ways. n equals 1 routes to an I-MR chart using the moving range with d2 equal to 1.128. n from 2 to 9 routes to an X-bar R chart estimating sigma as R-bar over d2 with constants A2, D3, D4. n of 10 or more routes to an X-bar S chart estimating sigma as S-bar over c4 with constants A3, B3, B4. Right panel: the relative efficiency of the range estimator versus the sample standard deviation, plotted against n, starts near 0.99 at n equals 2, passes about 0.85 at n equals 9, and keeps falling. The region from n equals 10 onward is shaded and labelled range too inefficient, so the router switches to the S estimator there. Route by observed subgroup size n One tidy subgroup count n from the data n = 1 n = 2–9 n ≥ 10 I‑MR chart no within-group spread σ̂ = MR̄ / d₂ d₂ = 1.128 X̄ – R chart range still efficient σ̂ = R̄ / d₂ A₂ · D₃ · D₄ X̄ – S chart S uses every reading σ̂ = S̄ / c₄ A₃ · B₃ · B₄ Range efficiency vs. S estimator 0.80 0.85 0.90 0.95 1.00 2 6 9 10 14 subgroup size n ≈0.85 at n=9 n ≥ 10 range too inefficient — switch to S

Prerequisites

Confirm these are in place before wiring the router into a pipeline:

  • Python 3.10+ with pandas >= 2.0 and numpy >= 1.24 installed (pip install "pandas>=2.0" "numpy>=1.24"); math.gamma from the standard library covers the constants
  • A tidy long-format pd.DataFrame with one row per measurement, a column identifying the rational subgroup (lot, cycle, or inspection run), and a numeric value column — not a wide array pre-averaged into means
  • Rational subgroups already established so within-subgroup spread is common cause only; a subgroup that straddles a tool change or lot boundary breaks the efficiency argument regardless of which chart you pick
  • Timestamps aligned and subgroups formed on real process boundaries via the time-series alignment pipeline, not arbitrary clock slices
  • Missing measurements resolved by the missing-value policy for quality data so a dropout does not silently turn an n = 10 subgroup into n = 9 and cross the routing threshold
  • The subgroup size treated as a locked design value, validated at runtime — not an ingestion convenience that varies row to row

Why n = 9/10 Is the Cutoff

Both charts monitor the subgroup mean the same way; they differ only in how they estimate within-subgroup dispersion. The X-bar R chart estimates σ from the average range scaled by the $d_2$ constant, $\hat{\sigma} = \bar{R}/d_2$; the X-bar S chart uses the average sample standard deviation corrected by the $c_4$ bias factor, $\hat{\sigma} = \bar{S}/c_4$. The X-bar limits then follow from whichever dispersion statistic you charted:

$$\text{UCL}/\text{LCL}_{\bar{X}} = \bar{\bar{X}} \pm A_2 \bar{R} \quad\text{(R route)} \qquad \bar{\bar{X}} \pm A_3 \bar{S} \quad\text{(S route)}$$

The range only ever looks at two observations — the extremes — no matter how many readings the subgroup carries. The sample standard deviation uses every reading. For small subgroups that barely matters: at n = 2 the range is essentially the whole story, and $\bar{R}/d_2$ recovers σ at roughly 99% of the efficiency of the standard deviation. As n grows the discarded interior grows with it, and range efficiency decays past the point where it earns its computational cheapness — beyond n ≈ 9 it drops below about 85% and keeps falling. That decay, not a regulatory line in the sand, is why the industry cutoff sits at n = 9/10.

Subgroup size n Estimator of σ Dispersion chart Relative efficiency Route to
1 Moving range, $\overline{MR}/d_2$ ($d_2 = 1.128$) MR — (no within-subgroup spread) I-MR chart
2–5 Range, $\bar{R}/d_2$ R ~0.99 → 0.95 X-Bar R
6–9 Range, $\bar{R}/d_2$ R ~0.93 → 0.85 X-Bar R (watch efficiency)
≥ 10 Std dev, $\bar{S}/c_4$ S S is materially more efficient X-Bar S chart

The practical consequence is a compressed limit, not a crash. A legacy MES template that keeps charting n = 15 on the range route produces a σ̂ biased low, so UCL/LCL sit too close to the centerline. That does not cause false alarms by itself — a biased-low σ makes limits tighter, so the more common field symptom is capability inflation feeding process capability analysis: a $\sigma_{within}$ read too small pushes Cpk up with no real process change. Misrouting in the other direction — forcing small-n data through S-route constants — distorts limits by a wide margin because the c₄, A₃, and B₃/B₄ values diverge sharply from their range-route counterparts at low n.

Step-by-Step Implementation

Step 1 — Count the subgroup size from the data and lock it

Never trust the recipe for n. Count it, and refuse to proceed if it varies, because a mixed-n frame biases both dispersion estimators and breaks constant lookup. This one guard prevents the drift that shows up after an ETL or missing-value change.

import pandas as pd


def observed_subgroup_size(
    df: pd.DataFrame, subgroup_col: str, value_col: str
) -> int:
    """Return the fixed subgroup size, or raise if n is not constant."""
    sizes = df.groupby(subgroup_col)[value_col].count()
    if (sizes < 2).any():
        raise ValueError("Every subgroup needs at least 2 observations.")
    if sizes.nunique() != 1:
        raise ValueError(
            f"Subgroup size is not constant: saw {sorted(sizes.unique())}. "
            "Fix upstream before charting — do not average across ragged n."
        )
    return int(sizes.iloc[0])

Verify in isolation: a clean frame returns a single integer; a frame with one short subgroup raises. This is the only place n enters the decision.

Step 2 — Compute the constants at runtime from n

Hardcoded constant tables are a classic misrouting vector: an n = 5 row reused for n = 15 batches distorts limits silently. Compute $c_4$ exactly from the gamma function and derive $A_3$, $B_3$, and $B_4$ from it, so any n is supported without a lookup table. (For the range route, $A_2$, $D_3$, and $D_4$ come from the standard $d_2/d_3$ tables — see the X-bar R limit calculation how-to.)

import math


def c4(n: int) -> float:
    """Unbiasing constant for the sample standard deviation (exact via gamma)."""
    return math.sqrt(2.0 / (n - 1)) * math.gamma(n / 2) / math.gamma((n - 1) / 2)


def s_route_constants(n: int) -> dict[str, float]:
    """A3, B3, B4 for the X-bar S chart at subgroup size n."""
    c4n = c4(n)
    spread = (3.0 / c4n) * math.sqrt(1.0 - c4n ** 2)
    return {
        "c4": c4n,
        "A3": 3.0 / (c4n * math.sqrt(n)),
        "B3": max(0.0, 1.0 - spread),   # clamped: negative B3 has no meaning
        "B4": 1.0 + spread,
    }

Verify against a known value: round(c4(10), 4) returns 0.9727, matching the AIAG SPC Reference Manual table. The max(0.0, ...) clamp on B3 matters — for n < 6 the raw expression goes negative, which would produce a nonsensical negative lower limit on the S chart.

Step 3 — Route with a runtime guard, never a default

The router is the safeguard. It counts n, picks the chart, and raises the instant the caller's assumption disagrees — so a silent default can never send large subgroups down the range route.

from typing import Literal

ChartType = Literal["I-MR", "X-bar R", "X-bar S"]


def select_chart(n: int) -> ChartType:
    """Deterministic chart routing for variable data by subgroup size."""
    if n < 1:
        raise ValueError(f"Subgroup size must be >= 1; got {n}.")
    if n == 1:
        return "I-MR"          # no within-subgroup spread -> moving range
    if n <= 9:
        return "X-bar R"       # range still efficient enough
    return "X-bar S"           # n >= 10: range too inefficient, use S/c4


def route_subgroups(
    df: pd.DataFrame,
    subgroup_col: str,
    value_col: str,
    expected: ChartType | None = None,
) -> ChartType:
    """Count n from the data, choose the chart, and reject a wrong assumption."""
    n = observed_subgroup_size(df, subgroup_col, value_col)
    chart = select_chart(n)
    if expected is not None and chart != expected:
        raise ValueError(
            f"Data has n = {n}, which routes to {chart}, "
            f"but the pipeline expected {expected}. Refusing to chart."
        )
    return chart

Pass expected="X-bar S" from the pipeline config so a feed that drifts to n = 9 fails loudly instead of quietly charting the wrong estimator.

Verification

Assert the boundary behaviour directly — the two rows either side of the cutoff are the ones that break in production:

assert select_chart(1) == "I-MR"
assert select_chart(9) == "X-bar R"      # last range subgroup
assert select_chart(10) == "X-bar S"     # first std-dev subgroup
assert round(c4(2), 4) == 0.7979
assert round(c4(25), 4) == 0.9896        # c4 -> 1 as n grows

# a large-subgroup frame must route to S, and must reject an "X-bar R" expectation
big = pd.DataFrame({"sg": sum(([i] * 12 for i in range(20)), []),
                    "x": range(240)})
assert route_subgroups(big, "sg", "x") == "X-bar S"
try:
    route_subgroups(big, "sg", "x", expected="X-bar R")
    raise AssertionError("router should have rejected the wrong expectation")
except ValueError:
    pass

Expected output: every assertion passes silently. If c4(10) does not return 0.9727 you have a gamma-function or off-by-one error in the n - 1 term; if the boundary assertions fail you have an inclusive/exclusive mistake at the n = 9/10 edge — the single most consequential line in the router.

Root-Cause Table

Symptom Cause Fix
Cpk looks inflated with no real improvement Large subgroups (n > 9) charted on the range route, so $\bar{R}/d_2$ underestimates $\sigma_{within}$ Route n ≥ 10 to $\bar{S}/c_4$ and reconcile Cpk against Ppk (Steps 2–3)
Limits shifted a fraction of a percent after an ETL change A dropout turned an n = 10 subgroup into n = 9 and crossed the threshold Count n from the data and raise on mixed sizes every run (Step 1)
Negative or NaN lower S-chart limit Raw B3 went negative for n < 6 Clamp with max(0.0, ...), or route small batches to X-bar R (Step 2)
False out-of-control alarms on the S chart ddof=0 (population σ) used in the subgroup std() Enforce ddof=1 so c₄'s bias correction is valid
Limits wildly off after a config change Hardcoded n = 5 constants reused for n = 15 batches Compute c₄/A₃/B₃/B₄ at runtime from the observed n (Step 2)

Lock subgroup size at the ingestion layer, validate the estimator route at runtime, and record n alongside every limit and capability figure. The constant tables and their precision requirements are governed by the AIAG SPC Reference Manual (ch. II); the c₄ unbiasing convention and the range-versus-standard-deviation guidance follow ISO 7870-2, and the same estimator that feeds the chart must feed $\sigma_{within}$ in any capability calculation per ASTM E2587.

FAQ

Where exactly is the boundary — does n = 10 use R or S?

n = 10 uses X-bar S. The convention is n = 2–9 on the range route and n ≥ 10 on the standard-deviation route, because the range estimator's efficiency has fallen below about 85% by the time you reach n = 9. Nothing forbids using S below n = 10, but there is little to gain and the cheaper range is fine there; above it, the range is materially worse and S becomes the correct default.

Can I just always use X-bar S and skip the routing?

You can — the standard deviation is the more efficient estimator at every n ≥ 2, and many modern tools default to it. The reasons to keep the router are practical: the range chart is easier to compute by hand on the shop floor, B3 needs clamping below n = 6, and n = 1 streams have no within-subgroup spread at all and must route to an I-MR chart regardless. The guard also catches subgroup-size drift, which is valuable no matter which chart you standardize on.

Why does misrouting large subgroups inflate Cpk instead of causing false alarms?

A biased-low σ̂ makes the control limits tighter, not wider, so the immediate charting symptom is subtle. The real damage is downstream: short-term capability uses $\sigma_{within}$ from $\bar{R}/d_2$ or $\bar{S}/c_4$, and when the range underestimates σ at large n, $\sigma_{within}$ shrinks and Cpk rises with no real change. Ppk, computed from the overall standard deviation, is unaffected, so a Cpk/Ppk ratio well above 1.3 is a reliable flag that the estimator, not the process, "improved."

What if my subgroup size varies from batch to batch?

For variables data such as X-bar, variable n is almost always a data defect rather than a design choice: fix it upstream in the missing-value policy and validation gate instead of averaging across ragged subgroups, which biases both the limits and $\sigma_{within}$. The router in Step 3 deliberately raises on mixed n so you catch this at ingestion. Genuinely variable counts belong to attribute charts (p, u), where limits legitimately stair-step with the per-subgroup count.

Up one level: X-Bar S Chart for Large Subgroups. For the full chart-selection map see SPC Fundamentals & Control Chart Taxonomy.