Behnam Analytics

Work Analytics engineering

SPC toolkit, a small Python package

XmR and p-chart limits, the four special-cause rules and funnel plot limits with overdispersion adjustment, packaged as typed, pure functions with 471 test cases and installable from a zip.

Synthetic data Every record here is generated. No real patient or organisational data is used.

Test cases, from 51 test functions
471
Public functions, all pure and typed
10
Lines of code, not counting docstrings, comments or blank lines
301

spckit is a small Python package for the arithmetic behind statistical process control (SPC) charts and funnel plots: XmR and p-chart limits, the four special-cause rules from NHS England’s Making Data Count, and funnel plot limits with an overdispersion adjustment. It is the kind of library an analytics team shares so that every chart on every dashboard is built the same way.

It implements the methods in two articles on this site, SPC charts for operational metrics and funnel plots for comparing units of very different size, with the same constants and rules. Run against the scripts behind both articles, it reproduces their limits, flagged weeks, first signals, φ, τ and flagged practices exactly.

In short: 10 public functions in 301 lines of code, NumPy and pandas as the only dependencies, 471 test cases, and a wheel that builds and installs cleanly on Python 3.12. The three examples below use synthetic data, and every number comes from examples/run.py.

Why a package, not notebook cells

An XmR chart is a few lines of NumPy, so it is tempting to paste them into each notebook that needs one. The trouble starts with the second copy. One copy calculates the limits from every point instead of a baseline, another uses the standard deviation instead of the moving range, a third runs the shift rule with eight points instead of seven. Each is a plausible choice, and together they mean two dashboards can disagree about whether a service changed.

A package gives the calculation one home, with one set of tests, a changelog and a version number. A bug is fixed once and picked up everywhere with an upgrade. Packaging analysis code as a library covers when that trade is worth making; this project is the worked example.

The API

Ten functions:

Function Returns
xmr_limits(values) XmRLimits: centre, upper, lower, mean moving range, moving range limit
moving_ranges(values) array of absolute differences between consecutive values
pooled_proportion(numerators, denominators) total over total, the centre line for a p-chart or funnel
p_chart_limits(centre, denominators) PChartLimits: one upper and lower limit per period
special_causes(values, limits) DataFrame with one True/False column per rule, keeping the input’s index
first_signals(values, limits, start) the first position where each rule would have fired live
funnel_limits(p0, denominators, z, phi, tau2) FunnelLimits: lower and upper limit per unit
z_scores(numerators, denominators, p0) each unit’s standardised distance from p0
overdispersion(numerators, denominators, p0) Overdispersion: φ, τ², the chi-squared check
between_unit_variance(denominators, p0, phi) τ² for additive limits

A typical XmR use reads the data, freezes limits from a baseline, and flags the rest:

import pandas as pd
from spckit import first_signals, special_causes, xmr_limits

weekly = pd.read_csv("assessment-times-xmr.csv", index_col="week", parse_dates=True)
minutes = weekly["minutes"]

limits = xmr_limits(minutes.iloc[:20])       # baseline: the first 20 weeks
flags = special_causes(minutes, limits)      # one boolean column per rule, same index
first = first_signals(minutes, limits, start=43)  # 43 is w/c 3 November 2025
print(limits, first, sep="\n")
XmRLimits(centre=16.765, upper=20.335, lower=13.195, mr_bar=1.3421052631578947, mr_upper=4.384657894736842)
{'outside_limits': 45, 'shift': 47, 'trend': 45, 'two_of_three': 45}

special_causes flags every point in a pattern, so all seven points of a shift are marked. first_signals answers the manager’s question: using only the data we had each week, when would we have known?

Example 1: an XmR chart

The data is 78 weeks of mean minutes from arrival to initial assessment in a fictional emergency department. I planted a stable process around 17 minutes, one disrupted week (an IT outage, w/c 14 July 2025) and a rapid-assessment process from 3 November 2025 that lowers the mean to 14.5 minutes.

Limits from the first 20 weeks: a mean of 16.8 minutes and process limits of 13.2 to 20.3. Against them, the outage week (23.4 minutes) is outside the limits, and so are its two moving ranges, 6.2 and 6.7 against a moving range limit of 4.38. After the change, three rules fire live in week 3 (w/c 17 November): a point outside the limits, a trend of six falling points, and two of three in the outer third. The run of seven below the mean follows in week 5 (w/c 1 December).

