EOQ & Lot-Sizing Methods¶
Chapter summary
Mirabelle implements four distinct lot-sizing methods, each suited to a different demand pattern and operational context. This chapter derives every formula from first principles, explains when each method is appropriate, documents all supplier constraints applied on top of the raw calculation, and shows how results propagate through the system.
| Method | Best for | Complexity |
|---|---|---|
| Classic EOQ | Steady, predictable demand | \(O(1)\) per item |
| K-Curve (portfolio EOQ) | Portfolio budget / frequency trade-off | \(O(N)\) per k value |
| Order-Frequency Constrained EOQ | Supplier delivery schedules | \(O(F)\) per item |
| Wagner-Whitin | Lumpy / intermittent demand | \(O(T^2)\) per item |
Classic EOQ¶
Figure: the classic EOQ inventory sawtooth — on-hand jumps to Q on each replenishment, then decays linearly at demand rate d to zero over the cycle time Q/D, repeating indefinitely.
gantt
title EOQ sawtooth — on-hand inventory over time
dateFormat X
axisFormat %s
section On-hand
Peak Q* at order arrival :milestone, ms1, 0
Linear decay at rate d :active, a1, 0, 4
Reaches zero — reorder :crit, ms2, 4
Peak Q* next order :milestone, ms3, 4
Linear decay at rate d :active, a2, 4, 8
Reaches zero — reorder :crit, ms4, 8
Peak Q* next order :milestone, ms5, 8
section Cycle
Cycle 1 :c1, 0, 4
Cycle 2 :c2, 4, 8
The fundamental trade-off¶
Every replenishment order incurs a fixed cost \(S\) (ordering, setup, administrative effort). Holding inventory costs money — capital tied up, warehousing, obsolescence risk — expressed as a fraction \(i\) of unit value per year. These two costs pull in opposite directions:
- Ordering less often (large batches) saves ordering cost but increases average inventory.
- Ordering more often (small batches) reduces average inventory but increases ordering cost.
The Economic Order Quantity is the quantity that minimizes total annual cost.
Derivation¶
Figure: derivation flow — total cost TC(Q) = ordering + holding; setting dTC/dQ = 0 makes ordering cost equal holding cost, yielding Q = sqrt(2DS/H).*
flowchart TD
D["Annual demand D"] --> TC
S["Ordering cost S"] --> TC["TC(Q) = (D/Q)·S + (Q/2)·h"]
P["Unit price p"] --> H["h = p · i<br/>(holding cost/unit/yr)"]
IR["Holding rate i"] --> H
H --> TC
TC --> DERIV["dTC/dQ = 0<br/>−DS/Q² + h/2 = 0"]
DERIV --> EQ["Q* = sqrt(2DS / h)<br/>= sqrt(2DS / p·i)"]
EQ --> CROSS["At Q*: ordering cost = holding cost<br/>TC(Q*) = sqrt(2DSh)"]
Let:
- \(D\) = annual demand (units/year)
- \(Q\) = order quantity (units per order)
- \(S\) = ordering (setup) cost per order
- \(p\) = unit price
- \(i\) = annual holding rate (fraction of price per year)
- \(h = p \cdot i\) = annual holding cost per unit
Assuming constant demand and instantaneous replenishment, average on-hand inventory (cycle stock only) is \(Q/2\).
Setting \(\frac{d\,TC}{d\,Q} = 0\):
At the EOQ, ordering cost equals holding cost — the two curves cross at their minimum sum.
Figure: the Wilson total-cost curve — ordering cost falls as Q grows, holding cost rises linearly; the U-shaped total cost is flat-bottomed near Q (the bathtub property).*
quadrantChart
title Total annual cost vs order quantity Q
x-axis "Small Q (frequent orders)" --> "Large Q (rare orders)"
y-axis "Low cost" --> "High cost"
quadrant-1 "Holding-dominated"
quadrant-2 "Ordering-dominated"
quadrant-3 "Optimal zone"
quadrant-4 "Over-stocked"
"small Q, high ordering": [0.15, 0.85]
"Q* minimum TC": [0.50, 0.30]
"flat-bottom (bathtub)": [0.55, 0.32]
"large Q, high holding": [0.85, 0.80]
Sensitivity (the bathtub property)¶
The total-cost curve is flat-bottomed near \(Q^*\). A 25 % deviation from \(Q^*\) raises cost by only 2.5 %. More usefully: a 4× error in the ordering cost estimate changes \(Q^*\) by only 2× and changes \(TC\) by only 41 %. This means approximate parameters still yield near-optimal quantities.
Numerical example¶
| Parameter | Value |
|---|---|
| Annual demand \(D\) | 1 200 units/year |
| Ordering cost \(S\) | €80 per order |
| Unit price \(p\) | €25 |
| Holding rate \(i\) | 20 %/year |
| \(h = p \times i\) | €5/unit/year |
Note the symmetry: ordering and holding costs are equal at the EOQ, confirming the analytical result.
Code location¶
# files/api/main.py — _kcurve_metrics() with k=1
D, S, h = it["annual_demand"], it["order_cost"], it["price"] * it["holding_rate"]
Q = math.sqrt(2 * S * D / h) # classic EOQ (k = 1 special case)
API endpoint: GET /api/kcurve/eoq (portfolio), GET /api/kcurve/eoq/item
(single item with its demand context).
Reorder Point (ROP)¶
The EOQ answers how much to order. The reorder point answers when:
where \(SS\) is safety stock (see Safety Stock) and \(L\) is
lead time in weeks from the route table
((lead_time + transit_time + pick_pack_time) / 7).
The K-Curve runner annualizes demand for EOQ then converts back for ROP:
K-Curve (Portfolio EOQ)¶
Why a portfolio approach?¶
Classic EOQ treats each SKU independently. Real companies face a joint constraint: a fixed ordering/procurement budget or a target for total purchase orders per year. The K-Curve (exchange curve) optimizes the entire portfolio simultaneously under such constraints.
The k-factor¶
A single scalar \(k\) multiplies the holding cost in the EOQ formula, shifting every SKU in the same direction:
- \(k = 1\): each item at its individual EOQ (minimum total cost, unconstrained)
- \(k > 1\): smaller quantities, more frequent orders, less cycle stock
- \(k < 1\): larger quantities, fewer orders, more cycle stock
Portfolio aggregation¶
Summing across all \(N\) items:
where:
Eliminating \(k\) gives the hyperbolic exchange curve:
Any policy on this curve is Pareto-efficient. Any policy inside it wastes resources (both more inventory and more orders than necessary).
Budget-mode solving (Brent's method)¶
When targetMode = 'budget', the system finds the \(k\) that achieves a target
inventory value:
# files/kcurve/runner.py — _solve_k()
from scipy.optimize import brentq
k = brentq(lambda kk: _kcurve_metrics(kk, items)[1] - target, 0.0001, 1000.0)
_kcurve_metrics(k, items) returns (Q_list, inv_val, orders). Since
InvVal(k) is strictly decreasing in \(k\), Brent's method always converges on
the bracket \([0.0001, 1000]\).
Exchange curve generation¶
The exchange curve is traced at 80 log-spaced k values:
Log-spacing gives high resolution near \(k = 1\) (the natural EOQ operating point) while capturing extreme compression and expansion.
The K-Curve write-back¶
After computing the optimal K-Curve EOQ for each item, the runner writes the
demand-weighted average Q* back to master.item.eoq. When the same item is
stocked at multiple sites, the per-site Q values are averaged weighted by site
annual demand:
# files/kcurve/runner.py — _write_back_item_eoq()
avg_q = sum(demand[site] * eoq[site] for site in sites) / sum(demand[site] for site in sites)
UPDATE master.item SET eoq = avg_q WHERE id = item_id
This ensures that MEIO, supply planning, and API reads all use the portfolio-optimised Q rather than the formula-seeded value.
EOQ resolution priority in supply planning:
COALESCE(
eoq_scenario.eoq_qty, -- 1. K-Curve result from pipeline's inventory_scenario
NULLIF(item.eoq, 0.0), -- 2. item.eoq (static / write-back value)
0.0 -- 3. no ordering cost configured
)
Scenario parameters¶
| Parameter | Description |
|---|---|
targetMode |
'k' — use a fixed k; 'budget' — solve for k to match a budget |
targetK |
k value (when targetMode = 'k'; 1.0 = classic EOQ) |
targetBudget |
Target cycle stock value in currency (when targetMode = 'budget') |
ocMult |
Ordering cost multiplier — scales all \(S_i\) before EOQ computation |
hrMult |
Holding rate multiplier — scales all \(h_i\) before EOQ computation |
Multipliers enable what-if analysis without touching master data:
- ocMult = 0.5 simulates e-procurement halving the ordering cost
- hrMult = 1.5 models higher capital cost or insurance premium
Demand sourcing priority¶
The K-Curve runner resolves demand in this order:
forecast_netting(ClickHouse, pipeline-scoped) — total forward-looking demand: statistical + maintenance + causal forecasts. Annualized from future horizon weeks only (is_past = FALSE). Indirect demand (upstream warehouse pull) is added on top.demand_actuals(PostgreSQL, 52-week rolling) — used only when no netting rows exist for the pipeline (e.g. forecast step not yet run).
Cost parameter resolution¶
For each (item, site) pair, unit price and costs are resolved in priority order:
item_location.price/item_location.cost_price(site-specific override)item.price/item.cost(item-level default)route.unit_cost(replenishment route cost)- Hardcoded default: price = 1.0
Similarly for ordering cost: item_location.order_cost →
route.order_cost → parameters.order_cost (global default: 50.0).
Holding rate: item_location.holding_rate →
parameters.holding_cost_percentage (global default: 0.20).
Order-Frequency Constrained EOQ¶
Motivation¶
Some suppliers deliver on fixed schedules: weekly, bi-weekly, monthly. In such cases the continuous EOQ formula is not actionable — the system must snap each item's order quantity to a discrete set of allowed frequencies.
How it works¶
Each item can have an order_frequency parameter (resolved via segment
membership, falling back to the global default). The parameter stores a set of
allowed orders-per-year values, e.g. [52, 26, 12, 4] (weekly, bi-weekly,
monthly, quarterly).
Opt-in only
The auto-seeded default order_frequency parameter has an empty
allowed_orders_per_year list. Constraints only become active when a user
creates or edits a parameter set with non-empty frequencies. Pipeline runs
also require constrained: true in the scenario parameters to apply
frequency snapping.
For a given allowed orders-per-year \(f\), the corresponding order quantity is:
The system evaluates total annual cost at each allowed quantity and selects the one with minimum cost:
When the K-Curve model is used with a \(k\) factor, snapping picks the allowed frequency whose unconstrained equivalent \(k\) is closest to the current \(k\), with total annual cost as the tie-break:
# files/kcurve/frequency.py — snap_item_to_frequency
Q_unconstrained = sqrt(2 * S * D / (h * k))
best = min(candidates, key=lambda c: (abs(c["eoq"] - Q_unconstrained), c["total_annual_cost"]))
The constrained flag is set to true only when the snapped Q differs from the
unconstrained Q by more than 0.5 units — if the allowed frequency happens to
coincide with the unconstrained EOQ, the item is treated as unconstrained.
Supported frequency aliases¶
The parameter supports both numeric values (orders per year) and named frequencies:
| Name | Orders/year |
|---|---|
daily |
365 |
weekly |
52 |
bi-weekly / fortnightly |
26 |
monthly |
12 |
bi-monthly |
6 |
quarterly |
4 |
semi-annually |
2 |
annually / yearly |
1 |
Output flags¶
When a frequency constraint is active, the API response includes a
freq_constraint object per item:
{
"param_name": "Standard Replenishment Schedule",
"allowed_orders_per_year": [52, 26, 12, 4],
"selected_orders_per_year": 12,
"constrained": true
}
constrained: true means the frequency constraint changed the quantity
from the raw K-Curve value by more than 0.5 units. When the snapped Q
happens to match the unconstrained result, constrained is false.
Interaction with MOQ and lot multiples¶
After frequency snapping, the resulting \(Q\) is still subject to minimum order quantity and lot multiple constraints (see Supplier Constraints below). This ensures that a snapped frequency quantity (e.g., \(Q = D/12\) for monthly) still respects supplier packaging requirements.
# files/api/main.py — kcurve_eoq_item
snapped_q = _apply_order_constraints(snap["eoq"], it.get("min_qty"), it.get("mult_qty"))
eoq_result["eoq"] = snapped_q
K-Curve constrained mode¶
When the K-Curve workbench has "Apply order-frequency constraints" enabled,
the full constrained exchange curve is built and overlaid on the chart as an
orange stepped trace. The solve returns alternatives — neighbouring feasible
k points — so the user can explore different operating points on the constrained
frontier. Each item also receives freq_options listing every allowed frequency
with its cost and quantity.
Per-item detail in EOQ tab¶
The series detail EOQ tab shows a new "Order Frequency Options" panel listing all allowed frequencies for the item. Orange markers on the cost curve SVG highlight each allowed quantity's position on the total cost curve, with the selected frequency shown in bold.
Configuration¶
Order-frequency parameters are created in Settings → Parameters with type
order_frequency. They can be assigned to segments or set as the global
default. Segment membership is resolved via ClickHouse PIPE_segment_membership.
Supplier Constraints (MOQ and Lot Multiples)¶
Regardless of which lot-sizing method is used, two supplier constraints are applied to every computed raw quantity before it is stored or returned.
Minimum Order Quantity (MOQ)¶
When a supplier enforces a minimum order:
Lot multiple rounding¶
When orders must be placed in multiples (e.g., pallet quantities):
where \(M\) is mult_qty. Rounding is always up to avoid under-ordering.
Combined application¶
Both constraints are applied in sequence:
# files/api/main.py — _apply_order_constraints()
def _apply_order_constraints(Q, min_qty, mult_qty):
if min_qty and min_qty > 0:
Q = max(Q, float(min_qty)) # step 1: enforce MOQ
if mult_qty and mult_qty > 0:
Q = math.ceil(Q / float(mult_qty)) * float(mult_qty) # step 2: round up to multiple
return Q
Example¶
Item with \(Q^* = 78\) units, MOQ = 50, lot multiple = 24:
- MOQ check: \(\max(78, 50) = 78\) (no change — EOQ exceeds MOQ)
- Lot multiple: \(\lceil 78 / 24 \rceil \times 24 = 4 \times 24 = 96\)
Final \(Q = 96\) units per order.
Item with \(Q^* = 30\), MOQ = 50, lot multiple = 24:
- MOQ check: \(\max(30, 50) = 50\)
- Lot multiple: \(\lceil 50 / 24 \rceil \times 24 = 3 \times 24 = 72\)
Final \(Q = 72\) units per order.
Where these values come from¶
min_qty and mult_qty are read from master.route for the item's active
replenishment route. They are applied in all three lot-sizing paths (classic
EOQ, K-Curve, and the individual item endpoint).
Wagner-Whitin Dynamic Lot-Sizing¶
Rich standalone page:
wagner-whitin.html(dark-themed, inline-SVG sawtooth + DP diagrams). The section below is the canonical reference.
When to use¶
Figure: decision flow — Classic EOQ suits steady continuous demand; lumpy/intermittent demand with high CV or zero-demand stretches should use Wagner-Whitin dynamic lot-sizing.
flowchart TD
START["Given a SKU's demand pattern"] --> Q1{"Demand constant<br/>and continuous?"}
Q1 -->|"yes"| Q2{"Supplier enforces<br/>fixed delivery freq?"}
Q1 -->|"no, lumpy / intermittent"| Q3{"CV above 0.5 or extended<br/>zero-demand weeks?"}
Q2 -->|"no"| EOQ["Classic EOQ<br/>Q* = sqrt(2DS/H)"]
Q2 -->|"yes"| FREQ["Order-Frequency<br/>Constrained EOQ"]
Q3 -->|"yes"| WW["Wagner-Whitin<br/>O(T²) DP, globally optimal"]
Q3 -->|"no, moderate"| EOQ
EOQ --> KC{"Portfolio budget or<br/>order-frequency constraint?"}
KC -->|"yes"| KCURVE["K-Curve<br/>(portfolio EOQ)"]
KC -->|"no"| DONE["Use Q* per item"]
Classic EOQ assumes demand is constant and continuous. Many industrial and MRO items have lumpy or intermittent demand — large spikes separated by zero-demand periods. In such cases, the EOQ may produce many small orders into periods with no demand, carrying inventory unnecessarily.
Wagner-Whitin (1958) finds the globally optimal lot schedule for a finite horizon of discrete demand periods. It minimizes total ordering plus holding cost exactly, without any assumption of constant demand.
Problem formulation¶
Given:
- \(T\) planning periods (weeks)
- \(d_t\) = demand in period \(t\), \(t = 1, \ldots, T\)
- \(S\) = fixed ordering cost per order
- \(h\) = holding cost per unit per period (\(= p \cdot i / 52\) for weekly periods)
- No backorders allowed (demand must be satisfied in or before the period)
Find the lot schedule \(q_1, q_2, \ldots, q_T \geq 0\) minimizing:
where \(I_t\) is the on-hand inventory at the end of period \(t\) (the units
carried into the next period). This end-of-period convention is what the DP
minimises; the reported total_holding_cost in the API equals this objective.
Key insight (zero-inventory property): At optimality, an order is placed in period \(j\) only if inventory is zero at the start of that period. This means every order exactly covers demand for a contiguous block of future periods — never a fractional amount.
Graphical interpretation — the sawtooth¶
Where Classic EOQ is visualised by the Wilson cost curve (ordering cost falling, holding cost rising, total cost a U-shape with minimum at \(Q^*\)), Wagner-Whitin is visualised by the inventory sawtooth profile:
inv
↑ +100 +200
│ █│ █│
│ █│\ █│\
│ █│ \ █│ \____100____
│ █│ \ █│ \ \
│_________█│___\_____________█│___________\__\_______ → week
1 2 3 4 5 6
demand: 100 0 100 0 100 0
order : 100 200
- Vertical green spikes = order arrivals (the lot lands at the start of the week).
- Downward slopes = demand consuming inventory.
- The sawtooth always returns to zero before the next order — the zero-inventory property made visible. Contrast with EOQ's smooth, equal-height sawtooth under constant demand.
The Wagner-Whitin workbench renders this profile per-item as an interactive SVG
(WwSawtoothChart in WagnerWhitinWorkbench.jsx); hover any row to see it.
O(T²) dynamic programming algorithm¶
Define \(f(t)\) as the minimum total cost to satisfy all demand in periods \(1, \ldots, t\).
Recursion:
The term \(h \sum_{i=j}^{t} (i-j)\, d_i\) is the holding cost when an order placed at the start of period \(j\) covers demand through period \(t\): demand \(d_j\) is carried 0 periods, \(d_{j+1}\) is carried 1 period, …, \(d_t\) is carried \(t - j\) periods. (Equivalently, \(\sum_{i=j}^{t}(i-j)d_i\) is the sum of end-of-period inventories over weeks \(j\ldots t\) for that single order.)
Base case: \(f(0) = 0\)
After computing \(f(T)\), backtrack via order_at[t] (the best \(j\) for each \(t\))
to reconstruct the optimal lot schedule.
Python implementation¶
# files/kcurve/wagner_whitin.py — wagner_whitin()
def wagner_whitin(demands: list, S: float, h: float) -> list:
"""O(T^2) Wagner-Whitin DP.
Returns lot_sizes[t]: lot_sizes[t] > 0 → order that size at period t+1.
"""
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
# Backtrack optimal lot schedule
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 cost conversion:
Worked example — full DP table¶
Six-week lumpy demand [100, 0, 100, 0, 100, 0], \(S = €100\), \(h = €0.30\)/unit/week (e.g. \(p = €52\), \(i = 30\%/\text{yr}\)). Annualised demand ≈ \(300 \times 52/6 = 2600\) units.
For each \(t\) (cover weeks \(1\ldots t\)) we evaluate every candidate order period \(j \in [1, t]\). Candidate cost \(= f(j-1) + S + h \cdot \text{hold}\), where \(\text{hold} = \sum_{i=j}^{t}(i-j)\,d_i\).
| \(t\) | \(j\) | \(\text{hold}\) | \(f(j{-}1)\) | \(+S\) | \(+h\cdot\text{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 |
Backtracking from \(f(6)=260\), order_at[6]=3 → an order in week 3 covers
weeks 3..6 = \(100+0+100+0 = 200\). Then \(t=2\), order_at[2]=1 → an order in
week 1 covers weeks 1..2 = \(100+0 = 100\). Then \(t=0\), done.
Optimal schedule: order 100 in week 1, order 200 in week 3.
End-of-period inventory: [0, 0, 100, 100, 0, 0] → sum \(= 200\) unit-weeks
→ holding \(= 200 \times 0.30 = €60\). Ordering \(= 2 \times €100 = €200\).
Total cost \(= €260\).
| Policy | Orders | Setup cost | Holding cost | Total |
|---|---|---|---|---|
| Lot-for-lot (order 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 avoids a third €100 setup and avoids carrying all 300 units from week 1. Saving: €40 (13 %) vs lot-for-lot.
The per-item eoq field — what WW "determines"¶
Wagner-Whitin produces a dynamic schedule (different lot each order), not a single order quantity. To give MEIO and supply a single, comparable batch size — the same role Classic EOQ's \(Q^*\) plays — the result exposes an annualised average order quantity:
For the example: \(n_{\text{orders}}=2\), \(T=6\) → \(\text{orders\_per\_year}=17.33\),
\(D_{\text{annual}}=2600\) → eoq ≈ 150 (between the two actual lots 100 and
200, weighted by their annualised frequency). This is the value downstream
consumers read; avg_lot_size (= 150 here, \(300/2\)) is the raw per-horizon
average kept for display.
When demand is continuous and stable, \(n_{\text{orders}}\) converges so that
eoq → the Classic EOQ \(Q^* = \sqrt{2DS/h_{\text{annual}}}\).
EOQ overrides — shared table¶
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 lot-sizing methods — there is no separate WW override store.
Precedence (highest first):
interact_io_overrides.eoq_override(scenario_id = 0) — user explicit- Computed WW
eoq(annualised average order qty) item.eoq(write-back from the last run; MEIO fallback)0
The endpoint annotates each item with eoq (post-override) and eoq_override
(the raw override value, null if none), exactly like /kcurve/eoq and
/kcurve/solve.
Downstream propagation — MEIO & supply¶
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_scenarioCTE) readsresults->items[].eoqwith theinteract_io_overridesoverride taking precedence; falls back toitem.eoq. No SQL change was required — it consumes WW results identically to K-Curve results. - Supply (
supply_runner.py,eoqCTE) does the same: the WWeoqdrivesmin_qtyper SKU, override first, respectingmult_qty/max_qty. item.eoqwrite-back: the runner writes the demand-weighted average WWeoqback tomaster.item.eoq, so the MEIO fallback path and reports reflect WW — the same behaviour K-Curve has.
In short: 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.
Complexity and horizon¶
The DP is \(O(T^2)\) per item. With \(T \le 52\) weeks and typical portfolios of a
few thousand SKUs, a full WW run completes in seconds. The endpoint defaults to
the last 24 weeks of demand_actuals (configurable up to 52 via
?periods=N); the pipeline runner honours parameters.periods (default 24).
Zero-demand weeks are included in the DP — they affect holding cost and the backtrack, and (per the zero-inventory property) never trigger an order on their own. Note that a leading zero-demand week can force one extra setup under the textbook DP; in practice this is negligible over a 24-week horizon.
When Wagner-Whitin outperforms EOQ¶
Wagner-Whitin is superior when:
- Demand has high variability (CV > 0.5)
- There are extended zero-demand periods (intermittent items)
- The horizon is short to medium (≤ 52 weeks — \(O(T^2)\) is tractable)
- Ordering cost is high relative to unit value (large \(S/h\) ratio)
For continuous, stable demand, Wagner-Whitin converges to the same result as EOQ.
API & UI¶
- Endpoint:
GET /api/kcurve/wagner-whitin?pipeline_id=...&periods=24→ per-itemeoq,eoq_override,orders_per_year,n_orders,avg_lot_size,schedule[], plus portfoliototals. - Pipeline step:
eoqstep branches ontag_code(run_pipeline.py). - UI:
WagnerWhitinWorkbench(K-Curve modal,tag_code='wagner_whitin') — KPIs, per-item table with an EOQ column, expandable weekly schedule, and the sawtooth inventory chart on row hover.
EOQ with MEIO Safety Stock Snapping¶
When MEIO configuration has consider_eoq = true (the default), the greedy
optimiser snaps safety-stock buffer increments to multiples of the item's EOQ:
This aligns safety stock top-ups with full order batches, avoiding partial
orders and reducing total ordering cost. The EOQ used here comes from
item.eoq (write-back from the last K-Curve run, or the formula seed).
Modifications for Special Situations¶
EOQ with quantity discounts¶
When suppliers offer price breaks at thresholds \(Q_1 < Q_2 < \ldots\), the unit price \(p\) is piecewise constant. The optimal procedure:
- Compute \(Q^*\) at each price tier using \(h = p_{\text{tier}} \cdot i\).
- For each tier, check whether \(Q^*\) falls within that tier's quantity range.
- If it does, compute \(TC\) at that feasible \(Q^*\).
- If it does not, compute \(TC\) at the minimum quantity of the next cheaper tier.
- Select the quantity with lowest \(TC\).
Mirabelle does not implement quantity-discount EOQ automatically. Planners can
approximate it by setting order_cost overrides per item-site to reflect
negotiated pricing tiers.
EOQ with capacity constraints¶
When storage capacity caps on-hand inventory to \(Q_{\max}\):
The item_location table can hold maximum stock levels enforced by the supply
runner.
Power-of-two rounding (Roundy 1985)¶
Rounding all order quantities to the nearest power-of-two weeks of supply
within a factor of \(\sqrt{2}\) of \(Q^*\) keeps total cost within 6 % of
optimal and enables synchronized replenishment cycles. Mirabelle does not
enforce this by default; planners can align cycle frequencies via targetK.
Output Columns¶
After running any EOQ pipeline step, the following columns appear per item in
inventory_scenario.results and in the API/UI:
| Column | Formula | Description |
|---|---|---|
eoq |
\(Q^*\) (rounded to 2 dp) | Order quantity in units |
cycle_stock_value |
\(Q/2 \times p\) | Average cycle stock value |
orders_per_year |
\(D / Q\) | Purchase orders per year |
annual_holding_cost |
\((Q/2) \times p \times i\) | Annual cycle stock carrying cost |
annual_ordering_cost |
\((D/Q) \times S\) | Annual ordering cost |
total_annual_cost |
sum of above two | Total annual lot-sizing cost |
Wagner-Whitin additionally returns:
| Column | Description |
|---|---|
eoq |
Annualised average order qty \(= D_{\text{annual}} / (n_{\text{orders}} \cdot 52 / T)\) — the field MEIO/supply consume (override applied) |
eoq_override |
The raw interact_io_overrides value, null if none |
orders_per_year |
\(n_{\text{orders}} \times 52 / T\) (annualised) |
schedule |
Array of {week, demand, lot_size, inventory_after, order} |
n_orders |
Number of orders in the horizon |
avg_lot_size |
Total horizon demand ÷ n_orders (raw per-horizon average) |
total_holding_cost |
Accumulated end-of-period holding cost across all periods |
total_ordering_cost |
Accumulated ordering cost across all periods |
total_cost |
total_holding_cost + total_ordering_cost |
Method Comparison¶
| Criterion | Classic EOQ | K-Curve EOQ | Freq-Constrained | Wagner-Whitin |
|---|---|---|---|---|
| Demand assumption | Constant | Constant | Constant | Arbitrary (discrete) |
| Horizon | Infinite | Infinite | Infinite | Finite (T weeks) |
| Optimality | Per-item optimal | Portfolio Pareto-efficient | Cost-minimizing 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 \cdot P)\) | \(O(N \cdot F)\) | \(O(N \cdot T^2)\) |
| Best for | Stable, high-volume | Budget negotiation | Fixed delivery schedules | Lumpy / intermittent |
| API endpoint | /kcurve/eoq |
/kcurve/solve |
/kcurve/solve |
/kcurve/wagner-whitin |
\(N\) = number of SKUs, \(P\) = number of k-points on curve (80), \(F\) = number of allowed frequencies, \(T\) = planning horizon in weeks.
Interpreting EOQ in the UI¶
A planner reviewing an item in the IO tab sees:
- Cycle stock — driven by EOQ (order batch ÷ 2 × price)
- Safety stock — driven by MEIO committed buffer
- Total inventory investment — sum of both components
The K-Curve workbench (Inventory Optimisation tab) shows all three
lot-sizing methods in separate tabs, with shared sensitivity sliders for ocMult
and hrMult. The exchange curve chart visualises the portfolio trade-off
between inventory investment and order frequency interactively.
Cross-references: K-Curve · Safety Stock · MEIO Theory · Supply Planning