Inventory Oscillation
Naive reordering that ignores in-transit stock makes inventory swing; counting the pipeline flattens it.
Level: Beginner
Delays
Learn about delays in systems, how they create oscillations, and their impact on system behavior and decision-making.
Take the QuizInventory oscillation from a blind reorder
A store wants to hold a fixed target stock. Each day demand draws stock down, and when inventory dips the manager orders enough to refill the gap. The catch: orders take a few days to arrive, and the naive policy ignores what is already on the way. So the manager re-orders for a shortfall that inbound shipments will already cover — they all land at once, stock gluts, ordering stops, stock drains, and the cycle repeats. That self-inflicted swing is the bullwhip effect.
Flip one knob, account_for_pipeline, and the manager subtracts the
in-transit orders before reordering (an order-up-to policy). The ±30
swing collapses to a flat line — same demand, same delay, one fix.
from tys import probe, progress
def simulate(cfg: dict):
import simpy
env = simpy.Environment()
inventory = cfg["initial_inventory"]
target = cfg["target_inventory"]
daily_demand = cfg["daily_demand"]
lead_time = cfg["lead_time"]
review_period = cfg["review_period"]
sim_time = cfg["sim_time"]The single knob: should the reorder count orders already in transit?
account_for_pipeline = cfg.get("account_for_pipeline", False)
pipeline = [] # list of (arrival_time, qty)
done = env.event()
def run():
nonlocal inventory, pipeline
for day in range(sim_time):demand depletes stock
inventory = max(inventory - daily_demand, 0)
receive any orders that have arrived
arrivals = [qty for (t, qty) in pipeline if t == day]
for qty in arrivals:
inventory += qty
pipeline = [(t, q) for (t, q) in pipeline if t != day]
on_order = sum(q for _, q in pipeline)
periodic review and reordering
if day % review_period == 0:
if account_for_pipeline:Order-up-to: count the shipments already on the way, so we only order the genuine shortfall.
order_qty = max(target - inventory - on_order, 0)
else:Naive: order to refill the visible gap, blind to transit.
order_qty = max(target - inventory, 0)
if order_qty > 0:
pipeline.append((day + lead_time, order_qty))
probe("inventory", env.now, inventory)
probe("pipeline", env.now, sum(q for _, q in pipeline))
progress(int(100 * (day + 1) / sim_time))
yield env.timeout(1)
done.succeed({"final_inventory": inventory})
env.process(run())
env.run(until=done)
return done.value
def requirements():
return {
"external": ["simpy==4.1.1"],
}
# bullwhip.yaml — the default. The manager reorders to fill the visible gap
# but ignores the 3-day pipeline, so shipments pile up and run out: ±30 swing.
initial_inventory: 100
target_inventory: 100
daily_demand: 10
lead_time: 3
review_period: 1
sim_time: 60
account_for_pipeline: false
| Metric | Value |
|---|---|
| final_inventory | 80.00 |
# smoothed.yaml — the contrast: only account_for_pipeline flips to true.
# Subtracting in-transit orders (order-up-to) damps the swing to a flat line.
initial_inventory: 100
target_inventory: 100
daily_demand: 10
lead_time: 3
review_period: 1
sim_time: 60
account_for_pipeline: true
| Metric | Value |
|---|---|
| final_inventory | 70.00 |
- What actually causes the swings?
- Pipeline blindness. Orders take lead_time days to arrive, but the naive policy reorders to fill the gap between current stock and target every day, ignoring shipments already in transit. So it re-orders for a shortfall that inbound orders will already cover. Those orders all land together, stock overshoots the target, ordering stops, demand drains it back down, and the cycle repeats — the bullwhip effect, generated entirely inside the store.
- What sets how big the swing gets?
- The lead time relative to how aggressively you reorder. A longer lead_time means more orders are in flight and unaccounted-for when you place the next one, so the eventual glut is bigger. With daily review and a multi-day delay, the naive policy effectively orders the same demand several times before the first shipment even arrives.
- How does the pipeline-aware fix work?
- It switches to an order-up-to policy: order_qty = target − inventory − on_order, subtracting everything already on the way. Now the manager only orders the genuine shortfall, so orders match demand and inventory settles at target − lead_time × demand (here 100 − 3×10 = 70) instead of oscillating. The pipeline probe shows why: it holds a steady 30 units in transit rather than gluts and droughts.
- What is the real-world analog?
- This is the Beer Distribution Game from MIT — a supply chain where each tier reorders against visible stock and ignores in-transit orders, amplifying a small demand change into wild swings up the chain. The same blindness shows up in hiring against a backlog, scaling servers against queue depth, or restocking with slow suppliers.