Minutes from arrival to initial assessment, by weekXmR chart; special-cause points from all four rules
Data table
From the first 20 weeks
Week commencingMinutes to assessmentMeanUpper limitLower limitSpecial cause
2025-01-0617.316.820.313.2
2025-01-1316.716.820.313.2
2025-01-2016.716.820.313.2
2025-01-2717.216.820.313.2
2025-02-0318.016.820.313.2
2025-02-1015.516.820.313.2
2025-02-1715.116.820.313.2
2025-02-2416.116.820.313.2
2025-03-0317.616.820.313.2
2025-03-1016.416.820.313.2
2025-03-1717.716.820.313.2
2025-03-2415.116.820.313.2
2025-03-3116.916.820.313.2
2025-04-0716.716.820.313.2
2025-04-1418.316.820.313.2
2025-04-2118.916.820.313.2
2025-04-2816.116.820.313.2
2025-05-0516.716.820.313.2
2025-05-1214.516.820.313.2
2025-05-1917.816.820.313.2
2025-05-2618.016.820.313.2
2025-06-0217.016.820.313.2
2025-06-0919.516.820.313.2
2025-06-1616.116.820.313.2
2025-06-2317.816.820.313.2
2025-06-3017.516.820.313.2
2025-07-0717.216.820.313.2
2025-07-1423.416.820.313.223.4
2025-07-2116.716.820.313.2
2025-07-2816.816.820.313.2
2025-08-0417.916.820.313.2
2025-08-1115.616.820.313.2
2025-08-1817.216.820.313.2
2025-08-2517.916.820.313.2
2025-09-0117.616.820.313.2
2025-09-0817.616.820.313.2
2025-09-1517.816.820.313.2
2025-09-2216.016.820.313.2
2025-09-2918.516.820.313.2
2025-10-0615.616.820.313.2
2025-10-1317.616.820.313.217.6
2025-10-2016.616.820.313.216.6
2025-10-2716.516.820.313.216.5
2025-11-0315.516.820.313.215.5
2025-11-1013.916.820.313.213.9
2025-11-1712.316.820.313.212.3
2025-11-2412.716.820.313.212.7
2025-12-0114.916.820.313.214.9
2025-12-0813.216.820.313.213.2
2025-12-1514.416.820.313.214.4
2025-12-2215.616.820.313.215.6
2025-12-2915.216.820.313.215.2
2026-01-0514.916.820.313.214.9
2026-01-1215.116.820.313.215.1
2026-01-1913.416.820.313.213.4
2026-01-2613.516.820.313.213.5
2026-02-0214.216.820.313.214.2
2026-02-0913.416.820.313.213.4
2026-02-1614.416.820.313.214.4
2026-02-2312.616.820.313.212.6
2026-03-0214.416.820.313.214.4
2026-03-0915.116.820.313.215.1
2026-03-1614.416.820.313.214.4
2026-03-2316.216.820.313.216.2
2026-03-3015.116.820.313.215.1
2026-04-0613.216.820.313.213.2
2026-04-1314.516.820.313.214.5
2026-04-2013.916.820.313.213.9
2026-04-2713.816.820.313.213.8
2026-05-0414.616.820.313.214.6
2026-05-1113.816.820.313.213.8
2026-05-1814.216.820.313.214.2
2026-05-2513.016.820.313.213.0
2026-06-0113.716.820.313.213.7
2026-06-0815.016.820.313.215.0
2026-06-1515.416.820.313.215.4
2026-06-2213.716.820.313.213.7
2026-06-2914.316.820.313.214.3
Recalculated after the change
Week commencingMinutes to assessmentMeanUpper limitLower limitSpecial cause
2025-01-0617.316.820.313.2
2025-01-1316.716.820.313.2
2025-01-2016.716.820.313.2
2025-01-2717.216.820.313.2
2025-02-0318.016.820.313.2
2025-02-1015.516.820.313.2
2025-02-1715.116.820.313.2
2025-02-2416.116.820.313.2
2025-03-0317.616.820.313.2
2025-03-1016.416.820.313.2
2025-03-1717.716.820.313.2
2025-03-2415.116.820.313.2
2025-03-3116.916.820.313.2
2025-04-0716.716.820.313.2
2025-04-1418.316.820.313.2
2025-04-2118.916.820.313.2
2025-04-2816.116.820.313.2
2025-05-0516.716.820.313.2
2025-05-1214.516.820.313.2
2025-05-1917.816.820.313.2
2025-05-2618.016.820.313.2
2025-06-0217.016.820.313.2
2025-06-0919.516.820.313.2
2025-06-1616.116.820.313.2
2025-06-2317.816.820.313.2
2025-06-3017.516.820.313.2
2025-07-0717.216.820.313.2
2025-07-1423.416.820.313.223.4
2025-07-2116.716.820.313.2
2025-07-2816.816.820.313.2
2025-08-0417.916.820.313.2
2025-08-1115.616.820.313.2
2025-08-1817.216.820.313.2
2025-08-2517.916.820.313.2
2025-09-0117.616.820.313.2
2025-09-0817.616.820.313.2
2025-09-1517.816.820.313.2
2025-09-2216.016.820.313.2
2025-09-2918.516.820.313.2
2025-10-0615.616.820.313.2
2025-10-1317.616.820.313.2
2025-10-2016.616.820.313.2
2025-10-2716.516.820.313.2
2025-11-0315.514.217.011.3
2025-11-1013.914.217.011.3
2025-11-1712.314.217.011.3
2025-11-2412.714.217.011.3
2025-12-0114.914.217.011.3
2025-12-0813.214.217.011.3
2025-12-1514.414.217.011.3
2025-12-2215.614.217.011.3
2025-12-2915.214.217.011.3
2026-01-0514.914.217.011.3
2026-01-1215.114.217.011.3
2026-01-1913.414.217.011.3
2026-01-2613.514.217.011.3
2026-02-0214.214.217.011.3
2026-02-0913.414.217.011.3
2026-02-1614.414.217.011.3
2026-02-2312.614.217.011.3
2026-03-0214.414.217.011.3
2026-03-0915.114.217.011.3
2026-03-1614.414.217.011.3
2026-03-2316.214.217.011.3
2026-03-3015.114.217.011.3
2026-04-0613.214.217.011.3
2026-04-1314.514.217.011.3
2026-04-2013.914.217.011.3
2026-04-2713.814.217.011.3
2026-05-0414.614.217.011.3
2026-05-1113.814.217.011.3
2026-05-1814.214.217.011.3
2026-05-2513.014.217.011.3
2026-06-0113.714.217.011.3
2026-06-0815.014.217.011.3
2026-06-1515.414.217.011.3
2026-06-2213.714.217.011.3
2026-06-2914.314.217.011.3

