Behnam Analytics

Writing Data analysis & statistics

SPC charts for operational metrics

How to build an XmR chart for a weekly metric, read the four special-cause rules, recalculate limits after a real change, and stop reacting to RAG noise.

Behnam Ebrahimi 8 min read

Every weekly metric moves. The DNA rate goes up a bit, then down a bit, and someone asks why. Most of those moves have no cause worth finding: they are the ordinary noise of a stable process. A statistical process control (SPC) chart separates that noise from the moves that do mean something, using limits calculated from the data itself rather than from a target someone chose.

This article builds an XmR chart for a weekly outpatient DNA (did not attend) rate, applies the four rules that NHS England’s Making Data Count programme teaches, recalculates the limits after a genuine change, and shows how a red-amber-green (RAG) rating against a single target gets the same data wrong.

The example

The data is synthetic, generated by spc_charts.py with a fixed seed. It covers 104 weeks from July 2024, with about 1,150 booked appointments a week. Three things are planted:

  • A stable process with a true DNA rate of 8.6%, plus a little week-to-week wobble on top of binomial noise.
  • One disrupted week, commencing 10 March 2025, with a true rate of 12.8%. Think of a snow week or a booking-system outage.
  • Text reminders starting on 3 November 2025, after which the true rate is 6.9%.

The question the chart has to answer is the one a service manager asks: did anything change, and when did we know?

Building the XmR chart

An XmR chart has two parts. The X chart plots the values. The mR chart plots the moving range, the absolute difference between each value and the one before. The moving range measures short-term variation, and the limits come from it:

  • centre line = mean of the values
  • process limits = mean ± 2.66 × mean moving range
  • upper limit for the mR chart = 3.267 × mean moving range

The 2.66 is not arbitrary. The mean moving range divided by 1.128 (a constant for ranges of two points) estimates the process standard deviation, and 3 ÷ 1.128 is 2.66. So the limits sit about three standard deviations either side of the mean. Making Data Count describes them as the band where you can expect about 99% of points to fall.

Why the moving range and not the standard deviation of all the points? Because a shift in the process inflates the overall standard deviation, which widens the limits and hides the shift you are looking for. Consecutive differences barely notice a step change, so the limits stay honest.

In Python:

def xmr_limits(values: np.ndarray) -> dict[str, float]:
    """Mean, process limits and mean moving range for an XmR chart."""
    mean = float(values.mean())
    mr_bar = float(np.abs(np.diff(values)).mean())
    return {
        "mean": mean,
        "upper": mean + 2.66 * mr_bar,
        "lower": mean - 2.66 * mr_bar,
        "mr_bar": mr_bar,
    }

In SQL, if you already have a weekly table, LAG gives you the moving range. I ran this in SQLite against the example data and it returns the same limits as the Python:

WITH mr AS (
  SELECT week_start,
         dna_rate,
         ABS(dna_rate - LAG(dna_rate) OVER (ORDER BY week_start)) AS moving_range
  FROM weekly_dna
  WHERE week_start < '2024-12-30'   -- the 26 baseline weeks
)
SELECT AVG(dna_rate)                            AS centre,
       AVG(dna_rate) + 2.66 * AVG(moving_range) AS upper_limit,
       AVG(dna_rate) - 2.66 * AVG(moving_range) AS lower_limit
FROM mr;

The first week has no previous week, so its moving range is NULL, and AVG ignores it. That is exactly what you want.

I calculated the limits from the first 26 weeks (July to December 2024) and then froze them. Making Data Count suggests 15 to 20 points as the minimum for limits you can rely on, so 26 is comfortable. The baseline gives a mean of 8.56%, a mean moving range of 0.87 percentage points, and process limits of 6.24% and 10.88%.

