The reorder-point trap: why ordering to average demand runs you out of stock
Data: both parts use synthetic data, generated locally by
data/make_data.py(seeded, byte-for-byte reproducible). Nothing is downloaded; no signup, no GPU. Synthetic is the right call here: to check whether a stocking policy delivers the service level it promises, you have to know the demand process it faces — and with synthetic data you do.
Before you start
New to this? Two minutes of vocabulary, because the whole project turns on it.
You run a warehouse. You can’t order one unit at a time, so you hold stock and reorder in batches. Two numbers decide whether you stock out:
- Lead time — how long an order takes to arrive after you place it. Order today, it lands in (say) 7 days. Demand keeps coming in the meantime.
- Service level — the promise you make about not running out. Two flavours, and
they are not the same number:
- Cycle service level (CSL) — the fraction of order cycles in which you never run out. “95% CSL” = you stock out in about 1 cycle in 20.
- Fill rate — the fraction of total demanded units you actually ship from stock. You can have a terrible CSL and a fine-looking fill rate at the same time, because a stockout usually misses only the last few units of a cycle.
Safety stock is the extra you hold above your average expected demand, to absorb the fact that demand is not the same every day. This project is about sizing it: too little and you stock out constantly; too much and you tie up cash in inventory that sits there.
A tiny example — you sell 50 units/day on average and an order takes 7 days. “Average demand over the lead time” is 50 × 7 = 350. If you reorder when stock hits 350, you’re betting the next 7 days will be exactly average. Half the time they’re higher — and you run out. That bet, made at scale, is the trap this project is named after.
What you’ll learn: to size safety stock to a service level you choose, to tell cycle service level from fill rate and report the one that matters, and to verify a stocking policy by simulating it instead of trusting a formula.
New to Python? Section 4 (“Where to write and run code”) starts from zero.
1. The Brief
You’ll take one steady, well-behaved retail SKU, set its stock level the way almost everyone first does — enough to cover average demand over the time you’re exposed — and then simulate 16 years of demand to watch it run out of stock in about 40% of its order cycles. Then you’ll add safety stock sized to the spread of demand, re-run the simulation, and watch the same SKU hit a 95% service level for the cost of a modest buffer. Along the way you’ll see why the honest way to know your service level is to simulate the policy, not to quote the formula that was supposed to produce it.
Anyone who orders stock for a living cares about this, and most get it subtly wrong. “Reorder at average demand over the lead time” feels obviously right and is quietly a coin-flip: you sit at the middle of the demand distribution, so a large share of cycles land above your stock and you stock out. Under-stocking looks like bad luck (“demand spiked”); it’s actually a policy that was designed to fail half the time. Sizing safety stock deliberately is the difference between a supply plan and a running apology.
2. Difficulty & time
Difficulty 5 / 10. It sits above the seed project #silent-row-loss (difficulty 3): that one turns on a single concept (join integrity) with the path fully specified, whereas this project chains several moving parts — estimate a demand process, simulate a periodic-review inventory policy, measure two different service metrics, size safety stock from the standard deviation of demand-over-a-window, and then discover by simulation that a formula’s promise and its realised behaviour can differ. It’s about level with #accuracy-is-lying (difficulty 5): both hinge on a genuinely non-obvious idea (there, that accuracy lies on imbalance; here, that “order to the average” is a coin flip) and both are one-tool, no-second-system projects with a fully guided Part A. It stays below 7–8 because there’s no algorithmic search, no second system, and no scaling constraint. Anchored against the difficulty rubric’s level-5 band and the three named ledger projects it cites — #silent-row-loss, #accuracy-is-lying, and the difficulty-3 #idempotent-loads (G10).
Time is separate from difficulty. The scripts run in under a second (measured Part A wall-clock 0.51s); your time goes to reading, running, and reasoning. Part A is about 75 minutes; Part B is 2–4 hours of your own work.
3. What you’ll be able to do after
- Size safety stock to a target service level: turn “I want 95% service” into an actual
stock number, using the standard deviation of demand over the exposure window and the
matching
zvalue. - Explain why a stock level set to average demand stocks out roughly half the time, and demonstrate it with a simulation rather than asserting it.
- Distinguish cycle service level from fill rate, compute both, and say which one a given situation calls for — instead of quoting whichever looks better.
- Account for variable lead time: recognise when lead-time variability, not demand variability, is what’s driving your stockouts, and size for it.
The finished result
By the end of Part A you’ll have run the same SKU under two stock levels and watched the realised service level move — proven by simulation, not asserted by a formula:
naive S = 698 (cover average demand only) -> cycle service level 0.605, fill rate 0.961+ safety stock 91 units (z = 1.645) -> order-up-to S = 789safety S = 789 -> cycle service level 0.958, fill rate 0.998About 91 extra units of stock lifted the same SKU from a 0.605 cycle service level to 0.958 — clearing the 95% it promised — and the simulation, not the formula, is what confirmed it.
4. Prereqs & time box
You can write a for loop and a function, and you’ve seen a pandas DataFrame. No
inventory background is assumed — the vocabulary is in the primer above, and the stats
(mean, standard deviation, a z-value from a target probability) are used but explained
as we go. No calculus.
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 only pandas and numpy, 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 pandas"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
Both datasets are synthetic, generated by one seeded command (byte-for-byte reproducible, git-ignored, never committed):
python data/make_data.pyThat writes:
- Part A —
data/demand_a.csv: 6,000 days of daily demand for one retail SKU (a popular bottled drink at a distribution centre). - Part B —
data/parts_demand.csvanddata/parts.csv: 10 years of daily demand for each of 10 bus-fleet spare parts, plus each part’s vendor-quoted lead-time mean and standard deviation.
The generative story and every design choice are documented in data/make_data.py.
Synthetic data teaches the mechanic here; it does not prove a finding about any real
store or depot (see Sources).
6. Part A — Guided Build
Run the full script and read along:
python data/make_data.py # oncepython part_a/simulate_policy.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/simulate_policy.py. The setting: one SKU, reviewed
once a week; each order takes a fixed 7 days to arrive. Every week we order up to a target
stock level S. The only thing we change between the two runs below is S.
Step 1 — Load the demand and measure it. Estimate the daily mean and standard deviation from the history.
Checkpoint: 6,000 days; mean demand 49.84 units/day, std 14.86. Review every 7 days, lead time 7 days → the stock you hold must survive a 14-day exposure window (this week’s demand plus the lead time before a correction can land).
Step 2 — The naive stock level: cover average demand over the window. Set
S = mean × 14, no safety stock, and simulate 858 review cycles.
Checkpoint: naive
S = 698. Realised cycle service level 0.605, fill rate 0.961. You run out in ~40% of cycles — because covering only the average leaves you below the window’s actual demand in a large share of them.
Step 3 — Why? Because demand over the window is itself variable. Its standard
deviation is sigma × sqrt(window), and it scales with the square root of the window,
because the variances of independent days add.
Checkpoint: std of demand over 14 days =
14.86 × sqrt(14)= 55.61 units. (Scaling bysqrt(window), notwindow, is the piece people most often get wrong.)
Step 4 — Size safety stock to a target. Hold an extra z × sigma_window, where z
is the normal quantile for the service level you promise (95% → z = 1.645).
Checkpoint: safety stock =
1.645 × 55.61≈ 91 units →S = 789. Realised cycle service level 0.958, fill rate 0.998. About 91 extra units of stock moved you from a coin-flip to a 95% promise, and the simulation confirms the promise — you didn’t just trust the formula.
Step 5 — Cycle service level is not fill rate. Look back at the naive run:
Checkpoint: naive policy — CSL 0.605 but fill rate 0.961. You can run short in most cycles yet still ship 96% of units, because each shortfall misses only the tail of a cycle. If a stranded customer is what hurts, manage CSL; if unmet units are what hurt, manage fill rate. Decide which, and measure that one — don’t quote the comforting fill rate to paper over a bad CSL.
Why this and not that
- Why simulate, instead of just quoting the formula? The safety-stock formula assumes demand over the window is exactly normal and that you reorder at precisely the right instant. Reality is lumpier. Simulating the policy on the actual demand history tells you the service level you’ll really get; the formula only tells you the one you hoped for. Here they agree closely — but you only know that because you checked.
- Why size to the exposure window (review + lead), not the lead time alone? After you order, the next chance to fix things is the next review, and that fix only lands a lead time later. So the stock on hand must cover review + lead = 14 days, not 7. Sizing to the lead time alone is a common way to under-stock by half.
- Why
sigma × sqrt(window)and notsigma × window? Day-to-day demand is roughly independent, so variances add over the window, and standard deviation is their square root. Multiplying the daily std by the whole window would massively over-state the spread and leave you drowning in stock.
Functions you’ll meet (and where to read more)
| Function | What it does here | Docs |
|---|---|---|
pd.read_csv | Loads the demand history | read_csv |
ndarray.mean() / .std(ddof=1) | Estimate the demand mean and (sample) std | numpy.std |
math.sqrt | Scale the daily std over the window (sqrt(window)) | math.sqrt |
statistics.NormalDist().inv_cdf(p) | The z value for a target service level (no scipy needed) | NormalDist |
| Concept | Safety stock, reorder points, service levels (background reading) | Safety stock (Wikipedia) |
If something looks off
- The naive run’s fill rate is 0.961 — that looks fine, so what’s the trap? It is the trap: the same naive run reads 0.961 on fill rate but only 0.605 on cycle service level (you stock out in ~40% of cycles). Fill rate counts units shipped; cycle service level counts cycles you got through without a stockout. Read the metric that matches your promise, not the flattering one.
- A checkpoint number doesn’t match. The data is seeded, so the numbers are exact. Re-run
python data/make_data.py(you may have skipped or edited it), 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.
Different world, harder job. You run a bus-fleet spare-parts depot with 10 parts,
reviewed weekly like Part A — but now each part’s supplier lead time is variable (a
brake pad ships in ~5 days like clockwork; a rebuilt turbocharger in ~18 days, wildly
erratic). The parts and their vendor-quoted lead-time mean/std are in data/parts.csv;
10 years of daily demand per part are in data/parts_demand.csv.
Your job is an allocation: choose an order-up-to level for every part so that each
one reaches a fill rate of at least 0.95 on the seeded demand, while keeping your
total safety stock at or below 700 units (you can’t just stock everything to the
ceiling). The canonical fill-rate simulation is provided in
part_b/_sim.py so you can check any policy before submitting.
Same core skill as Part A — size safety stock to a service target — but the Part A formula alone will betray you here. When lead time is random, the spread of demand-over-the-window has a second source (the lead time itself), and it’s usually the bigger one. Size with demand variability only and the erratic parts fall short while the steady ones look fine.
Acceptance criteria (checkable)
Fill in part_b/starter.py, then:
python part_b/starter.py # writes part_b/out/pytest 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.
Your solution writes into part_b/out/:
policy.csv— columnspart_id,order_up_to(your chosen stock level per part).report.json—overall_fill_rate,worst_part_fill_rate,total_safety_stock, andachieved_metric(= your worst part’s fill rate).
You’re done when the tests pass. They re-simulate your order-up-to levels themselves and require: every part’s fill rate ≥ 0.95, total safety stock ≤ 700 units, and your reported numbers matching the simulation. For reference, the solution gets every part to ≥ 0.987 (worst part 0.987) using 577 safety units — so 700 leaves you margin.
Constraints — what must be true, not how to get there
- Hit the floor on every part, not just on average. A great overall fill rate that hides one starved part fails — that’s the whole point.
- Stay within the 700-unit safety-stock budget. Cranking every part sky-high is not a solution; sizing each part to what its variability actually demands is.
- Your reported
overall_fill_rate/worst_part_fill_ratemust match what the simulation produces from yourorder_up_tolevels (the test recomputes them).
Hints
Hint 1 — the shape of the problem
Do Part A’s method per part first (estimate each part’s demand mean and std, size safety
stock with z × sigma_demand × sqrt(window)), run the provided simulation, and look at
which parts miss 0.95. You’ll see the steady parts pass and the erratic-lead-time parts
fall short. That gap is the clue.
Hint 2 — where the extra variability comes from
When the lead time is random, demand over the exposure window varies for two reasons:
demand wobbles, and the window length itself wobbles. The combined standard deviation is
sqrt( window * sigma_demand^2 + mean_demand^2 * sigma_lead^2 ). The second term is the
lead-time contribution — for several of these parts it’s the larger one. Use this in place
of the demand-only spread.
Hint 3 — hitting the target without blowing the budget (still not the code)
Sizing each part with the combined standard deviation puts the stock where the variance actually is — a lot on the erratic parts, little on the steady ones — so you clear the floor everywhere for far fewer units than padding every part equally would cost. If you’re over budget, you’re almost certainly still over-stocking the steady parts; check whether their fill rate is already well above 0.95.
8. Self-check
You don’t need the answer key. You’re done when:
- You can state, in one sentence, why the naive Part A stock level stocked out in ~40% of cycles.
- You can say, for your own use, whether cycle service level or fill rate is the metric that matters — and you measured that one.
- Every Part B part clears the 0.95 fill floor in the simulation, and your total safety stock is under budget.
- You can point to the parts where lead-time variability (not demand variability) was the thing you had to size for, and explain how you know.
9. Stretch
- Cost-aware allocation. Put a rough holding cost per unit on each part (a turbo costs far more to sit on a shelf than a wiper blade) and minimise total cost at the fill floor instead of total units. Does the allocation change?
- Fill rate directly. Part A sized to a cycle service level and got fill rate as a by-product. Size Part B to a target fill rate directly, using the loss-function (expected-shortage) method, and compare the stock it asks for.
- (Genuinely hard) Non-normal demand. Regenerate the data with intermittent, spiky demand (many zero days, occasional large orders — realistic for slow-moving spare parts) and show where the normal-approximation safety-stock formula breaks down and what you’d do instead.
10. Ship it
Put this in a portfolio repo and it counts. In that repo’s README:
- Lead with the one-liner a non-engineer gets: “Ordering to average demand sounds safe — here’s the simulation showing it stocks out 4 cycles in 10, and what I changed.”
- Show the two Part A numbers side by side (naive vs safety-stock: CSL 0.61 → 0.96) and name the cost in units. Show the Part B chart of per-part fill rate before and after you added the lead-time term.
- State the judgment in one honest sentence: which service metric you optimised and why, and which parts were driven by lead-time (not demand) variability. That sentence is what signals you can be trusted with an inventory plan.
Keep make_data.py and the tests in the repo so anyone can reproduce your numbers from
zero.
11. Sources
Rendered from SOURCES.md.
| Field | Value |
|---|---|
| Datasets | Synthetic — retail SKU demand (Part A); bus-fleet spare-parts demand (Part B) |
| Data type | Synthetic (seeded, reproducible) |
| Generated by | data/make_data.py — numpy.random.default_rng(seed=20260718) |
| Source / license | Not applicable — synthetic, generated in-repo; no third-party rights |
| Access / verification date | 2026-07-18 |
| Redistribution | Not applicable — generator committed, CSV output never committed |
| robots.txt / ToS | Not applicable — nothing is scraped or fetched |
The data is synthetic and teaches the mechanic (sizing safety stock to a service level);
it does not prove a finding about any real store or depot. Every injected feature is
documented in data/make_data.py.
Next up
Finished this one? Continue the Supply Chain track:
Delivery Routing: untangling a route with 2-opt, then splitting a full van into trips → · Browse all courses