Synthetic data. Source: projects/spc-toolkit/examples/run.py.

The retrospective flags start earlier: the shift on 20 October, two weeks before the change, because those weeks happened to sit just below the mean and belong to the same run, and the trend on 13 October. That is why the package keeps flags and live signals apart.

Switch to the recalculated limits. The new process has a mean of 14.2 minutes and limits of 11.3 to 17.0, no week after the change breaks a rule against them, and the outage is the only special cause left. Each phase is judged separately, since a run shouldn’t carry across a recalculation:

phased = pd.concat([special_causes(minutes[~change], base), special_causes(after, new)])

Example 2: a p-chart when volumes change

Here the data is 30-day readmissions by week of discharge for a surgical specialty. Discharges run from 53 to 177 a week: about 70 a week at first (fewer in holiday weeks), then 148 a week on average once a second ward opens on 1 September 2025. I planted a true rate of 9.5%, and three bad weeks from 13 October at 17%.

The p-chart’s centre line is the pooled rate over the first 26 weeks, 8.96%, and each week gets limits for its own denominator: 0% to 19.2% at 70 discharges, 2.0% to 16.0% at 150. Switch to the XmR chart on the same rates to see the difference.

30-day readmission rate by week of dischargeSame weeks, two charts: switch to see which weeks each one flags
Data table
p-chart: limits follow the discharges
Week commencingReadmission rateCentre line (limits shaded)Centre line (limits shaded) (low)Centre line (limits shaded) (high)Special cause
2025-01-065.9%9.0%0.0%19.3%
2025-01-1314.5%9.0%0.0%19.3%
2025-01-207.7%9.0%0.0%17.9%
2025-01-2713.2%9.0%0.0%19.3%
2025-02-0311.8%9.0%0.0%18.8%
2025-02-1011.3%9.0%0.0%19.1%
2025-02-178.5%9.0%0.0%18.4%
2025-02-246.0%9.0%0.0%19.4%
2025-03-0312.9%9.0%0.0%19.2%
2025-03-100.0%9.0%0.0%20.7%0.0%
2025-03-177.4%9.0%0.0%18.5%
2025-03-241.6%9.0%0.0%19.9%1.6%
2025-03-3112.7%9.0%0.0%18.6%
2025-04-0713.8%9.0%0.0%20.2%
2025-04-146.8%9.0%0.0%20.1%
2025-04-216.6%9.0%0.0%19.9%
2025-04-2811.1%9.0%0.0%19.1%
2025-05-058.3%9.0%0.0%20.0%
2025-05-123.7%9.0%0.0%18.5%
2025-05-195.8%9.0%0.0%19.3%
2025-05-2611.8%9.0%0.0%19.3%
2025-06-029.3%9.0%0.0%18.2%
2025-06-098.3%9.0%0.0%20.0%
2025-06-1613.6%9.0%0.0%19.5%
2025-06-2312.5%9.0%0.0%19.1%
2025-06-305.6%9.0%0.0%19.1%
2025-07-079.1%9.0%0.0%18.7%
2025-07-1421.9%9.0%0.0%19.7%21.9%
2025-07-216.2%9.0%0.0%19.7%
2025-07-289.5%9.0%0.0%18.9%
2025-08-0413.0%9.0%0.0%19.3%
2025-08-117.1%9.0%0.0%19.2%
2025-08-1812.5%9.0%0.0%19.7%
2025-08-2512.5%9.0%0.0%19.1%
2025-09-0110.1%9.0%1.7%16.2%
2025-09-0813.5%9.0%2.1%15.8%
2025-09-158.1%9.0%1.9%16.0%
2025-09-2210.9%9.0%1.9%16.0%
2025-09-2911.0%9.0%2.3%15.7%
2025-10-0611.6%9.0%1.9%16.0%
2025-10-1322.1%9.0%2.2%15.7%22.1%
2025-10-2016.7%9.0%1.8%16.1%16.7%
2025-10-2714.6%9.0%2.0%15.9%14.6%
2025-11-037.0%9.0%2.1%15.8%
2025-11-107.1%9.0%2.1%15.9%
2025-11-179.2%9.0%2.2%15.7%
2025-11-2410.4%9.0%2.1%15.9%
2025-12-0112.7%9.0%2.3%15.6%
2025-12-087.2%9.0%2.0%15.9%
2025-12-159.5%9.0%1.9%16.0%
2025-12-2213.8%9.0%0.0%20.2%
2025-12-297.0%9.0%0.0%19.1%
2026-01-058.7%9.0%1.7%16.2%
2026-01-127.3%9.0%1.6%16.3%
2026-01-1910.1%9.0%2.2%15.8%
2026-01-267.3%9.0%1.3%16.7%
2026-02-027.1%9.0%2.1%15.8%
2026-02-0911.5%9.0%2.3%15.6%
2026-02-165.8%9.0%2.1%15.8%
2026-02-2310.4%9.0%1.8%16.1%
2026-03-029.6%9.0%2.5%15.4%
2026-03-0911.2%9.0%2.0%15.9%
2026-03-1611.6%9.0%1.9%16.1%
2026-03-2313.0%9.0%2.2%15.7%
2026-03-306.1%9.0%0.4%17.6%
2026-04-069.9%9.0%2.2%15.7%
2026-04-136.8%9.0%2.2%15.7%
2026-04-2010.7%9.0%2.0%16.0%
2026-04-276.8%9.0%1.9%16.0%
2026-05-046.1%9.0%1.9%16.0%
2026-05-118.1%9.0%2.4%15.5%
2026-05-187.5%9.0%1.9%16.1%
2026-05-2510.4%9.0%2.1%15.9%
2026-06-013.4%9.0%1.9%16.1%
2026-06-089.9%9.0%2.0%15.9%
2026-06-1512.5%9.0%2.4%15.6%
2026-06-2216.0%9.0%2.2%15.7%16.0%
2026-06-299.9%9.0%2.0%15.9%
XmR chart: one pair of limits
Week commencingReadmission rateCentre line (limits shaded)Centre line (limits shaded) (low)Centre line (limits shaded) (high)Special cause
2025-01-065.9%8.9%-3.6%21.4%
2025-01-1314.5%8.9%-3.6%21.4%
2025-01-207.7%8.9%-3.6%21.4%
2025-01-2713.2%8.9%-3.6%21.4%
2025-02-0311.8%8.9%-3.6%21.4%
2025-02-1011.3%8.9%-3.6%21.4%
2025-02-178.5%8.9%-3.6%21.4%
2025-02-246.0%8.9%-3.6%21.4%
2025-03-0312.9%8.9%-3.6%21.4%
2025-03-100.0%8.9%-3.6%21.4%
2025-03-177.4%8.9%-3.6%21.4%
2025-03-241.6%8.9%-3.6%21.4%
2025-03-3112.7%8.9%-3.6%21.4%
2025-04-0713.8%8.9%-3.6%21.4%
2025-04-146.8%8.9%-3.6%21.4%
2025-04-216.6%8.9%-3.6%21.4%
2025-04-2811.1%8.9%-3.6%21.4%
2025-05-058.3%8.9%-3.6%21.4%
2025-05-123.7%8.9%-3.6%21.4%
2025-05-195.8%8.9%-3.6%21.4%
2025-05-2611.8%8.9%-3.6%21.4%
2025-06-029.3%8.9%-3.6%21.4%
2025-06-098.3%8.9%-3.6%21.4%
2025-06-1613.6%8.9%-3.6%21.4%
2025-06-2312.5%8.9%-3.6%21.4%
2025-06-305.6%8.9%-3.6%21.4%
2025-07-079.1%8.9%-3.6%21.4%
2025-07-1421.9%8.9%-3.6%21.4%21.9%
2025-07-216.2%8.9%-3.6%21.4%
2025-07-289.5%8.9%-3.6%21.4%
2025-08-0413.0%8.9%-3.6%21.4%
2025-08-117.1%8.9%-3.6%21.4%
2025-08-1812.5%8.9%-3.6%21.4%
2025-08-2512.5%8.9%-3.6%21.4%
2025-09-0110.1%8.9%-3.6%21.4%
2025-09-0813.5%8.9%-3.6%21.4%
2025-09-158.1%8.9%-3.6%21.4%
2025-09-2210.9%8.9%-3.6%21.4%
2025-09-2911.0%8.9%-3.6%21.4%
2025-10-0611.6%8.9%-3.6%21.4%
2025-10-1322.1%8.9%-3.6%21.4%22.1%
2025-10-2016.7%8.9%-3.6%21.4%
2025-10-2714.6%8.9%-3.6%21.4%
2025-11-037.0%8.9%-3.6%21.4%
2025-11-107.1%8.9%-3.6%21.4%
2025-11-179.2%8.9%-3.6%21.4%
2025-11-2410.4%8.9%-3.6%21.4%
2025-12-0112.7%8.9%-3.6%21.4%
2025-12-087.2%8.9%-3.6%21.4%
2025-12-159.5%8.9%-3.6%21.4%
2025-12-2213.8%8.9%-3.6%21.4%
2025-12-297.0%8.9%-3.6%21.4%
2026-01-058.7%8.9%-3.6%21.4%
2026-01-127.3%8.9%-3.6%21.4%
2026-01-1910.1%8.9%-3.6%21.4%
2026-01-267.3%8.9%-3.6%21.4%
2026-02-027.1%8.9%-3.6%21.4%
2026-02-0911.5%8.9%-3.6%21.4%
2026-02-165.8%8.9%-3.6%21.4%
2026-02-2310.4%8.9%-3.6%21.4%
2026-03-029.6%8.9%-3.6%21.4%
2026-03-0911.2%8.9%-3.6%21.4%
2026-03-1611.6%8.9%-3.6%21.4%
2026-03-2313.0%8.9%-3.6%21.4%
2026-03-306.1%8.9%-3.6%21.4%
2026-04-069.9%8.9%-3.6%21.4%
2026-04-136.8%8.9%-3.6%21.4%
2026-04-2010.7%8.9%-3.6%21.4%
2026-04-276.8%8.9%-3.6%21.4%
2026-05-046.1%8.9%-3.6%21.4%
2026-05-118.1%8.9%-3.6%21.4%
2026-05-187.5%8.9%-3.6%21.4%
2026-05-2510.4%8.9%-3.6%21.4%
2026-06-013.4%8.9%-3.6%21.4%
2026-06-089.9%8.9%-3.6%21.4%
2026-06-1512.5%8.9%-3.6%21.4%
2026-06-2216.0%8.9%-3.6%21.4%
2026-06-299.9%8.9%-3.6%21.4%

