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.
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.
Use Classic EOQ / K-Curve. WW converges to the same answer but pays $O(T^2)$ for nothing.
Use Wagner-Whitin. Large spikes separated by zero-demand weeks are exactly where WW beats EOQ.
Large $S/h$ ratio favours combining periods into fewer, larger orders — WW finds the optimal split.
WW is finite-horizon (typically 24–52 weeks). Beyond that, EOQ's steady-state assumption is reasonable.
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:
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.
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.
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.
Define $f(t)$ as the minimum cost to satisfy all demand in periods $1 \ldots t$.
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.
# 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).
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$hold | candidate | best $f(t)$ |
|---|---|---|---|---|---|---|---|
| 1 | 1 | 0 | 0 | 100 | 0.00 | 100.00 | 100.00 |
| 2 | 1 | 0 | 0 | 100 | 0.00 | 100.00 | 100.00 |
| 2 | 2 | 0 | 100 | 100 | 0.00 | 200.00 | |
| 3 | 1 | 200 | 0 | 100 | 60.00 | 160.00 | 160.00 |
| 3 | 2 | 100 | 100 | 100 | 30.00 | 230.00 | |
| 3 | 3 | 0 | 100 | 100 | 0.00 | 200.00 | |
| 4 | 1 | 200 | 0 | 100 | 60.00 | 160.00 | 160.00 |
| 4 | 2 | 100 | 100 | 100 | 30.00 | 230.00 | |
| 4 | 3 | 0 | 100 | 100 | 0.00 | 200.00 | |
| 4 | 4 | 0 | 160 | 100 | 0.00 | 260.00 | |
| 5 | 1 | 600 | 0 | 100 | 180.00 | 280.00 | |
| 5 | 2 | 400 | 100 | 100 | 120.00 | 320.00 | |
| 5 | 3 | 200 | 100 | 100 | 60.00 | 260.00 | 260.00 |
| 5 | 4 | 100 | 160 | 100 | 30.00 | 290.00 | |
| 5 | 5 | 0 | 160 | 100 | 0.00 | 260.00 | |
| 6 | 1 | 600 | 0 | 100 | 180.00 | 280.00 | |
| 6 | 2 | 400 | 100 | 100 | 120.00 | 320.00 | |
| 6 | 3 | 200 | 100 | 100 | 60.00 | 260.00 | 260.00 |
| 6 | 4 | 100 | 160 | 100 | 30.00 | 290.00 | |
| 6 | 5 | 0 | 160 | 100 | 0.00 | 260.00 | |
| 6 | 6 | 0 | 260 | 100 | 0.00 | 360.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.
[0,0,100,100,0,0] → 200 unit-weeks → holding
€60. Ordering €200. Total = €260.| Policy | Orders | Setup | Holding | Total |
|---|---|---|---|---|
| Lot-for-lot (each non-zero week) | 3 | 300 | 0 | 300 |
| Single batch (order 300 in week 1) | 1 | 100 | 180 | 280 |
| Wagner-Whitin optimal | 2 | 200 | 60 | 260 |
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.
eoq fieldWagner-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:
For the example: $n_{orders}{=}2$, $T{=}6$ → $17.33$ orders/yr; $D_{annual}{=}2600$
→ eoq ≈ 150 (between the two actual lots 100 and 200).
avg_lot_size (= 150 here) is the raw per-horizon average kept for display.
eoq converges to the Classic EOQ
$Q^* = \sqrt{2DS/h_{\text{annual}}}$ — WW is a strict generalisation of EOQ.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.
| Precedence | Source | Meaning |
|---|---|---|
| 1 | interact_io_overrides.eoq_override (scenario_id=0) | User explicit — wins |
| 2 | Computed WW eoq | Annualised average order qty |
| 3 | item.eoq | Write-back from last run; MEIO fallback |
| 4 | 0 | No 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.
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_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_runner.py eoq CTE uses the WW eoq as min_qty per SKU, override first, respecting mult_qty / max_qty.
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.
eoq value.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.
The eoq step (run_pipeline.py) calls run_eoq_for_pipeline,
which branches on the scenario's tag_code:
wagner_whitin → run_wagner_whitin_for_pipeline; otherwise the
K-Curve solver. The runner honours parameters.periods (default 24, max 52).
Rendered inside the K-Curve modal when tag_code='wagner_whitin'. Provides:
WwSawtoothChart, pure SVG) — the graphical analog of the Wilson cost curve.| Criterion | Classic EOQ | K-Curve | Freq-Constrained | Wagner-Whitin |
|---|---|---|---|---|
| Demand assumption | Constant | Constant | Constant | Arbitrary (discrete) |
| Horizon | Infinite | Infinite | Infinite | Finite (T weeks) |
| Optimality | Per-item optimal | Portfolio Pareto | Cost-min among allowed | Globally optimal |
| Portfolio lever | None | k-factor | None | None |
| Supplier constraints | MOQ + multiple | MOQ + multiple | Freq list + MOQ | MOQ + multiple |
| Computational cost | O(1) | O(N·P) | O(N·F) | O(N·T²) |
| Best for | Stable, high-volume | Budget negotiation | Fixed delivery schedules | Lumpy / intermittent |
| API endpoint | /kcurve/eoq | /kcurve/solve | /kcurve/solve | /kcurve/wagner-whitin |
| EOQ override table | interact_io_overrides | same | same | same |
| Feeds MEIO/supply | yes | yes | yes | yes (same shape) |
$N$ = SKUs, $P$ = k-points (80), $F$ = allowed frequencies, $T$ = horizon weeks.