Warehouse Sim: find the bottleneck a stopwatch would miss
The data in this project is synthetic — generated locally by
data/make_data.py, with no external source. That is deliberate: the lesson needs a workload with a known queueing structure and a deliberately misleading station layout, so the bottleneck and the achievable improvement can be measured exactly and reproducibly. No signup, no downloads, no GPU.
Before you start
New to discrete-event simulation? Start here.
A warehouse order passes through stations — pick, pack, ship — each with a limited number of workers. When every worker at a station is busy, orders queue. A discrete-event simulation (DES) models exactly this: it jumps from event to event (an order arrives, a worker frees up) and tracks the queues, so you can measure how long orders really take. SimPy is a small, free Python library for writing DES — you describe each order as a process that requests a worker at each station and holds it for the service time.
The number that finds the bottleneck is utilisation:
utilisation of a station = work arriving at it ÷ capacity it has ≈ (arrival rate × mean service time) ÷ number of workersA station near 100% utilisation is where orders pile up — the bottleneck. The catch, and the whole point of this project: the bottleneck is not necessarily the station with the longest service time. A slow station with plenty of workers can be comfortable, while a faster station with too few workers chokes. Only utilisation tells you which is which — and adding a worker anywhere except the bottleneck barely helps.
What you’ll learn: build a queueing line in SimPy, measure utilisation and cycle time, and find the bottleneck — then, on a line designed to fool the stopwatch, decide where one extra worker does the most good. Section 3 lists exactly what you’ll be able to do.
New to Python too? See Start Here — it starts from zero, and shows how to run this in your browser with nothing to install.
1. The Brief
Orders are taking too long to get out the door, and the obvious fix — “add people to the slowest station” — isn’t working. You’ll build a simulation of the fulfilment line, measure where orders actually wait, and show that the bottleneck is a matter of load versus capacity, not raw speed. Then, handed a line where the slowest station is a decoy, you’ll place one extra worker where it genuinely moves the cycle time.
Discrete-event simulation is how operations teams answer “what if we add a worker / a machine / a lane?” without disrupting the real system, and utilisation-based bottleneck analysis (the Theory of Constraints in miniature) is one of the highest-leverage ideas in operations. The same SimPy skills model call centres, clinics, checkout lines, and compute clusters.
2. Difficulty & time
Difficulty 6 / 10. A new library and a new mental model — event-driven simulation with SimPy
(processes, resources, yield) — plus the counterintuitive utilisation insight. Calibrated
against the ledger (G10): a peer of vendor-dedupe (6), attrition-leakage (6), and fraud-rings
(6) — each needs a genuine technique (a library or algorithm) and a judgment that a beginner
gets wrong on the first try. It sits below recommend-from-scratch (7), which builds a full
algorithm, and above the simulation-with-numpy 5s because SimPy’s event model is a real
conceptual step. The trap — reinforcing the slowest-looking station — is one you’ll walk straight
into without the utilisation lens.
Time is separate from difficulty: Part A is ~90 minutes (SimPy takes some getting used to); Part B is 2–3 hours. The simulations run in about a second.
3. What you’ll be able to do after
- Write a discrete-event simulation in SimPy — model stations as resources and orders as processes that queue for them.
- Measure utilisation and cycle time from a simulation run, per station.
- Locate a bottleneck by utilisation — and explain why the slowest-service station may not be it.
- Evaluate a capacity change — use the simulation to decide where one extra worker most reduces cycle time.
The finished result
By the end of Part A you’ll have found the bottleneck a stopwatch would miss, and proved it by adding one worker where it actually pays off:
mean cycle time: 8.61 time units per order (only ~3 is real work — the rest is queueing)utilisation: pick 0.51 pack 0.91 ship 0.30 -> bottleneck: packadd 1 worker: +1 pack -> 3.66 (+1 pick -> 8.24, +1 ship -> 8.59: barely move)Utilisation, not raw speed, points at pack, and one worker there cuts the cycle time by more than
half, while the same worker on pick or ship is wasted. Part B raises the stakes: the
slowest-looking station is a decoy, and only utilisation reveals the true bottleneck.
4. Prereqs & time box
You can write a function and use a pandas DataFrame. No SimPy or queueing-theory background needed
— Part A introduces the library and the concepts. Comfort with the idea that Python generators use
yield helps, but Part A shows the pattern.
Where to write and run code
Two ways to run this project — pick either:
- In your browser (no install). If the project page shows a ▶ Run button, click it to run Part A right here — nothing to set up. Part A uses pandas, numpy, and SimPy, which run in the browser.
- On your computer. The one-time setup — installing Python, downloading the code from the
course site (no GitHub, no
git), opening a terminal, creating a virtual environment, installing the requirements, and running Part A step by step — is covered once in Start Here. Come back here oncepython -c "import simpy"runs without error. Part B (writing your own solution) needs this local setup.
Time box
- Part A — Guided Build: 60–90 minutes. Hard cap.
- Part B — Your Turn: 2–4 hours. Hard cap.
5. Setup & data
The data is synthetic and generated locally — see the banner above and SOURCES.md for full
provenance. One command creates both parts’ workloads:
python data/make_data.pyThat writes two CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical
because the seed is fixed):
- Part A —
workload_a.csv(4,000 orders;order_id, arrival, svc_pick, svc_pack, svc_ship). - Part B —
workload_b.csv(4,000 orders; addssvc_receive). A 4-station line where the slowest station is a decoy.
Service times are pre-generated into the workload, so every simulation run is deterministic — the same servers always give the same numbers.
6. Part A — Guided Build
You’ll build the line in SimPy and find its bottleneck. Run the whole thing, or (better) one # %%
cell at a time — see Start Here:
python part_a/build_sim.pyPrefer the browser? If this page has a ▶ Run button, click it instead of the command above — Part A runs in your browser with nothing to install. The STEP output and every checkpoint below are identical either way.
The full, commented script is in part_a/build_sim.py. The spine:
Step 1 — Build and run the sim. Model pick → pack → ship; measure mean cycle time.
Checkpoint: ~8.6 time units per order — far more than the ~3 of actual work, so most of it is queueing.
Step 2 — Utilisation per station.
Checkpoint: pack ≈ 0.91 utilisation with a long queue wait; pick ≈ 0.51, ship ≈ 0.30. Pack is the bottleneck.
Step 3 — Add one server to each station; compare.
Checkpoint: +1 pack cuts cycle time ~8.6 → ~3.7; +1 pick or +1 ship barely move it. Capacity off the bottleneck just sits idle.
Step 4 — Re-measure after fixing it.
Checkpoint: pack drops ~0.91 → ~0.60 and pick (~0.51) is now nearly as loaded — the line is far more balanced. Writes
part_a/stations_a.csv.
Why this and not that
- Why utilisation, not service time. A station’s service time is how long one order takes there; utilisation is how much of the station’s capacity the incoming work consumes. A slow station with enough workers can idle; a fast one with too few chokes. Queues form where utilisation is high.
- Why a server off the bottleneck is wasted. Throughput is gated by the busiest station. Add capacity elsewhere and orders still back up at the bottleneck — the extra worker just waits. This is the Theory of Constraints in one line.
- Why simulate instead of a formula. Simple queues have closed-form results, but chain a few together with realistic variability and the interactions get hard to write down. A DES just runs it — and it’s the same tool when the line grows to ten stations with priorities and breakdowns.
Functions you’ll meet (and where to read more)
| Function | What it does here | Docs |
|---|---|---|
simpy.Environment() | The simulation clock and event loop | SimPy basics |
simpy.Resource(env, capacity=n) | A station with n parallel workers | Resources |
with res.request() as req: yield req | Queue for a worker, then hold it | Resource requests |
yield env.timeout(d) | Advance the clock by a service time | Events |
env.run() | Run until all orders are processed | Environment.run |
If something looks off
packsits at 0.91 utilisation — that’s the problem, not a compliment. High utilisation reads like “working hard, so it’s efficient,” but a station near 1.0 is saturated: it has almost no spare capacity, so orders pile into its queue — pack’s mean wait is 5.38, against 0.18 at pick and 0.01 at ship. The bottleneck is the station with the highest load-vs-capacity, not the one that’s individually slowest; a fast station with too few workers can still be the constraint.- A checkpoint number doesn’t match. The data is seeded, so the numbers are exact. Re-run
python data/make_data.py(if present), then re-run Part A. - Setup problems (Python not found,
pip,FileNotFoundError, re-activating the venv): see the troubleshooting list in Start Here.
7. Part B — Your Turn
Part B is the optional “your turn” half — a guided fill-in-the-blank, not a blank page. You get a working starter (
part_b/starter.py) that already handles the easy version; your job is to finish the# TODOit marks out. More open than Part A’s step-by-step, but you are never staring at an empty file. If you finished Part A and want to prove the skill, this is where it happens. New to Python? It’s completely fine to stop after Part A and come back later.
Same core skill, a line built to fool you. receive → pick → pack → ship is congested and you may
add one worker. Decide which station to add it to. The catch: receive has by far the longest
service time, so the stopwatch says it’s the slowest — but it has plenty of workers and isn’t the
bottleneck. A shared simulator, part_b/warehouse.py, lets you try what-ifs. The starter uses the
“slowest station” heuristic, adds to receive, and captures almost none of the possible
improvement.
The transfer: in Part A the bottleneck was the obvious congested station. Here you must compute utilisation to see past the decoy — the slow station is comfortable, and a different, faster station is the real constraint.
Acceptance criteria (checkable)
Your solution writes into part_b/out/report.json:
bottleneck— the station you identify as the bottleneck.add_server_to— the station you’d add the one worker to.base_cycle,fixed_cycle, andachieved_metric(you can leaveachieved_metricnull; the grader scores you).
Use the shared simulator to explore:
from warehouse import BASE_SERVERS, STAGES, run, add_serverbase = run(BASE_SERVERS) # dict with 'cycle' and per-station 'util'run(add_server("pack")) # try adding a worker to a stationThen:
python part_b/starter.pypytest part_b/test_solution.py -qIf
pytestreports everything asskipped, it just means the output files don’t exist yet — runpython part_b/starter.pyfirst (once your solution fills in the TODOs), then re-run.
You’re done when both tests pass: the station you reinforce captures ≥ 90% of the best
achievable cycle-time reduction (the reference captures 1.00), and your bottleneck matches the
true highest-utilisation station. The “slowest station” heuristic captures ~0 and names the wrong
one.
Constraints — what must be true of your solution, not how to get there
- Identify the bottleneck by utilisation (load ÷ capacity), not by service time.
- Add the single worker to the station that most reduces mean cycle time.
- Report the true bottleneck in
bottleneck. - Use the provided
warehouse.run(...)to measure — don’t guess from the service times alone.
Hints
Hint 1 — look at utilisation, not speed
run(BASE_SERVERS)["util"] gives utilisation per station. The bottleneck is the max. Notice it
is not receive, even though receive has the longest service time — receive has enough
workers that its utilisation stays low.
Hint 2 — let the simulator settle it
You don’t have to reason it out — try it. run(add_server(s))["cycle"] for each station s tells
you which added worker gives the lowest cycle time. It will be the highest-utilisation station.
Hint 3 — report both (still not the code)
Set bottleneck and add_server_to to the same station: the one with the highest utilisation,
which is also the one whose extra worker cuts cycle time the most.
8. Self-check
You don’t need the answer key. You’re done when all of these hold:
pytestreports both green — captured ≥ 0.90 and the rightbottleneck.- Your bottleneck is not the longest-service station; if you picked
receive, you’re reading service time, not utilisation. - Re-running from
python data/make_data.pyreproduces the same numbers — the workload is seeded.
And the tell-tale: adding the worker to your chosen station should cut the cycle time noticeably. If it barely moves, you reinforced a station that wasn’t the constraint.
9. Stretch
- A budget, not one worker. Given 3 extra workers, allocate them across stations to minimise cycle time — and watch the bottleneck shift as you relieve it, so the third worker may belong somewhere new.
- Variability matters. Make one station’s service time highly variable (same mean, larger spread) and show its queue grows even though utilisation is unchanged — variability, not just load, drives waiting.
- (Genuinely hard) Breakdowns. Give a station random failures and repairs (a SimPy
PreemptiveResourceor an interrupting process) and measure the throughput hit — the start of reliability modelling.
10. Ship it
Put this in a portfolio repo and it counts. In that repo’s README:
- State the finding in one sentence an ops manager gets: “the slowest station wasn’t the
bottleneck — utilisation pointed at
pack, and one worker there cut cycle time by more than half.” - Show the per-station utilisation table and the cycle time before/after the fix.
- Include the SimPy model and one paragraph on why utilisation, not service time, finds the bottleneck. That paragraph is the part that signals you understand queueing, not just SimPy syntax.
Keep make_data.py, warehouse.py, and the tests in the repo so anyone can reproduce your numbers
from zero.
11. Sources
Rendered from SOURCES.md.
| Field | Value |
|---|---|
| Dataset | Synthetic warehouse order workloads (Part A 3 stations, Part B 4 stations, 4,000 orders each) |
| Type | Synthetic |
| Generated by | data/make_data.py (this repo), seeds 20260732 (A) and 20260733 (B) |
| Source URL | Not applicable — generated, not fetched |
| License | Not applicable — synthetic, no third-party rights |
| Access / generation date | 2026-07-18 |
| Redistribution | Permitted — synthetic; output regenerated from the seeded script, never committed |
| robots.txt checked | Not applicable — no scraping |
| ToS URL | Not applicable — no external source |
Synthetic data teaches the mechanic; it does not prove a finding about the real world. The misleading station layout was designed on purpose — finding the true bottleneck demonstrates the technique, not a claim about any real warehouse.
Next up
That’s the top of the Open-Source Libraries track. Browse all courses → to pick your next build.