Synthetic data. Source: projects/spc-toolkit/examples/run.py.

The p-chart flags all three planted weeks: two above the limits (22.1% and 16.7%) and all three under the two-of-three rule. The XmR chart, with limits from the smaller baseline weeks, flags only the first of them. Its lower limit is −3.6%, a rate that cannot happen, which is the tell that it is the wrong chart for this data.

The p-chart also flags weeks I didn’t plant: w/c 14 July 2025 (14 of 64, 21.9%, which the XmR chart flags too) and w/c 22 June 2026 (26 of 163, 16.0%) above the limits, and two low weeks in March 2025 under two-of-three. Some of that is chance, and some is the maths. With small denominators the binomial’s upper tail is longer than the normal approximation assumes: the exact chance of a point above the upper limit when nothing has changed is 0.33% at 70 discharges and 0.66% at 53, not the 0.13% the normal curve promises. The baseline rate also came in below the planted 9.5%, which makes high weeks look more unusual.

Example 3: a funnel plot with overdispersion

The last data set is a year of outpatient DNA (did not attend) rates for 45 clinics with 155 to 7,408 appointments each, 7.22% overall. I planted clinic-to-clinic variation, as case mix creates in real data, plus three clinics: 12 genuinely high (12%), 31 genuinely low (4.5%) and 40 genuinely high (13%) but small, at 180 appointments.

