A process behavior chart asks “is this process stable?” Process capability asks a different question: “can this process consistently meet its specification?” And when you change something — a new machine, a new supplier, a new procedure — you usually want a third question answered: “did the change actually help?”
This tutorial uses a coffee shop’s drive-through wait times to answer that last question. A new espresso machine was installed partway through the data, and we’ll compare capability before and after the change — a time-windowed view of one formulated study.
What you’ll learn¶
The two-step workflow:
formulate()your study once, then ask questions of it.Current vs. potential capability (Pp/Ppk vs. Cp/Cpk).
How to window a capability view before/after a point on the study’s time axis.
Why the windowed potential figure keeps the full-study noise floor (a deliberate approximation), and how the chart says so.
The guardrails that stop a too-small window from producing a misleading capability chart.
Setup¶
The dataset ships with the library, so there’s nothing to download. load_coffee_shop() returns a ready-to-formulate ProcessBehavior (it reads the bundled CSV with the library’s own reader — no pandas needed).
from processbehavior import load_coffee_shop
pb = load_coffee_shop()
print(f"{pb.data.shape[0]} rows x {pb.data.shape[1]} columns")
print("columns:", pb.data.columns.tolist())
pb.data.head()384 rows x 7 columns
columns: ['date', 'year_week', 'week', 'day_of_week', 'hour', 'daypart', 'wait_sec']
The response is wait_sec (seconds). Each day is a subgroup of 4 readings, tagged with its
day_of_week and a sequential week (1–16). There are two natural time axes — the integer
week and the date — and capability windowing works with either.
Formulate the study¶
Formulation is where you declare the structure once: the response, the rational-subgroup factor(s), and the time axis. Everything downstream is a view of this frozen model.
study = pb.formulate(
response='wait_sec',
factors=['day_of_week'],
time='week',
precision=3,
)
print(study)Study(response='wait_sec', factors=[day_of_week], time='week', ads=1)
Valid: Histogram, Xbar, S, X, mR | Recommended: Xbar
Residuals: R1, R2, R3, R4, R5, R6
→ study.execute() or study.support for details
Full replication (4 readings per day) means the VAS residuals are available, so we get both current and potential capability. We’ll judge against a target of 180s and an upper spec of 240s (customers start walking away past 240s).
Baseline: capability over the whole series¶
First, the capability of all the data — no windowing.
full = study.capability(usl=240, target=180)
fullCapabilityResult:
Specs: LSL=None, USL=240, Target=180
N=384, Ybar=220.771, S=33.132, sigma_hat=33.154
Current Capability (overall sigma):
Pp=None Ppk=0.193 (lower=None, upper=0.193)
Potential Capability (R2 sigma):
Cp=None Cpk=0.269 (lower=None, upper=0.269)
Z-scores:
Z_lower=None, Z_upper=0.58
Empirical (current):
Below LSL: None (None%) Above USL: 106 (27.604%) Total outside: 106 (27.604%)
Empirical (potential):
Below LSL: None (None%) Above USL: 93 (24.219%)
Note: Stability not assessed; run study.execute() and review signals before interpreting indices.full.plot()Across the whole series the process looks marginal: the mean (~221s) sits well above the
180s target and a sizable fraction of readings exceed the 240s upper spec, so Ppk is low.
But that whole-series view blends two different eras together. The real question is whether the new machine changed things — which is exactly what a time window answers.
Did the new machine help? Window before vs. after¶
The machine went in at the start of week 8. We pass window=(start, end) in the time
axis’s own units — here, integer weeks. The interval is half-open start <= week < end,
so (None, 8) and (8, None) are exact complements (every reading lands in exactly one side):
before = study.capability(usl=240, target=180, window=(None, 8)) # weeks 1-7 (baseline)
after = study.capability(usl=240, target=180, window=(8, None)) # weeks 8-16 (new machine)
print("BEFORE:", f"mean={before.y_bar:.1f}s", f"{before.pct_above_usl:.1f}% over USL", f"Ppk={before.ppk:.3f}")
print("AFTER :", f"mean={after.y_bar:.1f}s", f"{after.pct_above_usl:.1f}% over USL", f"Ppk={after.ppk:.3f}")BEFORE: mean=241.3s 48.8% over USL Ppk=-0.017
AFTER : mean=204.8s 11.1% over USL Ppk=0.384
before.plot() # plot() defaults to the data this view summarized — no need to pass valuesafter.plot()The contrast is stark. Before, the process is incapable: the mean sits right at the 240s spec and roughly half the readings are over it. After, the mean drops to ~205s and only a small fraction exceed spec — a genuinely improved, far more capable process. The new machine worked.
Note we never recomputed anything: both results are views of the one frozen study.
A subtlety worth understanding: the pooled potential floor¶
Capability has two flavors: current (the spread you actually observe) and potential
(what’s achievable at the irreducible within-subgroup noise floor, σ̂_R2). On a windowed
view, the current spread and the centering come from the window — but the potential noise
floor is the full-study pooled σ̂_R2, not re-estimated on the subset:
print("sigma_hat_r2 — before:", round(before.sigma_hat_r2, 4))
print("sigma_hat_r2 — after :", round(after.sigma_hat_r2, 4))
print("sigma_hat_r2 — full :", round(full.sigma_hat_r2, 4))
print("all equal:", before.sigma_hat_r2 == after.sigma_hat_r2 == full.sigma_hat_r2)sigma_hat_r2 — before: 23.7993
sigma_hat_r2 — after : 23.7993
sigma_hat_r2 — full : 23.7993
all equal: True
They’re identical — the potential floor is pooled across the whole study, while the centering of each potential view is the window’s own mean. This is deliberate: the point of before/after is that the process location shifted while the measurement noise floor held roughly constant, so we judge “given where this era now sits, and the irreducible noise we measured across the whole study, how capable could it be?” The capability chart annotates this on the potential panel so a reader can’t mistake it for the window’s own noise floor. (Pooled potential on a subset is a known approximation — call it out if you report it.)
The same story on the date axis¶
Time is whatever you declared at formulation. Re-formulate with time='date' and the window takes a date bound instead of an integer — identical before/after semantics:
study_date = pb.formulate(response='wait_sec', factors=['day_of_week'], time='date', precision=3)
before_d = study_date.capability(usl=240, target=180, window=(None, '2026-02-23')) # before week 8
after_d = study_date.capability(usl=240, target=180, window=('2026-02-23', None))
print(f"date-axis before mean={before_d.y_bar:.1f}s (n={before_d.n}); after mean={after_d.y_bar:.1f}s (n={after_d.n})")
print("matches the week-axis partition:", before_d.n == before.n and after_d.n == after.n)date-axis before mean=241.3s (n=168); after mean=204.8s (n=216)
matches the week-axis partition: True
Guardrails: don’t over-window¶
Capability indices are unstable on small samples, so windowing has a built-in n-ladder. A window with 8–29 observations still computes but carries a warning; fewer than 8 is refused outright rather than render a confident-looking chart on noise.
from processbehavior.exceptions import ValidationError
# Warn: a single week is only ~24 readings.
one_week = study.capability(usl=240, target=180, window=(1, 2))
print(f"single week: n={one_week.n}")
print("warning:", one_week.window_warning)
# Refuse: a single day (~4 readings) is too few.
try:
study_date.capability(usl=240, target=180, window=('2026-01-05', '2026-01-06'))
except ValidationError as e:
print("\nRefused:", e)single week: n=24
warning: n=24: capability indices (Ppk/Cpk) are highly unstable at this sample size; treat as indicative, not authoritative.
Refused: Capability on window ('2026-01-05', '2026-01-06') of 'date' has only 4 observation(s); need >= 8 for a meaningful figure. 'date' spans [2026-01-05 00:00:00, 2026-04-25 00:00:00].
Programmatic access¶
Every result is a frozen dataclass; as_dict() gives you the numbers. A windowed result additionally records the window so it stays self-describing:
after.as_dict(){'usl': 240,
'lsl': None,
'target': 180,
'n': 216,
'y_bar': 204.838,
's': 30.452,
'sigma_hat': np.float64(30.487),
'pp': None,
'ppk_lower': None,
'ppk_upper': np.float64(0.384),
'ppk': np.float64(0.384),
'sigma_hat_r2': np.float64(23.799),
'cp': None,
'cpk_lower': None,
'cpk_upper': np.float64(0.492),
'cpk': np.float64(0.492),
'potential_unavailable_reason': None,
'z_lower': None,
'z_upper': np.float64(1.153),
'n_below_lsl': None,
'n_above_usl': 24,
'n_outside': 24,
'pct_below_lsl': None,
'pct_above_usl': 11.111,
'pct_outside': 11.111,
'potential_n_below_lsl': None,
'potential_n_above_usl': 16,
'potential_pct_below_lsl': None,
'potential_pct_above_usl': 4.167,
'window': (8, None),
'time_var': 'week',
'n_total': 384,
'window_warning': None}Summary¶
| Concept | Key point |
|---|---|
formulate() once | Declare response / factors / time; everything else is a view of the frozen model. |
study.capability(window=...) | A before/after view on the declared time axis — int or date, half-open [start, end). |
| Current vs. potential | Current = observed spread; potential = full-study pooled noise floor, centered on the window. |
| Guardrails | n < 8 refuses; 8 ≤ n < 30 warns. Don’t over-window. |
| No recomputation | Windowing never re-derives residuals — it’s a view of the immutable analytic dataset. |
Next steps¶
Process Capability — the fundamentals of Pp/Ppk vs. Cp/Cpk.
Loss Function — quantifying the cost of being off-target.