Rendering X-Bar R Charts with Plotly

An X-bar R chart is really two stacked charts that must share an x-axis: the subgroup means on top and the subgroup ranges below. This guide builds that paired figure in Plotly from limits that were frozen upstream, so the renderer draws exactly what it is handed and never re-derives a control boundary. It is the drawing recipe for the dynamic Plotly control chart rendering layer, and it assumes the numbers already exist — the range chart's drives the mean chart's limits, so the two panels are numerically coupled and must be plotted from a single settled result.

Prerequisites

Before you build a single trace, confirm the upstream state and the inputs the figure will consume:

  • Python 3.9+ with plotly installed (pip install plotly); pandas and numpy for the frame and the round-trip assertions.
  • Pre-computed limits from the calculation engine: the grand mean x_double_bar, the mean range r_bar, and the four limits xbar_ucl, xbar_lcl, r_ucl, r_lcl. Compute these with the X-bar R chart implementation recipe — this page does not recompute them.
  • Per-subgroup series ready to plot: an array of subgroup means, an array of subgroup ranges, and one x value (timestamp or subgroup index) per subgroup, all the same length.
  • Subgroup size n is fixed at 2–9 and matches the constant row used upstream; a mismatch silently biases every plotted limit.
  • At least 20–25 stable subgroups form the Phase I baseline that produced the limits, so the boundaries you are about to draw are trustworthy rather than preliminary.

Step 1 — Create the shared-axis subplot skeleton

Use make_subplots with two rows and one column, and set shared_xaxes=True so panning the mean chart pans the range chart in lockstep. Give the top panel more vertical room since operators read it first, and keep a small gap so the two titles never collide.

import plotly.graph_objects as go
from plotly.subplots import make_subplots


def build_xbar_r_skeleton() -> go.Figure:
    """Two stacked panels sharing one x-axis: X-bar on top, R below."""
    fig = make_subplots(
        rows=2, cols=1,
        shared_xaxes=True,
        vertical_spacing=0.08,
        row_heights=[0.6, 0.4],
        subplot_titles=("X-bar chart (subgroup means)", "R chart (subgroup ranges)"),
    )
    return fig

Verify in isolation that the skeleton has two y-axes before you plot anything: fig.layout.yaxis (row 1) and fig.layout.yaxis2 (row 2) both exist. If only one appears, make_subplots did not receive rows=2.

Step 2 — Add the measurement traces

Plot the subgroup means into row 1 and the subgroup ranges into row 2 with add_trace(..., row=r, col=1). Use mode="lines+markers" so both the trend and the individual sampled subgroups are visible. Keep the two traces visually distinct but muted — the limit lines, added next, carry the alarm signal and should dominate.

def add_measurement_traces(fig: go.Figure, x, means, ranges) -> go.Figure:
    """Draw subgroup means (row 1) and subgroup ranges (row 2)."""
    if not (len(x) == len(means) == len(ranges)):
        raise ValueError("x, means, and ranges must be equal length (one point per subgroup).")

    fig.add_trace(
        go.Scatter(x=x, y=means, mode="lines+markers", name="Subgroup mean",
                   line=dict(color="#1e6091", width=2), marker=dict(size=6)),
        row=1, col=1,
    )
    fig.add_trace(
        go.Scatter(x=x, y=ranges, mode="lines+markers", name="Subgroup range",
                   line=dict(color="#4a708b", width=2), marker=dict(size=6)),
        row=2, col=1,
    )
    return fig

The mean and range arrays must be aligned index-for-index to the same x values; a one-element offset between them will plot a subgroup's range under a neighbor's mean and quietly desynchronize the two panels.

Step 3 — Draw the frozen limits with add_hline

For an X-bar R chart the limits are flat horizontal lines, so add_hline is the correct tool — one call per boundary, targeting a specific row. The math the upstream engine already applied is, for subgroup size $n$ over $k$ subgroups:

$$\bar{\bar{X}} = \frac{1}{k}\sum_{i=1}^{k}\bar{X}_i, \qquad \bar{R} = \frac{1}{k}\sum_{i=1}^{k}R_i$$