p0 = pooled_proportion(clinics["dna"], n)
od = overdispersion(clinics["dna"], n, p0=p0)
action = funnel_limits(p0, n, z=Z998, tau2=od.tau2)

The winsorised φ is 3.73, and k × φ = 167.8 against a chi-squared 95th percentile of 60.5 on 44 degrees of freedom, so the clinics vary far more than chance allows. τ is 0.94 percentage points. The chart switches between the three sets of limits.

Outpatient DNA rate by clinic, against its number of appointmentsOne year, 45 clinics; switch the overdispersion adjustment
Data table
No adjustment
Appointments in the yearClinicsUpper 99.8%Upper 95%Lower 95%Lower 99.8%Outside 99.8%
15513.6%13.6%11.3%3.1%0.8%
16513.5%11.2%3.3%1.0%
1685.9%
1777.3%13.2%11.0%3.4%1.2%
18011.7%
1863.8%
18913.0%10.9%3.5%1.4%
2005.0%
20112.9%10.8%3.6%1.6%
21512.7%10.7%3.8%1.8%
2306.1%12.5%10.6%3.9%1.9%
2427.4%
24512.3%10.5%4.0%2.1%
24710.1%
26212.2%10.3%4.1%2.3%
28012.0%10.2%4.2%2.4%
29911.8%10.2%4.3%2.6%
3099.4%
3188.2%
31911.7%10.1%4.4%2.7%
3364.5%
34011.6%10.0%4.5%2.9%
36311.4%9.9%4.6%3.0%
38811.3%9.8%4.6%3.2%
41411.2%9.7%4.7%3.3%
44211.0%9.6%4.8%3.4%
4618.0%
47210.9%9.6%4.9%3.5%
50410.8%9.5%5.0%3.7%
5367.5%
53810.7%9.4%5.0%3.8%
57510.5%9.3%5.1%3.9%
5867.5%
60810.0%
61410.4%9.3%5.2%4.0%
65510.3%9.2%5.2%4.1%
6617.0%
70010.2%9.1%5.3%4.2%
74710.2%9.1%5.4%4.3%
7548.8%
7818.3%
79810.1%9.0%5.4%4.4%
85210.0%9.0%5.5%4.5%
9109.9%8.9%5.5%4.6%
9719.8%8.8%5.6%4.7%
9967.6%
10379.7%8.8%5.6%4.7%
109610.4%10.4%
11079.6%8.7%5.7%4.8%
11177.5%
11829.6%8.7%5.7%4.9%
11989.6%9.6%
12629.5%8.6%5.8%5.0%
13118.1%
13489.4%8.6%5.8%5.0%
14399.3%8.6%5.9%5.1%
15379.3%8.5%5.9%5.2%
16419.2%8.5%6.0%5.2%
17166.4%
17529.1%8.4%6.0%5.3%
18719.1%8.4%6.0%5.4%
19979.0%8.3%6.1%5.4%
20086.3%
21096.3%
21328.9%8.3%6.1%5.5%
21676.2%
22778.9%8.3%6.2%5.5%
23168.1%
24318.8%8.2%6.2%5.6%
250011.4%11.4%
25787.7%
25968.8%8.2%6.2%5.7%
27728.7%8.2%6.3%5.7%
29598.7%8.2%6.3%5.8%
30064.7%4.7%
30198.1%
31608.6%8.1%6.3%5.8%
33748.6%8.1%6.3%5.8%
36028.6%8.1%6.4%5.9%
36595.6%5.6%
370610.2%10.2%
38468.5%8.0%6.4%5.9%
40004.6%4.6%
40757.9%
41078.5%8.0%6.4%6.0%
43858.4%8.0%6.5%6.0%
46828.4%8.0%6.5%6.0%
48836.8%
49446.2%
49998.3%7.9%6.5%6.1%
51427.0%
53388.3%7.9%6.5%6.1%
54778.0%
57008.3%7.9%6.6%6.2%
60448.7%8.7%
60868.2%7.9%6.6%6.2%
64988.2%7.8%6.6%6.2%
69388.2%7.8%6.6%6.3%
70336.0%6.0%
71366.8%
74086.4%8.2%7.8%6.6%6.3%
Multiplicative (φ)
Appointments in the yearClinicsUpper 99.8%Upper 95%Lower 95%Lower 99.8%Outside 99.8%
15513.6%19.6%15.1%0.0%0.0%
16519.2%14.8%0.0%0.0%
1685.9%
1777.3%18.8%14.6%0.0%0.0%
18011.7%
1863.8%
18918.4%14.3%0.1%0.0%
2005.0%
20118.1%14.1%0.3%0.0%
21517.8%13.9%0.5%0.0%
2306.1%17.4%13.7%0.8%0.0%
2427.4%
24517.1%13.5%1.0%0.0%
24710.1%
26216.8%13.3%1.2%0.0%
28016.4%13.1%1.4%0.0%
29916.2%12.9%1.6%0.0%
3099.4%
3188.2%
31915.9%12.7%1.7%0.0%
3364.5%
34015.6%12.5%1.9%0.0%
36315.3%12.4%2.1%0.0%
38815.1%12.2%2.2%0.0%
41414.8%12.0%2.4%0.0%
44214.6%11.9%2.6%0.0%
4618.0%
47214.3%11.7%2.7%0.1%
50414.1%11.6%2.9%0.3%
5367.5%
53813.9%11.4%3.0%0.6%
57513.7%11.3%3.1%0.8%
5867.5%
60810.0%
61413.5%11.2%3.3%1.0%
65513.2%11.1%3.4%1.2%
6617.0%
70013.1%10.9%3.5%1.4%
74712.9%10.8%3.6%1.6%
7548.8%
7818.3%
79812.7%10.7%3.8%1.8%
85212.5%10.6%3.9%1.9%
91012.3%10.5%4.0%2.1%
97112.2%10.4%4.1%2.3%
9967.6%
103712.0%10.3%4.2%2.4%
109610.4%
110711.9%10.2%4.3%2.6%
11177.5%
118211.7%10.1%4.4%2.7%
11989.6%
126211.6%10.0%4.5%2.9%
13118.1%
134811.4%9.9%4.5%3.0%
143911.3%9.8%4.6%3.1%
153711.2%9.7%4.7%3.3%
164111.0%9.6%4.8%3.4%
17166.4%
175210.9%9.6%4.9%3.5%
187110.8%9.5%5.0%3.6%
199710.7%9.4%5.0%3.8%
20086.3%
21096.3%
213210.6%9.3%5.1%3.9%
21676.2%
227710.5%9.3%5.2%4.0%
23168.1%
243110.3%9.2%5.2%4.1%
250011.4%11.4%
25787.7%
259610.2%9.1%5.3%4.2%
277210.2%9.1%5.4%4.3%
295910.1%9.0%5.4%4.4%
30064.7%
30198.1%
316010.0%9.0%5.5%4.5%
33749.9%8.9%5.5%4.6%
36029.8%8.8%5.6%4.7%
36595.6%
370610.2%10.2%
38469.7%8.8%5.6%4.7%
40004.6%4.6%
40757.9%
41079.6%8.8%5.7%4.8%
43859.6%8.7%5.7%4.9%
46829.5%8.6%5.8%5.0%
48836.8%
49446.2%
49999.4%8.6%5.8%5.0%
51427.0%
53389.3%8.6%5.9%5.1%
54778.0%
57009.3%8.5%5.9%5.2%
60448.7%
60869.2%8.5%6.0%5.2%
64989.1%8.4%6.0%5.3%
69389.1%8.4%6.0%5.4%
70336.0%
71366.8%
74086.4%9.0%8.4%6.1%5.4%
Additive (τ²)
Appointments in the yearClinicsUpper 99.8%Upper 95%Lower 95%Lower 99.8%Outside 99.8%
15513.6%14.3%11.7%2.8%0.2%
16514.1%11.6%2.9%0.4%
1685.9%
1777.3%13.9%11.5%3.0%0.5%
18011.7%
1863.8%
18913.7%11.3%3.1%0.7%
2005.0%
20113.6%11.2%3.2%0.9%
21513.4%11.1%3.3%1.0%
2306.1%13.2%11.0%3.4%1.2%
2427.4%
24513.1%10.9%3.5%1.3%
24710.1%
26213.0%10.9%3.6%1.5%
28012.8%10.8%3.7%1.6%
29912.7%10.7%3.8%1.8%
3099.4%
3188.2%
31912.6%10.6%3.8%1.9%
3364.5%
34012.4%10.5%3.9%2.0%
36312.3%10.5%4.0%2.1%
38812.2%10.4%4.0%2.2%
41412.1%10.3%4.1%2.3%
44212.0%10.3%4.2%2.4%
4618.0%
47211.9%10.2%4.2%2.5%
50411.8%10.1%4.3%2.6%
5367.5%
53811.7%10.1%4.4%2.7%
57511.7%10.0%4.4%2.8%
5867.5%
60810.0%
61411.6%10.0%4.5%2.9%
65511.5%9.9%4.5%2.9%
6617.0%
70011.4%9.9%4.6%3.0%
74711.3%9.8%4.6%3.1%
7548.8%
7818.3%
79811.3%9.8%4.6%3.2%
85211.2%9.8%4.7%3.2%
91011.2%9.7%4.7%3.3%
97111.1%9.7%4.8%3.3%
9967.6%
103711.1%9.7%4.8%3.4%
109610.4%
110711.0%9.6%4.8%3.4%
11177.5%
118210.9%9.6%4.9%3.5%
11989.6%
126210.9%9.6%4.9%3.5%
13118.1%
134810.8%9.5%4.9%3.6%
143910.8%9.5%4.9%3.6%
153710.8%9.5%5.0%3.7%
164110.7%9.4%5.0%3.7%
17166.4%
175210.7%9.4%5.0%3.7%
187110.7%9.4%5.0%3.8%
199710.6%9.4%5.1%3.8%
20086.3%
21096.3%
213210.6%9.4%5.1%3.8%
21676.2%
227710.6%9.3%5.1%3.9%
23168.1%
243110.5%9.3%5.1%3.9%
250011.4%11.4%
25787.7%
259610.5%9.3%5.1%3.9%
277210.5%9.3%5.1%3.9%
295910.5%9.3%5.1%4.0%
30064.7%
30198.1%
316010.5%9.3%5.2%4.0%
337410.4%9.3%5.2%4.0%
360210.4%9.2%5.2%4.0%
36595.6%
370610.2%
384610.4%9.2%5.2%4.0%
40004.6%
40757.9%
410710.4%9.2%5.2%4.0%
438510.4%9.2%5.2%4.1%
468210.4%9.2%5.2%4.1%
48836.8%
49446.2%
499910.3%9.2%5.2%4.1%
51427.0%
533810.3%9.2%5.2%4.1%
54778.0%
570010.3%9.2%5.2%4.1%
60448.7%
608610.3%9.2%5.3%4.1%
649810.3%9.2%5.3%4.1%
693810.3%9.2%5.3%4.2%
70336.0%
71366.8%
74086.4%10.3%9.2%5.3%4.2%

