Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Coffee Shop Wait Times — A Complete Process Behavior Story

A busy café measures how long a customer waits for their drink (wait_sec). Baristas time four orders a day — two at the Peak rush (08:00, 13:00) and two at Off-peak lulls (10:00, 16:00) — Monday through Saturday for 16 weeks.

Three real things happen along the way:

  1. Week 8 — a new espresso machine is installed.

  2. Week 12 — a new point-of-sale (POS) system goes live.

  3. Weeks 14–16 — several new baristas are hired.

We’ll let the process behavior charts tell us what actually changed, when, and how — and see why you need both a location chart and a dispersion chart to catch it all.

Load and formulate

processbehavior models data as a factor × time grid. Here the process-design factor is daypart (Peak vs Off-peak) and the time axis is date. Four readings a day (two per daypart) give full replication, so this is a complete (SDS 1) design.

from processbehavior import Calibration, load_coffee_shop

pb = load_coffee_shop()
pb.data.head()
Loading...
study = pb.formulate(response='wait_sec', factors=['daypart'], time='date')
print(study.design())
Design Report (1 factors)
  Design-state lineage:
    PDS (Planned):    no plan supplied
    SDS (Sampling):   1 (Full Replication)
    ADS (Analytical): 1 (Full Replication)
  Min cell size: 2 | K: 2 | T: 96 | R: 192 | N: (min=2, median=2.0, max=2)

  Factors:
    daypart: observed=['Off-peak', 'Peak']

  Structure: Complete structure

  Available analyses (ADS 1):
    Primary: Histogram, Xbar *, S, X, mR
    R2: S, X
    R3: Xbar, S
    R4: Xbar, S
    R5: Xbar, S
    R6: Xbar, S
    Methods: Capability, Loss Function, Maximum Information

Location: what happened to the average wait?

The Xbar chart tracks the mean wait per subgroup over time.

result = study.execute(chart='Xbar', companion=True)  # Xbar plus its S companion
fig = result.plot(chart='Xbar', show_zones=True, highlight_signals=True, show_stats=True)
fig.show()
Loading...

Two things jump out:

  • At week 8 the average wait drops to a new, lower level and stays there — the new espresso machine made the café faster. Not every signal is bad news; this is a process improvement, and the chart proves it wasn’t just luck.

  • Around week 12 there’s a run of high points for about a week, then it settles — the POS rollout: staff learning the new system slowed things down temporarily.

sig = result.detect_signals(chart='Xbar')
print(f'Xbar signals: {sig.count}')
sig.violations.head(10) if sig.has_signals else 'no signals'
Xbar signals: 36
Loading...

Dispersion: the change the average hides

Now the S chart — the within-subgroup spread over time. This is the one most people skip, and it’s where the most important lesson lives.

fig = result.plot(chart='S', show_zones=True, highlight_signals=True)
fig.show()
sig_s = result.detect_signals(chart='S')
print(f'S-chart signals: {sig_s.count}')
Loading...
S-chart signals: 3

From week 14 the spread balloons — while the average (the Xbar chart) barely moves. That’s the new-hire effect: consistency, not speed, degraded. A location chart alone would have declared the process fine. Averages hide variation; you need the dispersion chart too.

# Same story in the numbers: mean holds, within-subgroup SD roughly doubles.
cell = pb.data.groupby(['daypart', 'date', 'week'])['wait_sec'].agg(['mean', 'std'])
settled = cell.query('8 <= week <= 13 and week != 12')
newhire = cell.query('week >= 14')
print(f'settled  (wk8-13): mean={settled["mean"].mean():5.1f}s   within-cell SD={settled["std"].mean():4.1f}s')
print(f'new hires(wk14-16): mean={newhire["mean"].mean():5.1f}s   within-cell SD={newhire["std"].mean():4.1f}s')
settled  (wk8-13): mean=200.5s   within-cell SD=13.4s
new hires(wk14-16): mean=200.0s   within-cell SD=24.5s

The daypart effect

Peak hours are genuinely slower than Off-peak — a real main effect the study separates from the noise.

print('Mean wait by daypart:')
print(pb.data.groupby('daypart')['wait_sec'].mean().round(1))
Mean wait by daypart:
daypart
Off-peak    203.6
Peak        238.0
Name: wait_sec, dtype: float64

Calibration: hold the process to a known standard

Once the café settled into its post-machine rhythm (weeks 8–13, before the new hires), we can freeze that as the expected standard and monitor everything against it — instead of limits re-derived from the data. That’s what a Calibration does.

settled_vals = pb.data.query('8 <= week <= 13 and week != 12')['wait_sec']
post = Calibration(
    label='post-machine normal',
    mean=float(round(settled_vals.mean(), 1)),
    sigma=float(round(settled_vals.std(ddof=1), 1)),
)
print(post)
Calibration(label='post-machine normal', mean=200.5, sigma=20.4)
# Monitor the individual readings against the frozen standard.
fig = study.execute(chart='X', by=[], calibration=post).plot(chart='X', highlight_signals=True)
fig.show()
Loading...

Held to the standard the café earned in its settled weeks, the new-hire weeks stand out immediately — exactly the early warning a manager wants, before customers start complaining. Calibration turns “what do the data say?” into “are we still meeting the standard we set?”

Summary

EventWeekSignalWhich chart caught it
New espresso machine8sustained downward shift (improvement)Xbar
New POS system12~1-week run, then settlesXbar
New baristas14–16spread doubles, mean flatS

One dataset, the whole toolkit: SDS 1 detection, location and dispersion charts, run rules, a real factor effect, and calibration to a standard. The espresso machine shows a good change; the new hires show why the S chart is not optional.