Weekly DNA rate against limits from the first 26 weeksXmR chart: mean and process limits from July to December 2024, extended
Data table
Week commencingDNA rate
2024-07-019.5%
2024-07-088.1%
2024-07-157.6%
2024-07-227.4%
2024-07-298.7%
2024-08-058.3%
2024-08-128.6%
2024-08-197.8%
2024-08-2610.2%
2024-09-029.4%
2024-09-098.5%
2024-09-168.0%
2024-09-237.5%
2024-09-308.3%
2024-10-079.1%
2024-10-1410.2%
2024-10-218.3%
2024-10-289.5%
2024-11-049.4%
2024-11-118.6%
2024-11-189.2%
2024-11-257.9%
2024-12-027.9%
2024-12-097.2%
2024-12-169.2%
2024-12-238.4%
2024-12-308.1%
2025-01-068.3%
2025-01-138.5%
2025-01-208.7%
2025-01-278.5%
2025-02-037.6%
2025-02-108.9%
2025-02-179.1%
2025-02-249.0%
2025-03-038.7%
2025-03-1012.9%
2025-03-176.9%
2025-03-248.1%
2025-03-318.8%
2025-04-078.9%
2025-04-148.1%
2025-04-219.3%
2025-04-288.1%
2025-05-059.0%
2025-05-128.3%
2025-05-198.0%
2025-05-269.3%
2025-06-027.7%
2025-06-098.2%
2025-06-168.3%
2025-06-2310.5%
2025-06-308.8%
2025-07-077.4%
2025-07-149.3%
2025-07-219.4%
2025-07-287.0%
2025-08-049.9%
2025-08-118.1%
2025-08-189.6%
2025-08-259.8%
2025-09-018.9%
2025-09-088.0%
2025-09-158.5%
2025-09-229.0%
2025-09-2910.5%
2025-10-069.9%
2025-10-138.4%
2025-10-207.8%
2025-10-278.1%
2025-11-037.3%
2025-11-108.6%
2025-11-177.5%
2025-11-246.9%
2025-12-017.7%
2025-12-087.3%
2025-12-156.1%
2025-12-226.0%
2025-12-298.1%
2026-01-057.2%
2026-01-127.7%
2026-01-196.0%
2026-01-267.3%
2026-02-027.8%
2026-02-096.2%
2026-02-165.4%
2026-02-236.3%
2026-03-026.1%
2026-03-097.5%
2026-03-166.4%
2026-03-237.8%
2026-03-306.6%
2026-04-068.5%
2026-04-137.6%
2026-04-206.3%
2026-04-276.6%
2026-05-046.1%
2026-05-116.5%
2026-05-188.7%
2026-05-256.8%
2026-06-016.3%
2026-06-086.9%
2026-06-157.7%
2026-06-227.5%

Synthetic data. Source: projects/article-examples/stats/spc_charts.py.

Common cause and special cause

Points that wander inside the limits with no pattern are common cause variation. They come from the way the process works: which patients were booked, the weather, the day the clinic letters went out. Asking why one week was higher than the last is asking the process to explain its own noise. There is no answer to find, and chasing one wastes time.

Special cause variation is a signal that something outside the usual system has acted, for better or worse. It is worth investigating, and the rules below tell you when you have it.

For the 70 weeks before the reminders started, this process was stable. The only special cause was the disrupted week, at 12.88%, well above the upper limit. The other 69 weeks were noise.

The four rules

The Making Data Count getting started guide describes four rules for healthcare data:

  1. A single point outside the process limits.
  2. A run of points on one side of the mean. The guide says most people use between seven and nine points, and that fewer than six is not enough. I use seven.
  3. Six consecutive points increasing, or six decreasing. A trend of two or three points is not a trend.
  4. Two out of three consecutive points close to a process limit, meaning in the outer third of the band. I apply it one side at a time.

The run rule is the one that catches a modest, sustained shift, and it is simple to code: flag any stretch of seven or more points with the same sign relative to the mean.

def shift_flags(values: np.ndarray, lim: dict[str, float]) -> np.ndarray:
    """Seven or more consecutive points on the same side of the mean."""
    return runs_of(np.sign(values - lim["mean"]), RUN_LENGTH)

After the reminders started on 3 November 2025, here is when each rule would first have fired, using only the data available that week:

Rule First signal Weeks after the change
Point outside the limits w/c 15 December 2025 (6.13%) 6
Two of three in the outer third w/c 22 December 2025 7
Run of seven below the mean w/c 29 December 2025 8
Six points falling never –

The trend rule never fired, and that is correct. The process stepped down; it did not slide. Over the 34 weeks after the change, seven fell below the old lower limit.

Six weeks is a realistic time to confirm a step of about 1.6 percentage points in a metric this noisy. A dashboard that compares this week with last week would have announced success (or failure) several times before then, on noise alone.

Recalculating limits after a real change

Once the rules have fired, it is tempting to redraw the limits straight away. Making Data Count advises against doing that automatically. Before redrawing, be certain that the system has fundamentally changed: talk to the service, find the cause, and only then recalculate from the point where the new system started. And you need as many points from the new system as you would for any other chart. If you wanted 20 points before, you need 20 points after.