Synthetic data. Source: projects/spc-toolkit/examples/run.py.

  • No adjustment: 15 clinics outside the 95% limits and 9 outside 99.8%, including both large planted clinics. The other seven differ from the average only by the ordinary clinic-to-clinic spread, which binomial limits, narrow at thousands of appointments, treat as a signal.
  • Multiplicative (φ): 3 outside 99.8%: clinics 12 and 31, and clinic 11, whose planted rate is 9.1% but which recorded 10.1% on 3,706 appointments.
  • Additive (τ²): 1 outside 99.8%: clinic 12. Clinic 31 recorded 183 DNAs in 4,000 appointments (4.575%), inside an additive lower limit of 4.05%.

In the funnel article’s data, the additive limits flagged exactly the two planted outliers. Here they flag one of two. Additive limits ask whether a clinic stands out from the spread of clinics, not just from the average, and neither planted clinic is extreme on that scale: clinics 12 and 31 sit 2.60 and 2.71 planted standard deviations from typical. Clinic 12 still crosses the line; clinic 31 doesn’t. Clinic 40 is inside every set of limits, because 11.7% on 180 appointments is not enough evidence. Pick the adjustment before you see which clinics it flags.

Design decisions

  • Pure functions. No file access, no global state, no plotting. The same inputs always give the same outputs, so the functions are easy to test and safe to call from a notebook, a pipeline or a web service.
  • Typed returns. Limits come back as frozen dataclasses with named fields (limits.upper, not limits["upper"]), so a typo fails in the type checker, not in a board paper. A py.typed marker lets users’ type checkers read the annotations.
  • One protocol for the rules. special_causes accepts anything with centre, upper and lower, so the same rule code handles XmR limits (single numbers) and p-chart limits (arrays).
  • One binomial limit function. A p-chart and a funnel plot draw the same limits, p ± z × √(p(1 − p)/n), with different z. Both call one function, so a fix to one is a fix to both.
  • No plotting in the core. The chart specs live in examples/page_charts.py. The maths outlives any chart library, and the same numbers can go to Power BI as a CSV.
  • Loud failures. Missing values, zero denominators, numerators above their denominator, limits in the wrong order and φ below 1 raise a ValueError with a plain message. A silent NaN in a control limit is worse than an error.
  • Tested lower bounds. The dependencies are numpy>=2.0 and pandas>=2.2.2, and the tests pass with the newest versions and with the oldest the bounds allow (uv run --isolated --python 3.12 --resolution lowest-direct pytest).

