Adding UCL/LCL Annotation Overlays in Plotly

A flat add_hline is fine for a static baseline, but production charts need richer overlays: shaded 1σ/2σ/3σ zones for run-rule reading, bands that follow a moving limit instead of a straight line, and out-of-control points that stay legible in grayscale. This guide layers those overlays onto a Plotly figure without recomputing any statistic, extending the base figure built in rendering X-bar R charts with Plotly. It belongs to the dynamic Plotly control chart rendering layer, where the rule that statistics are computed once upstream and only drawn here is absolute.

Prerequisites

Confirm the figure and the flags exist before you overlay anything:

  • Python 3.9+ with plotly and numpy installed (pip install plotly numpy).
  • An existing go.Figure with a measurement trace already plotted — the overlays attach to it, they do not create it.
  • Frozen limit values: the centerline cl, and either scalar ucl/lcl for a fixed baseline or equal-length per-point ucl/lcl arrays from rolling window limit recalibration.
  • A boolean or coded out-of-control flag per point, produced upstream by out-of-control rule detection — the renderer marks the points it is told to, it does not decide which are violations.
  • Sigma known per point (or a single sigma) so zone boundaries at 1σ and 2σ can be placed; these come from the same upstream engine that set the 3σ limits.

Step 1 — Shade the 1σ, 2σ, and 3σ zones

Western Electric run rules read zones A, B, and C — the bands one, two, and three sigma from the centerline. Draw them as filled rectangles spanning the full x-range so operators can see at a glance which zone a point falls in. Use add_hrect, layering the widest band first so narrower bands paint on top.

import plotly.graph_objects as go


def add_sigma_zones(fig: go.Figure, cl: float, sigma: float, **kw) -> go.Figure:
    """Shade zones C (1s), B (2s), A (3s) above and below the centerline."""
    # Widest first so inner zones layer on top; low opacity keeps data readable.
    fig.add_hrect(y0=cl - 3 * sigma, y1=cl + 3 * sigma,
                  fillcolor="#c0392b", opacity=0.05, line_width=0, layer="below", **kw)
    fig.add_hrect(y0=cl - 2 * sigma, y1=cl + 2 * sigma,
                  fillcolor="#e08e0b", opacity=0.06, line_width=0, layer="below", **kw)
    fig.add_hrect(y0=cl - 1 * sigma, y1=cl + 1 * sigma,
                  fillcolor="#1b8a5a", opacity=0.07, line_width=0, layer="below", **kw)
    return fig

Zone C (green, innermost) is where most points should sit; zone A (red, outermost) touching the 3σ limit is where a single point trips a rule. The bands convey meaning by position, not color alone, so the chart still reads if the fills wash out in print.

Step 2 — Draw moving limits as a band with fill tonexty

When limits come from a rolling window they change per subgroup, and a straight add_hline would misrepresent them. Plot the upper limit, then the lower limit with fill="tonexty", so Plotly shades the region between the two traces. The centerline runs through the middle as its own line.

import numpy as np


def add_rolling_limit_band(fig: go.Figure, x, ucl, lcl, cl) -> go.Figure:
    """Draw per-point UCL/LCL as a shaded band for recalibrated limits."""
    x = np.asarray(x)
    if not (len(x) == len(ucl) == len(lcl) == len(cl)):
        raise ValueError("x, ucl, lcl, and cl must be equal length for a per-point band.")

    # Upper edge first (no fill), then lower edge fills up to the previous trace.
    fig.add_trace(go.Scatter(x=x, y=ucl, mode="lines", name="UCL",
                             line=dict(color="#c0392b", dash="dash", width=1.5)))
    fig.add_trace(go.Scatter(x=x, y=lcl, mode="lines", name="LCL", fill="tonexty",
                             fillcolor="rgba(192,57,43,0.06)",
                             line=dict(color="#c0392b", dash="dash", width=1.5)))
    fig.add_trace(go.Scatter(x=x, y=cl, mode="lines", name="CL",
                             line=dict(color="#1b8a5a", width=1.5)))
    return fig

The tonexty fill references the immediately preceding trace, so the UCL trace must be added directly before the LCL trace — insert nothing between them or the band will fill against the wrong series.

Step 3 — Flag out-of-control points by shape and color

A violation must be distinguishable without relying on hue, because roughly one in twelve viewers has a color-vision deficiency and audit prints are often grayscale. Overlay a second marker trace on the flagged points only, using a distinct symbol (an open diamond or x) and a larger size, so the flag survives desaturation.

def flag_violations(fig: go.Figure, x, y, is_violation) -> go.Figure:
    """Overlay out-of-control points with a shape-and-color marker."""
    x, y = np.asarray(x), np.asarray(y)
    mask = np.asarray(is_violation, dtype=bool)
    if mask.shape != y.shape:
        raise ValueError("is_violation must be one boolean per plotted point.")

    if mask.any():
        fig.add_trace(go.Scatter(
            x=x[mask], y=y[mask], mode="markers", name="Out of control",
            marker=dict(symbol="diamond-open", size=13, color="#c0392b",
                        line=dict(width=2, color="#c0392b")),
            hovertemplate="OUT OF CONTROL<br>x=%{x}<br>value=%{y}<extra></extra>",
        ))
    return fig