Here the cause is known, so I recalculated from the 20 weeks starting 3 November 2025. The new process has a mean of 6.98% and limits of 4.47% and 9.49%. None of the 34 post-change weeks breaks a rule against those limits. The new process is stable at a lower level. With real data, the chart shows when the rate changed, not that the reminders caused it; that takes a controlled test like the appointment reminder experiment.

Weekly DNA rate with limits recalculated after the changeBefore: first 26 weeks. After: first 20 weeks of the new process
Data table
Week commencingDNA rateMeanUpper limitLower limit
2024-07-019.5%8.6%10.9%6.2%
2024-07-088.1%8.6%10.9%6.2%
2024-07-157.6%8.6%10.9%6.2%
2024-07-227.4%8.6%10.9%6.2%
2024-07-298.7%8.6%10.9%6.2%
2024-08-058.3%8.6%10.9%6.2%
2024-08-128.6%8.6%10.9%6.2%
2024-08-197.8%8.6%10.9%6.2%
2024-08-2610.2%8.6%10.9%6.2%
2024-09-029.4%8.6%10.9%6.2%
2024-09-098.5%8.6%10.9%6.2%
2024-09-168.0%8.6%10.9%6.2%
2024-09-237.5%8.6%10.9%6.2%
2024-09-308.3%8.6%10.9%6.2%
2024-10-079.1%8.6%10.9%6.2%
2024-10-1410.2%8.6%10.9%6.2%
2024-10-218.3%8.6%10.9%6.2%
2024-10-289.5%8.6%10.9%6.2%
2024-11-049.4%8.6%10.9%6.2%
2024-11-118.6%8.6%10.9%6.2%
2024-11-189.2%8.6%10.9%6.2%
2024-11-257.9%8.6%10.9%6.2%
2024-12-027.9%8.6%10.9%6.2%
2024-12-097.2%8.6%10.9%6.2%
2024-12-169.2%8.6%10.9%6.2%
2024-12-238.4%8.6%10.9%6.2%
2024-12-308.1%8.6%10.9%6.2%
2025-01-068.3%8.6%10.9%6.2%
2025-01-138.5%8.6%10.9%6.2%
2025-01-208.7%8.6%10.9%6.2%
2025-01-278.5%8.6%10.9%6.2%
2025-02-037.6%8.6%10.9%6.2%
2025-02-108.9%8.6%10.9%6.2%
2025-02-179.1%8.6%10.9%6.2%
2025-02-249.0%8.6%10.9%6.2%
2025-03-038.7%8.6%10.9%6.2%
2025-03-1012.9%8.6%10.9%6.2%
2025-03-176.9%8.6%10.9%6.2%
2025-03-248.1%8.6%10.9%6.2%
2025-03-318.8%8.6%10.9%6.2%
2025-04-078.9%8.6%10.9%6.2%
2025-04-148.1%8.6%10.9%6.2%
2025-04-219.3%8.6%10.9%6.2%
2025-04-288.1%8.6%10.9%6.2%
2025-05-059.0%8.6%10.9%6.2%
2025-05-128.3%8.6%10.9%6.2%
2025-05-198.0%8.6%10.9%6.2%
2025-05-269.3%8.6%10.9%6.2%
2025-06-027.7%8.6%10.9%6.2%
2025-06-098.2%8.6%10.9%6.2%
2025-06-168.3%8.6%10.9%6.2%
2025-06-2310.5%8.6%10.9%6.2%
2025-06-308.8%8.6%10.9%6.2%
2025-07-077.4%8.6%10.9%6.2%
2025-07-149.3%8.6%10.9%6.2%
2025-07-219.4%8.6%10.9%6.2%
2025-07-287.0%8.6%10.9%6.2%
2025-08-049.9%8.6%10.9%6.2%
2025-08-118.1%8.6%10.9%6.2%
2025-08-189.6%8.6%10.9%6.2%
2025-08-259.8%8.6%10.9%6.2%
2025-09-018.9%8.6%10.9%6.2%
2025-09-088.0%8.6%10.9%6.2%
2025-09-158.5%8.6%10.9%6.2%
2025-09-229.0%8.6%10.9%6.2%
2025-09-2910.5%8.6%10.9%6.2%
2025-10-069.9%8.6%10.9%6.2%
2025-10-138.4%8.6%10.9%6.2%
2025-10-207.8%8.6%10.9%6.2%
2025-10-278.1%8.6%10.9%6.2%
2025-11-037.3%7.0%9.5%4.5%
2025-11-108.6%7.0%9.5%4.5%
2025-11-177.5%7.0%9.5%4.5%
2025-11-246.9%7.0%9.5%4.5%
2025-12-017.7%7.0%9.5%4.5%
2025-12-087.3%7.0%9.5%4.5%
2025-12-156.1%7.0%9.5%4.5%
2025-12-226.0%7.0%9.5%4.5%
2025-12-298.1%7.0%9.5%4.5%
2026-01-057.2%7.0%9.5%4.5%
2026-01-127.7%7.0%9.5%4.5%
2026-01-196.0%7.0%9.5%4.5%
2026-01-267.3%7.0%9.5%4.5%
2026-02-027.8%7.0%9.5%4.5%
2026-02-096.2%7.0%9.5%4.5%
2026-02-165.4%7.0%9.5%4.5%
2026-02-236.3%7.0%9.5%4.5%
2026-03-026.1%7.0%9.5%4.5%
2026-03-097.5%7.0%9.5%4.5%
2026-03-166.4%7.0%9.5%4.5%
2026-03-237.8%7.0%9.5%4.5%
2026-03-306.6%7.0%9.5%4.5%
2026-04-068.5%7.0%9.5%4.5%
2026-04-137.6%7.0%9.5%4.5%
2026-04-206.3%7.0%9.5%4.5%
2026-04-276.6%7.0%9.5%4.5%
2026-05-046.1%7.0%9.5%4.5%
2026-05-116.5%7.0%9.5%4.5%
2026-05-188.7%7.0%9.5%4.5%
2026-05-256.8%7.0%9.5%4.5%
2026-06-016.3%7.0%9.5%4.5%
2026-06-086.9%7.0%9.5%4.5%
2026-06-157.7%7.0%9.5%4.5%
2026-06-227.5%7.0%9.5%4.5%

