K-Curve (Exchange Curve)¶
Chapter summary
The K-Curve (also called the exchange curve or service-investment curve) plots the trade-off between inventory investment and achieved service level across a portfolio of SKUs. This chapter derives the curve from first principles, explains the Mirabelle implementation, and shows how planners use it for budget negotiations and scenario analysis.
What is the K-Curve?¶
Figure: the hyperbolic exchange curve — InvVal × Orders = A·B = const. k<1 shifts right (more inventory, fewer orders), k=1 is the EOQ point, k>1 shifts left (less inventory, more orders). Any point on the curve is Pareto-efficient; inside is dominated.
flowchart LR
K1["k < 1<br/>larger Q, fewer orders<br/>↑ InvVal, ↓ Orders"] --- KEQ["k = 1<br/>each item at its EOQ<br/>min total cost"]
KEQ --- K2["k > 1<br/>smaller Q, more orders<br/>↓ InvVal, ↑ Orders"]
K1 -.->|"inv_val · orders = A·B"| K2
KEQ --> OPT["Pareto-efficient frontier<br/>(hyperbolic exchange curve)"]
The K-Curve answers the question: "If we invest \(X\) in cycle stock, how many purchase orders per year will we need?" Equivalently, "If we want to cut our order frequency in half, how much more inventory must we carry?"
For a single item the Economic Order Quantity (EOQ) model says:
There is no free parameter — \(Q^*\) is uniquely determined by demand \(D\), ordering cost \(S\), and holding cost rate \(H\). But in practice, companies may choose to deviate from \(Q^*\) by applying a scalar factor \(k\) to the holding cost:
When \(k = 1\) we are at EOQ; \(k < 1\) means we order more often (lower inventory, higher ordering cost); \(k > 1\) means we order less often (higher inventory, lower ordering cost).
Applying the same \(k\) to every SKU in a portfolio and tracing the resulting portfolio inventory value and order frequency as \(k\) varies from 0 to \(\infty\) traces out the K-Curve (exchange curve).
Mathematical derivation¶
Single-item metrics at factor k¶
For a single item with annual demand \(D\), ordering cost \(S\), unit price \(p\), and holding rate \(h\) (fraction of price per year):
Portfolio aggregation¶
For a portfolio of \(N\) items, aggregate over all items at the same \(k\):
Let \(A = \sum_i \sqrt{D_i S_i p_i / (2h_i)}\) and \(B = \sum_i \sqrt{D_i p_i h_i / (2S_i)}\). Then:
Eliminating \(k\):
This is the hyperbolic exchange curve: the product of inventory investment and order frequency is constant across the entire feasible frontier. Moving along the curve reduces one at the expense of the other; moving away from the curve is sub-optimal.
Managerial interpretation
The K-Curve shows that you cannot simultaneously reduce both inventory and order frequency. Any policy below the curve (inside the hyperbola) is dominated. Any policy on the curve is Pareto-efficient.
Implementation in Mirabelle¶
The _kcurve_metrics function¶
files/kcurve/runner.py implements the core calculation in _kcurve_metrics:
def _kcurve_metrics(k: float, items: list) -> tuple:
"""Return (Q_list, inv_val, orders_per_year) for a given k factor."""
Q_list, inv_val, orders = [], 0.0, 0.0
for it in items:
D = it["annual_demand"]
S = it["order_cost"]
h = it["price"] * it["holding_rate"]
if D > 0 and S > 0 and h > 0 and k > 0:
Q = math.sqrt(2 * S * D / (h * k))
else:
Q = max(D, 1.0)
Q_list.append(Q)
inv_val += Q * it["price"] / 2
if Q > 0:
orders += D / Q
return Q_list, inv_val, orders
Note the formula matches the derivation exactly: \(Q(k) = \sqrt{2SD/(hk)}\) where \(h = p \cdot \text{holding\_rate}\).
Curve generation — 80 log-spaced points¶
The exchange curve is traced by evaluating _kcurve_metrics at 80 values of
\(k\) spaced logarithmically between \(10^{-3}\) and \(10^{1.5} \approx 31.6\):
k_vals = np.logspace(-3, 1.5, 80).tolist()
curve = []
for kv in k_vals:
_, iv, ord_ = _kcurve_metrics(kv, items)
curve.append({"k": round(kv, 6), "inv_val": round(iv, 2), "orders": round(ord_, 2)})
Log-spacing ensures good resolution near \(k = 1\) (the EOQ operating point) while also capturing the asymptotic extremes.
Budget-mode and orders-mode solving (Brent's method)¶
When the scenario parameter target_mode = 'budget', the runner solves for
the \(k\) that achieves a specified inventory value target using Brent's
root-finding method:
When target_mode = 'orders', the same approach solves for a target number of
orders per year:
Brent's method is guaranteed to converge when the bracket \([0.0001, 1000]\)
straddles the root — which it always does because InvVal(k) is strictly
decreasing and Orders(k) is strictly increasing in \(k\).
In Explore mode, no solver is needed — the user clicks a point on the curve and the corresponding \(k\) is read directly from the curve data.
Demand sourcing¶
The items loaded for K-Curve computation use the most accurate demand available for the current pipeline:
Priority 1 — Pipeline-scoped forecast netting:
The forecast_netting table combines statistical forecast, maintenance
forecast, and causal forecast into a total forward-looking demand
(total_forecast_qty). Annual demand is annualized from the future horizon:
Indirect demand (from upstream warehouse aggregation) is added on top.
Priority 2 — Historical actuals (52-week rolling):
When no netting rows exist for the pipeline (e.g., forecasting has not yet
been run), the runner falls back to annualizing the last 52 weeks of
demand_actuals.
Cost data hierarchy¶
For each (item, site) pair, unit price and costs are resolved in priority order:
item_site.priceoritem_site.cost_price(site-specific override)item.priceoritem.cost(item-level default)route.unit_cost(replenishment route cost)- Hardcoded default: price = 1.0
Similarly for ordering cost: item_site.order_cost → route.order_cost →
parameters.order_cost (default: 50.0). Holding rate defaults to 20 %
(holding_cost_percentage in parameters).
Per-item EOQ output¶
At the operating point \(k\), each item receives a computed EOQ:
| Column | Formula |
|---|---|
eoq |
\(Q = D / f_{\text{chosen}}\) (frequency-snapped, not Wilson formula) |
cycle_stock_value |
\(Q/2 \times p\) |
orders_per_year |
\(D/Q\) (= chosen frequency \(f\)) |
annual_holding_cost |
\((Q/2) \times p \times h\) |
annual_ordering_cost |
\((D/Q) \times S\) |
total_annual_cost |
sum of above two |
freq_constraint |
{param_name, selected_freq_label, selected_orders_per_year, allowed_orders_per_year, constrained} |
freq_options |
Array of all allowed frequencies with per-frequency Q, cycle stock, cost, k-equivalent, and is_selected flag |
These per-item EOQs are stored in inventory_scenario.results (JSONB) and
consumed by the supply planning module when generating planned orders.
K-Curve EOQ takes priority over static item EOQ
When a pipeline is linked to an inventory scenario that has K-Curve results,
the supply planning engine reads EOQ from inventory_scenario.results (the
portfolio-optimised value) via a CTE join on scenario_pipeline.
It falls back to item.eoq (the static field seeded from the simple formula)
only when no scenario is linked or the item has no K-Curve row.
-- Resolved EOQ priority in supply planning:
-- 1. K-Curve result from pipeline's linked inventory_scenario
-- 2. item.eoq (static fallback)
-- 3. 0 (no ordering cost configured)
COALESCE(eoq_scenario.eoq_qty, NULLIF(item.eoq, 0.0), 0.0)
The K-Curve screen also displays an info note explaining this distinction: "Classic EOQ optimises each item independently. MEIO and Supply Planning use the K-Curve EOQ, which jointly optimises the full portfolio under a shared capital constraint."
Key points on the curve¶
Figure: comparison of the three k operating regimes — how Q(k), InvVal, and Orders move as k sweeps from <1 to >1; the same k multiplies every SKU in the portfolio.
flowchart TD
K["Shared scalar k applied to every SKU<br/>Q(k) = sqrt(2DS / kH)"] --> LO["k < 1"]
K --> EQ["k = 1"]
K --> HI["k > 1"]
LO --> LO1["Larger batches Q ↑"]
LO1 --> LO2["InvVal ↑ (more cycle stock)"]
LO1 --> LO3["Orders/yr ↓ (fewer POs)"]
EQ --> EQ1["Each SKU at individual EOQ"]
EQ1 --> EQ2["Minimises total annual cost"]
EQ1 --> EQ3["Natural analysis start point"]
HI --> HI1["Smaller batches Q ↓"]
HI1 --> HI2["InvVal ↓ (leaner stock)"]
HI1 --> HI3["Orders/yr ↑ (more POs)"]
The EOQ point (\(k = 1\))¶
At \(k = 1\), every item operates at its individual EOQ. This minimizes total cost for the portfolio. It is the natural starting point for any analysis.
The diminishing-returns zone¶
Moving from \(k = 1\) toward \(k \to \infty\) increases inventory while reducing orders. The marginal cost of the next order saved (in inventory terms) rises steeply:
This second derivative is always negative — each additional order saved costs more inventory than the previous one. The curve is convex.
Reading the knee¶
Figure: the knee of the K-Curve is the point of maximum curvature — highest marginal return on inventory investment; beyond it, large inventory increases yield diminishing order-frequency reductions.
quadrantChart
title K-Curve — InvVal vs Orders (knee interpretation)
x-axis "Few orders/yr (high k)" --> "Many orders/yr (low k)"
y-axis "Low InvVal" --> "High InvVal"
quadrant-1 "Over-invested<br/>(past knee, k<1)"
quadrant-2 "Lean but busy<br/>(k>1)"
quadrant-3 "Starved"
quadrant-4 "Diminishing returns"
"k=1 EOQ point": [0.50, 0.50]
"knee (max curvature)": [0.40, 0.62]
"k<1 (more stock, fewer orders)": [0.20, 0.85]
"k>1 (less stock, more orders)": [0.75, 0.30]
The "knee" of the K-Curve is the region of maximum curvature — the point of highest marginal return on investment. Beyond the knee, large increases in inventory produce diminishing reductions in ordering frequency (or, for service-level curves, diminishing fill-rate improvements).
Mirabelle's K-Curve chart in the frontend marks the current operating point and allows the planner to slide along the curve interactively.
Scenario parameters¶
The inventory_scenario table stores K-Curve parameters in a JSONB parameters
column:
| Parameter | Description |
|---|---|
target_mode |
'explore' — click a point on the curve (default); 'budget' — solve for k matching an inventory budget; 'orders' — solve for k matching a target orders/year |
target_budget |
Total cycle stock value target in currency units (used when target_mode = 'budget') |
target_orders |
Target orders per year (used when target_mode = 'orders') |
k |
Resolved k factor (output, not input — computed by the solver from the target) |
ordering_cost_mult |
Ordering cost multiplier (scales all \(S_i\) values) |
holding_rate_mult |
Holding rate multiplier (scales all \(h_i\) values) |
frequency_groups |
Scenario-level frequency group definitions (segment-based constraints) |
Results are persisted back to inventory_scenario.results and tagged with
pipeline_id so the supply module and UI retrieve the pipeline-appropriate
EOQ values.
Target modes¶
The exchange curve tab offers three target modes:
| Mode | UI | Behaviour |
|---|---|---|
| Explore | Hint text only | Default. The curve is plotted freely; the user clicks a point to select an operating point. No target input. |
| Inv. Budget | Log-scale slider | The user drags a slider bounded by the curve's min/max inventory value. A brentq solver finds the \(k\) that produces that inventory value. Auto-solves on slider release (300 ms debounce). |
| Orders / Yr | Log-scale slider | The user drags a slider bounded by the curve's min/max orders per year. A brentq solver finds the \(k\) that produces that order frequency. Same debounce. |
The K factor is never a user input — it is an internal curve parameterisation. The user selects a point on the curve (by clicking or via a slider), and the solver computes the corresponding \(k\) internally. The resolved \(k\) is shown as informational metadata in the KPI cards.
Pipeline integration¶
The K-Curve / EOQ step is triggered by:
Which calls run_eoq_for_pipeline(pipeline_id) in files/kcurve/runner.py.
The linked inventory_scenario_id is resolved from scenario_pipeline.
The scenario tag 'kcurve' in inventory_scenario_tag identifies this as an
EOQ scenario (as opposed to a safety-stock or reorder-point scenario).
Using the K-Curve for budget negotiations¶
A common management conversation goes:
"Our procurement team can handle at most 5 000 purchase orders per year. How much inventory does that require?"
Procedure:
- Open the Exchange Curve tab — the curve is plotted automatically in Explore mode.
- Switch to Orders / Yr mode and drag the slider to 5 000 — the solver finds the \(k\) that produces exactly 5 000 orders/year.
- Read the resulting cycle stock value from the KPI card. The "Selected" star marker on the curve shows the operating point.
- Alternatively, set an inventory budget in Inv. Budget mode and read the implied orders/year.
- Use the curve visualization to show management the trade-off: cutting orders from 8 000/year to 5 000/year may require 60 % more inventory (illustrative — actual numbers depend on your portfolio).
The Item Frequencies tab shows the per-item breakdown: for each SKU, the chosen frequency (e.g., "Monthly"), the EOQ (= demand / chosen frequency), and all possible frequencies with their Q, cycle stock, and total cost. Expand any row to see the full frequency option table with k-equivalents.
Relationship to MEIO safety stock¶
The K-Curve determines cycle stock (order quantity / 2). MEIO determines safety stock (committed buffer). Total inventory investment is:
Both components are optimized independently in Mirabelle:
- K-Curve / EOQ: minimizes total ordering + holding cost for cycle stock.
- MEIO: minimizes total safety-stock investment subject to service-level targets.
The EOQ computed by the K-Curve runner is written to item.eoq and used by
MEIO when consider_eoq = true — the greedy loop snaps buffer increments
to EOQ multiples, ensuring safety stock tops up neatly to the next order
quantity boundary.
Order-frequency constraints¶
Motivation¶
In practice, items are often constrained to order at certain allowed frequencies — weekly, monthly, quarterly, or annually — due to supplier contracts, container sizes, or planning calendar constraints. The unconstrained EOQ model may suggest an order quantity that implies an intermediate frequency (e.g., 8.3 orders/year), which is infeasible.
Mirabelle implements snap-to-grid K points: the K-Curve model is preserved, but for each candidate \(k\), every item's \(Q(k)\) is snapped to the nearest allowed frequency that minimises total annual cost. This produces a stepped constrained exchange curve that replaces the smooth hyperbola with a piecewise-linear frontier.
Configuration: the order_frequency parameter¶
The order_frequency parameter type is registered in config_parameters and
follows the standard segment-based override mechanism:
| Level | Source | Priority |
|---|---|---|
| Segment override | config_parameter_segment joined with PIPE_segment_membership |
1 (highest) |
| Default parameter | config_parameters where is_default = TRUE |
2 |
The parameter value is a JSON object:
Constraints are always active
The K-Curve algorithm always snaps each item to a frequency. When no
explicit frequency groups or DB-based order_frequency parameters are
configured, the system falls back to all frequency definitions (daily,
weekly, bi-weekly, monthly, bi-monthly, quarterly, semi-annually, annually)
as the allowed set for every item. This ensures the Item Frequencies tab
always shows both the chosen frequency and the possible frequencies for
every item — the core output of the K-Curve algorithm.
The unconstrained (smooth Wilson) curve is still shown for exploration; the constrained (stepped) curve is always overlaid as an orange trace.
String frequency names resolve via the FREQ_NAMES map:
| Name | Orders/year |
|---|---|
| daily | 365 |
| weekly | 52 |
| bi-weekly | 26 |
| monthly | 12 |
| bi-monthly | 6 |
| quarterly | 4 |
| semi-annually | 2 |
| annually | 1 |
Numeric values are also accepted (e.g., 6.5 for 6.5 orders/year).
Snap-to-grid algorithm¶
The snap-to-grid algorithm is the core of the K-Curve method. The input is the set of available frequencies per item (constraints), and the output is the chosen frequency for each item — from which the EOQ is simply:
This is not the Wilson EOQ formula (\(\sqrt{2SD/H}\)). The Wilson formula is used only to draw the unconstrained exploration curve and to determine which frequency to snap to at a given \(k\).
For each item \(i\) and candidate \(k\):
- Compute the unconstrained \(Q(k) = \sqrt{2S_iD_i / (k\,p_i\,h_i)}\).
- For each allowed frequency \(f_j\), compute \(Q_j = D_i / f_j\).
- Snap: choose the \(Q_j\) whose unconstrained equivalent \(k\) is closest to the candidate \(k\): $$ k_j = \frac{2 S_i f_j^2}{p_i h_i D_i} $$ Tie-break by lowest total annual cost \(TC(Q_j) = S_i f_j + (Q_j/2) p_i h_i\).
- Mark
constrained = Trueonly when the snapped \(Q_j\) differs from the unconstrained \(Q(k)\) by more than 0.5 units — if the snap matches the unconstrained result, the item is effectively unconstrained. - Return \(Q_j\) as the constrained EOQ for item \(i\) at this \(k\).
Constrained exchange curve¶
The constrained curve is built by:
- Starting with the same 80 log-spaced \(k\) grid as the unconstrained curve.
- Adding every threshold \(k\) where an item's unconstrained EOQ equals one of its allowed quantities (\(k_j\) values from above). At these thresholds the snap-to-grid assignment can change.
- Evaluating
constrained_kcurve_metricsat each candidate \(k\) (snapping all items at each point). - Deduplicating near-identical points to produce a clean stepped curve.
The result is returned as {"curve": [{k, inv_val, orders}], "target_metrics": ...}.
When target_k is provided, target_metrics contains the constrained metrics
at that exact k, avoiding a separate constrained_kcurve_metrics call.
API endpoints¶
| Endpoint | Parameters | Behaviour |
|---|---|---|
GET /api/kcurve/curve |
constrained: bool (default false), frequency_groups: JSON |
Returns curve (unconstrained). When constrained=true or frequency_groups provided, also returns constrained_curve. Falls back to default frequency definitions when no explicit constraints exist. |
POST /api/kcurve/solve |
k, target_inv_val, or target_orders_per_year; constrained: bool; frequency_groups: JSON; optional scenario_id + persist |
Solves for k given the target, snaps each item to its best frequency, returns per-item freq_constraint (with selected_freq_label) and freq_options. Always falls back to default definitions. When scenario_id+persist are passed (the frontend sends this on an explicit curve click, not exploratory slider drags), the solve atomically merges items[] into scen_inventory_scenario.results for the active pipeline — the surface downstream MEIO/supply read for EOQ. master.item.eoq is intentionally not written here (that stays exclusive to the EOQ process step, kcurve.runner.run_eoq_for_pipeline) so a dev/what-if click cannot clobber production master data. The frontend suppresses its heavy solve auto-save payload for ~1.5 s after a persisted click so a stale pre-click timer cannot overwrite the just-persisted results. |
GET /api/kcurve/eoq/item |
respect_frequency_constraints: bool (default true) |
Snaps per-item EOQ to its allowed frequency and returns freq_options. The snapped Q also respects min_qty/mult_qty lot-size constraints. |
Pipeline runner integration¶
The EOQ pipeline runner (files/kcurve/runner.py) always applies frequency
constraints. When the scenario has explicit frequency_groups, those are used.
Otherwise, it falls back to DB-based order_frequency parameters (if
constrained: true is set), and finally to all frequency definitions as the
default allowed set.
The runner calls resolve_order_freq_params (or
resolve_freq_params_from_groups) after loading items, builds the constrained
curve, and writes frequency-constrained Q* values back to item.eoq. The
inventory_scenario.results JSONB includes both curve and
constrained_curve, plus per-item freq_constraint (with
selected_freq_label) and freq_options.
UI locations¶
Order-frequency constraint information appears in three places:
- K-Curve workbench — Parameters tab — frequency group editor where users assign allowed frequencies to segments. Items not in any segment-based group fall into the Default group, which uses all frequency definitions.
- K-Curve workbench — Exchange Curve tab — the constrained curve is always overlaid as an orange stepped trace on the exchange curve chart. Three target modes: Explore (click a point), Inv. Budget (slider), Orders/Yr (slider). Feasible k alternatives are shown as clickable buttons.
- K-Curve workbench — Item Frequencies tab — per-item table showing chosen frequency (e.g., "Weekly"), EOQ (= demand / chosen frequency), orders/year, cycle stock, total cost, and all possible frequencies. Each row is expandable to show the full frequency option table with k-equivalents. Filter chips ("Can be:" / "Is:") allow filtering by available or chosen frequencies.
- Series detail / EOQ tab — a "Order Frequency Options" panel
(
eoq-frequencies) shows a table of allowed frequencies with Q, cycle stock, and total cost for each. The EOQ cost curve SVG overlays orange markers at each allowed frequency quantity on the total cost curve.
Shared module¶
All constraint logic lives in files/kcurve/frequency.py, imported by both
files/api/main.py (API endpoints) and files/kcurve/runner.py (pipeline
runner) to ensure identical algorithm behaviour. Key functions:
| Function | Purpose |
|---|---|
load_freq_definitions() |
Loads canonical frequency list from DB (cached 5 min), falls back to FREQ_OPTIONS |
default_freq_params_from_definitions(items) |
Builds default freq_params using all frequency definitions — ensures every item is always snapped to a frequency |
resolve_order_freq_params(conn, schema, uids) |
Resolves DB-based order_frequency parameters via segment membership |
resolve_freq_params_from_groups(items, groups, uid_to_segs) |
Resolves scenario-level frequency groups (segment-based) |
snap_item_to_frequency(it, allowed, k) |
Snaps one item to its best allowed frequency at k — returns {freq, eoq, constrained, k_equivalent} |
item_feasible_points(it, freq_params) |
Computes per-frequency candidate points for the UI (Q, cycle stock, cost, k-equivalent) |
build_constrained_curve(items, freq_params, target_k) |
Builds the stepped constrained exchange curve with threshold k values |
Rust acceleration (kcurve_engine)¶
The CPU-bound math — exchange-curve evaluation, brentq k-solving, the
constrained snap-to-grid (with precomputed per-item candidate tables) and
the O(T²) Wagner-Whitin DP — runs in the Rust crate
files/kcurve_rs/ (PyO3 + rayon), exposed as the
kcurve_engine Python extension. This mirrors the supply_engine pattern:
msgpack fast path + JSON fallback, GIL released during parallel work, and a
pure-Python fallback when the wheel is not built.
files/kcurve/rust_bridge.py dispatches between the Rust fast path and the
Python fallback (frequency.py, runner.py, wagner_whitin.py); the API
endpoints and pipeline runner call the bridge, keeping DB I/O, EOQ-override
application and display rounding in Python. Build with:
At ≥100k items the constrained solve runs ~500× faster than the Python loop
with bit-identical results (parity enforced by
files/kcurve/tests/test_rust_parity.py).
Cross-references: EOQ · Safety Stock · MEIO Theory