Tests, and what they caught

The 471 test cases come from 51 test functions and run in seconds. They fall into five groups:

  1. Known answers worked by hand. Values 10, 12, 11, 13 and 14 have a mean of 12 and a mean moving range of 1.5, so the limits are 12 ± 2.66 × 1.5 = 8.01 to 15.99.
  2. Edge cases. Empty input, one point, NaN, pd.NA, a flat line, zero denominators, a point exactly on a limit or on the centre line.
  3. Invariants. Adding a constant moves the limits without widening them; additive limits are never narrower than binomial ones.
  4. An oracle. The vectorised rules are compared with the plain loops from the SPC article on 300 random series with uneven limits, and first_signals with rerunning the rules point by point on 100 of them.
  5. SQL parity. sql/xmr_limits.sql computes the limits with LAG for teams that work in the warehouse, and a test checks it against xmr_limits in an in-memory SQLite database.

What they caught:

  • A wrong test. “Limits narrow as the denominator grows” failed on its first run. The code was right: the lower limit is clipped at zero for the smallest denominators, so it stays flat there. The test encoded a belief I hadn’t checked.
  • A conservative check. I expected the chi-squared check to flag about 10 of 200 simulated data sets with no overdispersion. It flagged none. Winsorising pulls the extreme z-scores in, so without overdispersion φ averages about 0.68, not 1. The package keeps the articles’ method, and a test now pins the behaviour.
  • A wrong lower bound. The first pyproject.toml said pandas>=2.2. The lowest-version run installed pandas 2.2.2, because 2.2.0 and 2.2.1 require NumPy below 2. The bound now says what was tested.