Synthetic data. Source: projects/article-examples/stats/spc_charts.py.

Recalculating matters for the next question. If you keep judging against the old limits, every future week looks like a success, and you lose the ability to spot the next change, including a slow drift back to the old habits.

Why RAG against a single target misleads

Suppose the service has a local target of 8% or lower, and the performance report shows each week as red or green against it.

In the 69 stable weeks before the reminders (the disrupted week left out), the RAG rating said red 53 times and green 16 times, and it changed colour 24 times. The process did not change once in those 69 weeks. Every one of those colour changes was noise, and each could have prompted a question at a meeting.

The SPC view gives a better answer. The target of 8% sat inside the process limits of 6.24% to 10.88%. Making Data Count describes this case directly: when the target sits inside the limits, the process will sometimes meet it and sometimes fail, and a RAG report will flip between red and green. The honest message is that the process cannot reliably hit the target, and meeting it consistently needs a change to the process, not an explanation for last week.

After the reminders, the RAG status was green in 30 of 34 weeks and still red in 4. The new upper limit is 9.49%, above the target, so this is still not a process that meets 8% every week. A single “green” on a board report would hide that.

The same logic runs the other way. A green week can give false assurance while the process deteriorates, if the points are drifting towards the target from the safe side. Making Data Count makes this point as well.

Practical notes

  • Rates with changing denominators. An XmR chart works for a rate when the denominators are broadly similar week to week, as they are here (about 1,150 appointments). If the volume swings a lot, a p-chart with limits that vary by denominator is a better fit.
  • Comparing units, not weeks. SPC compares a process with its own past. To compare clinics or practices of different sizes at one point in time, use a funnel plot.
  • Keep special causes out of the baseline. If a disrupted week falls inside your baseline, it widens the limits. Investigate it, and consider excluding it from the calculation (while still plotting it).
  • Freeze the limits. Recalculating every week makes the limits chase the data, and a gradual shift never breaks a rule.
  • Too few points? With fewer than 15 to 20 points, draw a run chart (values against the median) and wait for more data before adding limits.

Reproduce

The chart data comes from projects/article-examples/stats/spc_charts.py, which simulates the weeks, calculates the limits, applies the four rules in real time, and counts the RAG colour changes:

uv run python projects/article-examples/stats/spc_charts.py

The SPC toolkit packages the same limits and rules as tested Python functions, and reproduces the limits and signals above exactly.