Wagner-Whitin Dynamic Lot-Sizing

ForecastAI 2026.01 · Mirabelle · tag_code: wagner_whitin O(T²) per item globally optimal

Wagner-Whitin (1958) finds the globally optimal replenishment schedule for a finite horizon of discrete, arbitrarily-varying demand periods. Unlike Classic EOQ — which assumes constant, continuous demand and yields a single fixed order quantity $Q^*$ — Wagner-Whitin decides when to order and how much, period by period, so that total ordering + holding cost is minimised exactly.

In one line: for each planning period, decide whether to place an order that covers the demand of this period alone, or to combine several future periods into one larger order — trading one extra setup cost against the holding cost of carrying the combined units forward.

Overview & When to use

Classic EOQ is visualised by the Wilson cost curve (a U-shaped total cost with minimum at $Q^*$). Wagner-Whitin is visualised by the inventory sawtooth profile — order arrivals as vertical spikes, demand as downward slopes, returning to zero before each new order.

Constant demand?

Use Classic EOQ / K-Curve. WW converges to the same answer but pays $O(T^2)$ for nothing.

Lumpy / intermittent?

Use Wagner-Whitin. Large spikes separated by zero-demand weeks are exactly where WW beats EOQ.

High setup cost?

Large $S/h$ ratio favours combining periods into fewer, larger orders — WW finds the optimal split.

Short horizon?

WW is finite-horizon (typically 24–52 weeks). Beyond that, EOQ's steady-state assumption is reasonable.

Intuition & the Zero-Inventory Property

Given $T$ weekly demand values $d_1 \ldots d_T$, a fixed ordering cost $S$ per order, and a holding cost $h$ per unit per week, choose a lot size $q_t \ge 0$ for each week to minimise:

min  ∑t: qt>0 S  +  h · ∑t=1..T It

where $I_t$ is the on-hand inventory at the end of period $t$ (units carried into the next period). No backorders: every period's demand must be met by an order in that period or earlier.

Zero-inventory ordering property. At optimality, an order is placed in period $j$ only when inventory is zero at the start of $j$. Therefore every order exactly covers the demand of a contiguous block of future periods $j, j{+}1, \ldots, t$ — never a partial mix. This collapses the search space: instead of choosing arbitrary quantities, we only choose which periods start a new order.

Graphical View: the Sawtooth

The diagram below is the optimal schedule for the running example (demand [100, 0, 100, 0, 100, 0], $S=$€100, $h=$€0.30/unit/week). The WW algorithm orders 100 in week 1 and 200 in week 3. Note how each order lands exactly when inventory hits zero.

0 50 100 150 200 On-hand inventory (units) +100 +200 d=100 d=100 d=100 W1 W2 W3 W4 W5 W6 Week Inventory level Order arrival (+lot)

Compare with Classic EOQ's sawtooth under constant demand: equal-height, evenly-spaced teeth. Wagner-Whitin's teeth are irregular — tall where demand clusters, absent in zero-demand weeks — because it batches lumpy demand optimally.

The O(T²) Dynamic Program

Define $f(t)$ as the minimum cost to satisfy all demand in periods $1 \ldots t$.

f(t) = min1≤j≤t [  f(j−1) + S + h·∑i=j..t(i−j)·di  ]

The inner sum is the holding cost when a single order placed at the start of period $j$ covers demand through period $t$: $d_j$ is carried 0 weeks, $d_{j+1}$ one week, …, $d_t$ carried $t{-}j$ weeks. (Equivalently it is the sum of end-of-period inventories over weeks $j \ldots t$ for that one order.)

Base case: $f(0) = 0$. After filling the table to $f(T)$, backtrack through the recorded best $j$ (order_at[t]) to reconstruct the schedule.

Implementation

# files/kcurve/wagner_whitin.py
def wagner_whitin(demands, S, h):
    T = len(demands)
    if T == 0 or S <= 0:
        return demands[:]
    INF = float("inf")
    f = [INF] * (T + 1);  f[0] = 0.0
    order_at = [0] * (T + 1)
    for t in range(1, T + 1):
        for j in range(1, t + 1):
            hold = sum((i - j) * demands[i - 1] for i in range(j, t + 1))
            cost = f[j - 1] + S + h * hold
            if cost < f[t]:
                f[t] = cost
                order_at[t] = j
    lots = [0.0] * (T + 1)
    t = T
    while t > 0:
        j = order_at[t]
        lots[j] = sum(demands[j - 1:t])   # order covers weeks j..t
        t = j - 1
    return lots[1:]

Weekly holding conversion: $h = p \cdot i / 52$ (e.g. price €52, carrying rate 30%/yr → $h =$ €0.30/unit/week).

Worked Example — Full DP Table

Demand [100, 0, 100, 0, 100, 0], $S=$€100, $h=$€0.30. For each $t$ (cover weeks $1 \ldots t$) we test every candidate order period $j$. Candidate $= f(j{-}1) + S + h \cdot \text{hold}$.

$t$$j$hold$f(j{-}1)$+$S$+$h\cdot$holdcandidatebest $f(t)$
11001000.00100.00100.00
21001000.00100.00100.00
2201001000.00200.00
31200010060.00160.00160.00
3210010010030.00230.00
3301001000.00200.00
41200010060.00160.00160.00
4210010010030.00230.00
4301001000.00200.00
4401601000.00260.00
516000100180.00280.00
52400100100120.00320.00
5320010010060.00260.00260.00
5410016010030.00290.00
5501601000.00260.00
616000100180.00280.00
62400100100120.00320.00
6320010010060.00260.00260.00
6410016010030.00290.00
6501601000.00260.00
6602601000.00360.00

