Skip to content

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\).

\[ TC(Q) = \underbrace{\frac{D}{Q} \cdot S}_{\text{ordering cost/year}} + \underbrace{\frac{Q}{2} \cdot h}_{\text{holding cost/year}} \]

Setting \(\frac{d\,TC}{d\,Q} = 0\):

\[ -\frac{DS}{Q^2} + \frac{h}{2} = 0 \quad\Rightarrow\quad \boxed{Q^* = \sqrt{\frac{2\,D\,S}{h}} = \sqrt{\frac{2\,D\,S}{p\,i}}} \]

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]
\[ TC(Q^*) = \sqrt{2\,D\,S\,h} \]

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
\[ Q^* = \sqrt{\frac{2 \times 1200 \times 80}{5}} = \sqrt{38\,400} \approx 196\;\text{units} \]
\[ \text{Orders per year} = \frac{1200}{196} \approx 6.1 \qquad \text{Cycle time} = \frac{196}{1200} \times 52 \approx 8.5\;\text{weeks} \]
\[ TC(196) = \frac{1200}{196}\times 80 + \frac{196}{2}\times 5 = 490 + 490 = €980/\text{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:

\[ ROP = \bar{d}_{\text{weekly}} \times L_{\text{weeks}} + SS \]

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:

\[ D_{\text{annual}} = \bar{d}_{\text{weekly}} \times 52 \]

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:

\[ Q(k) = \sqrt{\frac{2\,D\,S}{k\,h}} \]
  • \(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:

\[ \text{InvVal}(k) = \sum_{i=1}^N \frac{Q_i(k)}{2} p_i = \frac{A}{\sqrt{k}} \qquad \text{Orders}(k) = \sum_{i=1}^N \frac{D_i}{Q_i(k)} = B\sqrt{k} \]

where:

\[ A = \sum_{i=1}^N \sqrt{\frac{D_i S_i p_i}{2 h_i}}, \quad B = \sum_{i=1}^N \sqrt{\frac{D_i p_i h_i}{2 S_i}} \]

Eliminating \(k\) gives the hyperbolic exchange curve:

\[ \text{InvVal} \times \text{Orders} = A \cdot B = \text{const} \]

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:

k_vals = np.logspace(-3, 1.5, 80)   # k ∈ [0.001 … 31.6]

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:

  1. 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.
  2. 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:

  1. item_location.price / item_location.cost_price (site-specific override)
  2. item.price / item.cost (item-level default)
  3. route.unit_cost (replenishment route cost)
  4. Hardcoded default: price = 1.0

Similarly for ordering cost: item_location.order_costroute.order_costparameters.order_cost (global default: 50.0). Holding rate: item_location.holding_rateparameters.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:

\[ Q_f = \frac{D}{f} \]

The system evaluates total annual cost at each allowed quantity and selects the one with minimum cost:

\[ Q^*_{\text{freq}} = \arg\min_{f \in \mathcal{F}} TC\!\left(\frac{D}{f}\right) = \arg\min_{f \in \mathcal{F}} \left[\frac{D \cdot f \cdot S}{D} + \frac{D \cdot h}{2f}\right] = \arg\min_{f \in \mathcal{F}} \left[f \cdot S + \frac{D \cdot h}{2f}\right] \]

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:

\[ Q_{\text{eff}} = \max(Q_{\text{raw}},\; MOQ) \]

Lot multiple rounding

When orders must be placed in multiples (e.g., pallet quantities):

\[ Q_{\text{eff}} = \left\lceil \frac{Q_{\text{raw}}}{M} \right\rceil \times M \]

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:

  1. MOQ check: \(\max(78, 50) = 78\) (no change — EOQ exceeds MOQ)
  2. 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:

  1. MOQ check: \(\max(30, 50) = 50\)
  2. 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:

\[ \min \sum_{t: q_t > 0} S + h \sum_{t=1}^T I_t \]

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:

\[ f(t) = \min_{1 \leq j \leq t} \left[ f(j-1) + S + h \sum_{i=j}^{t} (i - j)\, d_i \right] \]

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:

\[ h_{\text{weekly}} = \frac{p \cdot i}{52} \]

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:

\[ \text{orders\_per\_year} = \frac{n_{\text{orders}} \times 52}{T} \qquad \text{eoq} = \frac{D_{\text{annual}}}{\text{orders\_per\_year}} \]

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):

  1. interact_io_overrides.eoq_override (scenario_id = 0) — user explicit
  2. Computed WW eoq (annualised average order qty)
  3. item.eoq (write-back from the last run; MEIO fallback)
  4. 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_scenario CTE) reads results->items[].eoq with the interact_io_overrides override taking precedence; falls back to item.eoq. No SQL change was required — it consumes WW results identically to K-Curve results.
  • Supply (supply_runner.py, eoq CTE) does the same: the WW eoq drives 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 — 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-item eoq, eoq_override, orders_per_year, n_orders, avg_lot_size, schedule[], plus portfolio totals.
  • Pipeline step: eoq step branches on tag_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:

\[ b_{\text{snapped}} = \left\lceil \frac{b_{\text{computed}}}{Q^*} \right\rceil \times Q^* \]

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:

  1. Compute \(Q^*\) at each price tier using \(h = p_{\text{tier}} \cdot i\).
  2. For each tier, check whether \(Q^*\) falls within that tier's quantity range.
  3. If it does, compute \(TC\) at that feasible \(Q^*\).
  4. If it does not, compute \(TC\) at the minimum quantity of the next cheaper tier.
  5. 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}\):

\[ Q^* = \min\!\left(\sqrt{\frac{2DS}{h}},\; Q_{\max}\right) \]

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