Inventory Oscillation

Naive reordering that ignores in-transit stock makes inventory swing; counting the pipeline flattens it.

Level: Beginner

inventorydelaybullwhip

  • Stocks: inventory
  • Flows: demand, shipments
  • Feedback Loops: delayed reorder (balancing loop that overshoots)
  • Probes: inventory, pipeline

Delays

Learn about delays in systems, how they create oscillations, and their impact on system behavior and decision-making.

Take the Quiz
simulation.py

Inventory 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
# 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
Charts (Bullwhip)

inventory

Samples60 @ 0.00–59.00
Valuesmin 70.00, mean 95.67, median 95.00, max 130.00, σ 20.03

pipeline

Samples60 @ 0.00–59.00
Valuesmin 0.00, mean 31.83, median 20.00, max 80.00, σ 31.12
Final Results (Bullwhip)
MetricValue
final_inventory80.00
Pipeline-aware.yaml
# 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
Charts (Pipeline-aware)

inventory

Samples60 @ 0.00–59.00
Valuesmin 70.00, mean 70.50, median 70.00, max 90.00, σ 2.84

pipeline

Samples60 @ 0.00–59.00
Valuesmin 10.00, mean 29.50, median 30.00, max 30.00, σ 2.84
Final Results (Pipeline-aware)
MetricValue
final_inventory70.00
FAQ
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.