$$UCL_{\bar{X}} = \bar{\bar{X}} + A_2\,\bar{R}, \qquad LCL_{\bar{X}} = \bar{\bar{X}} - A_2\,\bar{R}$$

$$UCL_R = D_4\,\bar{R}, \qquad LCL_R = D_3\,\bar{R}$$

Because $D_3 = 0.000$ for every $n$ up to and including six, the R chart's lower limit sits exactly on zero for small subgroups — draw it anyway, so operators see the floor explicitly rather than inferring it.

def add_control_limits(fig: go.Figure, limits: dict) -> go.Figure:
    """Draw CL/UCL/LCL as horizontal lines on each panel from frozen values."""
    red, green = "#c0392b", "#1b8a5a"

    # X-bar panel (row 1)
    fig.add_hline(y=limits["xbar_ucl"], line=dict(color=red, dash="dash", width=1.5),
                  annotation_text="UCL", annotation_position="right", row=1, col=1)
    fig.add_hline(y=limits["x_double_bar"], line=dict(color=green, width=1.5),
                  annotation_text="CL", annotation_position="right", row=1, col=1)
    fig.add_hline(y=limits["xbar_lcl"], line=dict(color=red, dash="dash", width=1.5),
                  annotation_text="LCL", annotation_position="right", row=1, col=1)

    # R panel (row 2)
    fig.add_hline(y=limits["r_ucl"], line=dict(color=red, dash="dash", width=1.5),
                  annotation_text="UCL", annotation_position="right", row=2, col=1)
    fig.add_hline(y=limits["r_bar"], line=dict(color=green, width=1.5),
                  annotation_text="CL", annotation_position="right", row=2, col=1)
    fig.add_hline(y=limits["r_lcl"], line=dict(color=red, dash="dash", width=1.5),
                  annotation_text="LCL", annotation_position="right", row=2, col=1)
    return fig

The solid green centerline distinguishes itself from the dashed red limits by dash pattern as well as hue, so the chart survives a monochrome print or a color-vision deficiency. The limit values come straight from the limits dict — nothing here recomputes A_2 or D_4.

Step 4 — Configure unified hover and the plotly_white theme

Set hovermode="x unified" so a single hover shows the mean and its range at the same subgroup, and apply the plotly_white template for a clean, print-friendly surface that matches archived exports. Label both y-axes and give the shared x-axis a single title on the bottom panel only.

def finalize_layout(fig: go.Figure, title: str) -> go.Figure:
    """Unified hover, plotly_white theme, and axis titles."""
    fig.update_layout(
        title=title,
        template="plotly_white",
        hovermode="x unified",
        margin=dict(l=70, r=60, t=70, b=50),
        legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
    )
    fig.update_yaxes(title_text="Subgroup mean", row=1, col=1)
    fig.update_yaxes(title_text="Subgroup range", row=2, col=1)
    fig.update_xaxes(title_text="Timestamp / subgroup", row=2, col=1)
    return fig

Step 5 — Assemble the full renderer

Wire the four steps into one function that takes the plotted series and the frozen limits and returns a figure. Keep it stateless so it can run per production line inside a scheduled job without shared state leaking between partitions.

def render_xbar_r_chart(x, means, ranges, limits: dict,
                        title: str = "X-bar R control chart") -> go.Figure:
    """Render a paired X-bar R Plotly figure from pre-computed limits.

    Parameters
    ----------
    x, means, ranges : array-like
        One value per subgroup, all equal length and index-aligned.
    limits : dict
        Frozen upstream limits: x_double_bar, r_bar, xbar_ucl, xbar_lcl,
        r_ucl, r_lcl. This function draws them; it never recomputes them.
    """
    required = {"x_double_bar", "r_bar", "xbar_ucl", "xbar_lcl", "r_ucl", "r_lcl"}
    missing = required - limits.keys()
    if missing:
        raise KeyError(f"Missing pre-computed limits: {sorted(missing)}")

    fig = build_xbar_r_skeleton()
    fig = add_measurement_traces(fig, x, means, ranges)
    fig = add_control_limits(fig, limits)
    return finalize_layout(fig, title)