The oracle tests passed first time. They are there for the next change, not this one.

Install it from the zip

The download below unzips to a folder called spc-toolkit-python-package. Any of these works:

uv add --no-workspace ./spc-toolkit-python-package      # into a uv project, as a path dependency
uv pip install ./spc-toolkit-python-package             # into the active virtual environment
python -m pip install ./spc-toolkit-python-package      # the same with pip
uv build ./spc-toolkit-python-package                   # a wheel to share, in its dist/ folder

From inside the folder, uv run pytest runs the tests and uv run python examples/run.py rewrites the charts and CSVs into output/. I built the wheel, installed it into a fresh Python 3.12 environment and imported it before publishing this page. The package isn’t on PyPI, and no licence has been chosen yet.

Limits

  • Normal-approximation limits. They are too tight above small denominators, as example 2 shows.
  • Crude rates only. For outcomes like readmission, a funnel should plot observed over expected from a case-mix model, which the package doesn’t build.
  • A conservative overdispersion check. Mild overdispersion can pass it.
  • No sense of direction. The rules say a signal happened, not whether it is good news; that depends on the measure.
  • Three chart types. XmR (with its moving range chart), p-chart and funnel plot, nothing else yet.

What I’d do next

  • Publish it. Choose a licence, then push the wheel to a private index so teams can uv add spckit with a version pin.
  • A documentation site. An API reference generated from the docstrings, with the three examples as tutorials.
  • More chart types. Run charts for short series, c- and u-charts for counts, and Laney p′ charts, which adjust p-chart limits for overdispersion.
  • Exact limits and direction. Exact binomial limits for small denominators, and an option to label each signal as an improvement or a concern.
  • CI. Run the tests and the lowest-version check on every push, as in testing analytics code with pytest.

Run it yourself

From the root of the site repository:

uv run pytest projects/spc-toolkit/tests
uv run python projects/spc-toolkit/examples/run.py

The script regenerates the three data sets, prints every number quoted here, and rewrites the charts and CSVs on this page. A rerun gives byte-identical files.

Built with

  • Python
  • NumPy
  • pandas
  • pytest
  • uv
  • hatchling

Downloads