The flags themselves are computed upstream. The values that decide them are 3σ boundaries around the centerline:

$$UCL = \bar{X} + 3\sigma, \qquad CL = \bar{X}, \qquad LCL = \bar{X} - 3\sigma$$

where $\sigma$ is estimated from within-subgroup variation (for an Individuals chart, $\sigma = \overline{MR}/d_2$ with $d_2 = 1.128$ for the span-two moving range). The overlay only marks the points the Nelson and Western Electric rule detection layer already flagged.

Step 4 — Label the limits with annotations

Give each boundary a persistent text label anchored to the right edge so it is readable even when the legend is collapsed. Use add_annotation with xref="paper" so the labels pin to the axis frame rather than a data coordinate that scrolling would push off-screen.

def label_limits(fig: go.Figure, ucl: float, lcl: float, cl: float) -> go.Figure:
    """Anchor UCL/CL/LCL text labels to the right edge of the plot area."""
    for y, text, color in [(ucl, "UCL", "#c0392b"),
                           (cl, "CL", "#1b8a5a"),
                           (lcl, "LCL", "#c0392b")]:
        fig.add_annotation(x=1.0, y=y, xref="paper", yref="y",
                           text=text, showarrow=False, xanchor="left",
                           font=dict(size=12, color=color))
    return fig

For a moving band, label the last point of each edge instead of a single scalar so the annotation tracks the limit's current value rather than a fixed baseline.

Verification

Confirm each overlay added the elements it should and that the band filled between the correct traces. A per-point band is three traces; a fixed baseline with zones is a set of layout shapes.

import numpy as np

x = np.arange(1, 21)
cl = np.full(20, 10.0)
ucl = np.full(20, 13.0)
lcl = np.full(20, 7.0)
y = 10 + np.random.default_rng(0).normal(0, 0.8, 20)
y[7] = 13.6                                      # force one point above UCL
flags = y > ucl

fig = go.Figure(go.Scatter(x=x, y=y, mode="lines+markers", name="Measurement"))
fig = add_rolling_limit_band(fig, x, ucl, lcl, cl)
fig = flag_violations(fig, x, y, flags)

# 1 measurement + 3 band traces (UCL, LCL, CL) + 1 violation trace = 5 traces.
assert len(fig.data) == 5
# The LCL trace fills toward the previous (UCL) trace.
lcl_trace = next(t for t in fig.data if t.name == "LCL")
assert lcl_trace.fill == "tonexty"
# Exactly the flagged points are marked.
viol = next(t for t in fig.data if t.name == "Out of control")
assert len(viol.x) == int(flags.sum()) == 1
print("Overlays verified: band filled correctly and one violation flagged.")

If the band fills against the measurement trace instead of the UCL trace, another trace was inserted between the UCL and LCL add_trace calls — tonexty always references whatever was added immediately before. If the violation count disagrees with the upstream flags, the mask is misaligned to the plotted series and should be regenerated, not patched at draw time.

Root-Cause Table

Symptom Cause Fix
Band fills against the wrong series A trace was added between the UCL and LCL add_trace calls Add UCL immediately before LCL; fill="tonexty" references the previous trace only
Zones hide the data Fill opacity too high or drawn on top of the data Use opacity ≤ 0.07 and layer="below" so bands sit behind the measurement trace
Violations invisible in grayscale Points flagged by color alone Overlay a distinct marker symbol (diamond-open, x) and larger size, not just a red fill
Flagged count differs from the rule engine The boolean mask is misaligned to the plotted points Regenerate the flag array against the exact plotted series; assert equal length
Labels scroll off with the data Annotations anchored to a data x coordinate Anchor with xref="paper" at x=1.0 so labels pin to the axis frame
Moving limits shown as one flat line add_hline used for per-point limits Draw per-point band traces with fill="tonexty" instead of a single horizontal line

Frequently Asked Questions

Why flag out-of-control points by shape and not just color?

Roughly one viewer in twelve has a color-vision deficiency, and audit packets are frequently printed or photocopied in grayscale, where a red marker and a blue marker become indistinguishable. Encoding a violation with a distinct symbol — an open diamond or an x, at a larger size — means the flag survives desaturation. Color still helps sighted operators scan quickly, but shape carries the signal so the chart never depends on hue alone.

When should I use a band instead of add_hline for limits?

Use a flat add_hline only when the limit is a single fixed baseline. As soon as the limits vary per point — anything produced by rolling window limit recalibration — a horizontal line misrepresents a moving boundary. Plot the upper and lower limits as two Scatter traces and fill between them with fill="tonexty" so the shaded band tracks the actual limit at every subgroup.

Where do the violation flags come from?

Not from the renderer. The overlay marks the points it is told to; the decision of which points violate a rule is made upstream by out-of-control rule detection, which evaluates the Nelson and Western Electric run rules against the frozen limits. The renderer receives a boolean flag per point and draws a marker on the true ones, keeping detection and presentation cleanly separated.

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