Skip to content

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 workers

A 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: pack
add 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 once python -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:

Terminal window
python data/make_data.py

That writes two CSVs into data/ (git-ignored; regenerate anytime, byte-for-byte identical because the seed is fixed):

  • Part Aworkload_a.csv (4,000 orders; order_id, arrival, svc_pick, svc_pack, svc_ship).
  • Part Bworkload_b.csv (4,000 orders; adds svc_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:

Terminal window
python part_a/build_sim.py

Prefer 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)

FunctionWhat it does hereDocs
simpy.Environment()The simulation clock and event loopSimPy basics
simpy.Resource(env, capacity=n)A station with n parallel workersResources
with res.request() as req: yield reqQueue for a worker, then hold itResource requests
yield env.timeout(d)Advance the clock by a service timeEvents
env.run()Run until all orders are processedEnvironment.run

If something looks off

  • pack sits 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 # TODO it 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, and achieved_metric (you can leave achieved_metric null; the grader scores you).

Use the shared simulator to explore:

from warehouse import BASE_SERVERS, STAGES, run, add_server
base = run(BASE_SERVERS) # dict with 'cycle' and per-station 'util'
run(add_server("pack")) # try adding a worker to a station

Then:

Terminal window
python part_b/starter.py
pytest part_b/test_solution.py -q

If pytest reports everything as skipped, it just means the output files don’t exist yet — run python part_b/starter.py first (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:

  • pytest reports both green — captured ≥ 0.90 and the right bottleneck.
  • 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.py reproduces 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 PreemptiveResource or 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.

FieldValue
DatasetSynthetic warehouse order workloads (Part A 3 stations, Part B 4 stations, 4,000 orders each)
TypeSynthetic
Generated bydata/make_data.py (this repo), seeds 20260732 (A) and 20260733 (B)
Source URLNot applicable — generated, not fetched
LicenseNot applicable — synthetic, no third-party rights
Access / generation date2026-07-18
RedistributionPermitted — synthetic; output regenerated from the seeded script, never committed
robots.txt checkedNot applicable — no scraping
ToS URLNot 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.