Verification

The single most important check is a limit round-trip: assert that the values drawn on the figure are the values the engine handed in, to floating-point tolerance. A mismatch means the renderer altered a limit it was only supposed to draw.

import numpy as np

# Frozen limits from the calculation engine (see the X-bar R implementation page).
limits = {
    "x_double_bar": 11.0, "r_bar": 2.25,
    "xbar_ucl": 13.30175, "xbar_lcl": 8.69825,   # 11.0 +/- 1.023 * 2.25
    "r_ucl": 5.7915, "r_lcl": 0.0,               # 2.574 * 2.25 ; D3 = 0 at n <= 6
}
x = np.arange(1, 5)
means = np.array([11.0, 10.0, 12.0, 11.0])
ranges = np.array([2.0, 2.0, 2.0, 3.0])

fig = render_xbar_r_chart(x, means, ranges, limits)

# Two measurement traces (means + ranges) and six limit lines (3 per panel).
assert len(fig.data) == 2
assert len(fig.layout.shapes) == 6

# Limit round-trip: every drawn hline y matches a frozen input within tolerance.
drawn = sorted(s["y0"] for s in fig.layout.shapes)
expected = sorted([limits["xbar_ucl"], limits["x_double_bar"], limits["xbar_lcl"],
                   limits["r_ucl"], limits["r_bar"], limits["r_lcl"]])
assert np.allclose(drawn, expected, atol=1e-9)
print("X-bar R figure verified: limits drawn exactly as handed in.")

If the allclose assertion fires, the renderer is mutating limits — trace back through add_control_limits and confirm no arithmetic snuck in. Evaluate the R panel before the X-bar panel when reading the finished chart: the mean limits are derived from , so they are only meaningful once the range chart is confirmed in control.

Root-Cause Table

Symptom Cause Fix
Only one panel renders make_subplots called without rows=2, or traces added without row= Build the skeleton with rows=2, cols=1 and pass row=1/row=2 on every add_trace and add_hline
Ranges plotted under the wrong means means and ranges arrays are offset by one index Align both arrays to the same subgroup x values before plotting; assert equal length
Limit lines land on the wrong panel add_hline missing its row/col, so it spans both axes Always pass row=1, col=1 or row=2, col=1 to target one panel
Drawn limits differ from the engine Renderer recomputed A₂/D₄ instead of consuming frozen values Pass limits in as a dict and draw them verbatim; enforce the round-trip assert
R lower limit missing on the chart r_lcl = 0 treated as "nothing to draw" Draw the zero line explicitly so the floor is visible for n ≤ 6
Hover shows only one series hovermode left at default Set hovermode="x unified" so mean and range read together per subgroup

Frequently Asked Questions

Why plot the X-bar and R charts on a shared x-axis?

Because they describe the same subgroups from two angles: the R chart tracks within-subgroup variation and the X-bar chart tracks subgroup-to-subgroup location. Sharing the x-axis with shared_xaxes=True keeps them synchronized so a range spike and the mean it destabilizes line up vertically, which is how the two-panel chart is read. A range chart out of control invalidates the mean limits above it, so the panels must be inspected together.

Should the renderer ever compute A2 or D4 itself?

No. The renderer draws the limits it is handed and nothing more. Selecting A₂/D₄ and applying them belongs in the X-bar R chart implementation engine, which freezes a Phase I baseline. If the drawing layer recomputes a constant, two views of the same subgroup can disagree on whether a point is out of control, defeating the audit trail. The limit round-trip assertion exists precisely to catch a renderer that has started computing instead of drawing.

Why draw the R chart's lower limit when it is zero?

For subgroup sizes of six or fewer, $D_3 = 0.000$, so the R chart's lower control limit sits exactly on zero rather than at a negative value that would be clamped. Drawing that zero line explicitly tells the operator the floor is real and intentional, not missing — an R chart at small n genuinely cannot signal a reduction in variation, and showing the line makes that property visible instead of leaving it to be inferred.

Up one level: this page is part of dynamic Plotly control chart rendering. For the broader pipeline, see Automated Control Chart Generation and Calculation.