Backtrack: $f(6)=260$, order_at[6]=3 → order in week 3 covers weeks 3..6 = $100{+}0{+}100{+}0 = 200$. Then $t{=}2$, order_at[2]=1 → order in week 1 covers weeks 1..2 = $100{+}0 = 100$. Done.

Optimal: order 100 in week 1, order 200 in week 3. End-of-period inventory [0,0,100,100,0,0] → 200 unit-weeks → holding €60. Ordering €200. Total = €260.
PolicyOrdersSetupHoldingTotal
Lot-for-lot (each non-zero week)33000300
Single batch (order 300 in week 1)1100180280
Wagner-Whitin optimal220060260

WW beats both extremes — it skips the third €100 setup and avoids carrying all 300 units from week 1. Saving €40 (13%) vs lot-for-lot.

The per-item eoq field

Wagner-Whitin produces a dynamic schedule (different lot each order), not a single quantity. To give MEIO and supply one comparable batch size — the same role Classic EOQ's $Q^*$ plays — the result exposes an annualised average order quantity:

orders_per_year = n_orders × 52 / T   ⇒   eoq = D_annual / orders_per_year

For the example: $n_{orders}{=}2$, $T{=}6$ → $17.33$ orders/yr; $D_{annual}{=}2600$ → eoq150 (between the two actual lots 100 and 200). avg_lot_size (= 150 here) is the raw per-horizon average kept for display.

Under continuous, stable demand eoq converges to the Classic EOQ $Q^* = \sqrt{2DS/h_{\text{annual}}}$ — WW is a strict generalisation of EOQ.

Shared EOQ Overrides

Wagner-Whitin uses the same override mechanism as Classic EOQ and K-Curve: the interact_io_overrides table, with scenario_id = 0 as the pipeline-level sentinel. One override row per (pipeline_id, item_id, site_id) applies to all three methods — there is no separate WW override store.

PrecedenceSourceMeaning
1interact_io_overrides.eoq_override (scenario_id=0)User explicit — wins
2Computed WW eoqAnnualised average order qty
3item.eoqWrite-back from last run; MEIO fallback
40No order quantity

The endpoint annotates each item with eoq (post-override) and eoq_override (raw value, null if none) — identical shape to /kcurve/eoq and /kcurve/solve.

MEIO & Supply Propagation

When a pipeline's linked inventory_scenario has tag_code = 'wagner_whitin', the pipeline eoq step runs run_wagner_whitin_for_pipeline (instead of the K-Curve solver) and persists results.items[] in the same shape MEIO and supply already consume:

{ "unique_id": "...", "item_id": 1, "site_id": 2, "eoq": 150.0,
  "eoq_override": null, "orders_per_year": 17.33, "annual_demand": 2600, ... }

MEIO

meio_runner.py eoq_scenario CTE reads results->items[].eoq with the override taking precedence; falls back to item.eoq. No SQL change was required — it consumes WW exactly like K-Curve.

Supply

supply_runner.py eoq CTE uses the WW eoq as min_qty per SKU, override first, respecting mult_qty / max_qty.

item.eoq write-back

The runner writes the demand-weighted average WW eoq back to master.item.eoq, so the MEIO fallback path and reports reflect WW — same behaviour K-Curve has.

A WW-tagged pipeline feeds MEIO and supply exactly like an EOQ/K-Curve pipeline; the only difference is the source of the eoq value.

API, Pipeline & UI

Endpoint

GET /api/kcurve/wagner-whitin?pipeline_id=...&periods=24&ordering_cost_mult=1&holding_rate_mult=1

Returns per-item eoq, eoq_override, orders_per_year, n_orders, avg_lot_size, schedule[], plus portfolio totals.

Pipeline step

The eoq step (run_pipeline.py) calls run_eoq_for_pipeline, which branches on the scenario's tag_code: wagner_whitinrun_wagner_whitin_for_pipeline; otherwise the K-Curve solver. The runner honours parameters.periods (default 24, max 52).

UI — WagnerWhitinWorkbench

Rendered inside the K-Curve modal when tag_code='wagner_whitin'. Provides:

Method Comparison

CriterionClassic EOQK-CurveFreq-ConstrainedWagner-Whitin
Demand assumptionConstantConstantConstantArbitrary (discrete)
HorizonInfiniteInfiniteInfiniteFinite (T weeks)
OptimalityPer-item optimalPortfolio ParetoCost-min among allowedGlobally optimal
Portfolio leverNonek-factorNoneNone
Supplier constraintsMOQ + multipleMOQ + multipleFreq list + MOQMOQ + multiple
Computational costO(1)O(N·P)O(N·F)O(N·T²)
Best forStable, high-volumeBudget negotiationFixed delivery schedulesLumpy / intermittent
API endpoint/kcurve/eoq/kcurve/solve/kcurve/solve/kcurve/wagner-whitin
EOQ override tableinteract_io_overridessamesamesame
Feeds MEIO/supplyyesyesyesyes (same shape)

$N$ = SKUs, $P$ = k-points (80), $F$ = allowed frequencies, $T$ = horizon weeks.