LP Transport Solver — Algorithm Reference¶
Overview¶
The LP Transport Solver is an alternative to the default Rust MRP engine for supply planning. It formulates the entire multi-echelon supply chain as a single linear program (LP) and solves it with the HIGHS solver via Pyomo. The objective is to minimize total transport cost while respecting all constraints from upstream processes (forecast demand, MEIO safety stock, EOQ min-qty, firm orders, calendars, capacity).
When to use it:
- You want globally optimal transport routing instead of greedy source-priority ordering
- You have a multi-echelon network (2–7 levels) where the MRP's sequential 2-pass approach produces suboptimal transfers
- You want to model reverse logistics (repair dispatch, bad-stock pool, scrap) as explicit decisions rather than pre-computed parameters
When NOT to use it:
- You need source priority enforcement (the LP ignores
source_priorityand picks the cheapest route globally) - You rely on MinMax/PoQ order policies (the LP uses continuous review only)
- You need the consolidation window or nervousness limit features (not yet modeled in Phase 1)
Mathematical Formulation¶
Sets¶
| Set | Description |
|---|---|
I |
Items (sku_id) |
L |
Locations (site_id) |
T |
Time periods (weeks 0…H-1, default H=52) |
R |
Routes (route_id with source_type, src→dest, lead_time) |
Decision Variables¶
All variables are continuous, non-negative.
| Variable | Domain | Description |
|---|---|---|
x[i,l,r,t] |
ℝ⁺ | Flow on route r for item i at location l released in period t |
inv[i,l,t] |
ℝ⁺ | Ending inventory of good stock at node (i,l) in period t |
short[i,l,t] |
ℝ⁺ | Shortage (unmet demand) at node (i,l) in period t |
ss_slack[i,l,t] |
ℝ⁺ | Safety stock violation slack at node (i,l) in period t |
bad[i,l,t] |
ℝ⁺ | Bad-stock inventory at node (i,l) in period t |
Y[i,l,t] |
ℝ⁺ | Repair dispatch (units sent to repair) at node (i,l) in period t |
scrap[i,l,t] |
ℝ⁺ | Units scrapped from bad pool at node (i,l) in period t |
Objective Function¶
Minimize:
Σ c(r) × x[i,l,r,t] // transport + procurement cost
+ Σ h(i) × inv[i,l,t] // inventory holding cost
+ Σ P_short × short[i,l,t] // shortage penalty
+ Σ P_ss × ss_slack[i,l,t] // safety stock violation penalty
+ Σ P_scrap × scrap[i,l,t] // scrap cost
Where:
c(r)= per-unit cost on router=unit_cost + (order_cost + setup_cost) / estimated_batch_sizeh(i)=unit_cost × holding_cost_pct / 52(weekly holding cost)P_short=shortage_penalty(default 50.0 per unit)P_ss=safety_stock_penalty(default 100.0 per unit)P_scrap=repair_scrap_cost(default 0.0 per unit)
Constraints¶
1. Inventory Balance (Good Stock)¶
inv[i,l,t] = inv[i,l,t-1]
+ SR[i,l,t] // scheduled receipts (firm orders)
+ repair_in[i,l,t] // repair arrivals
+ Σ x[i,l,r,t-LT] // route arrivals (release t-LT arrives at t)
- Σ x[dest,ri,t] // TRANSFER outflows to downstream nodes
- demand[i,l,t] // customer demand
+ short[i,l,t] // shortage (elastic slack)
- ss_slack[i,l,t] // SS violation (elastic slack)
This is the core constraint that unifies the entire multi-echelon network. Unlike the MRP's 2-pass approach (upstream MRP → allocation → downstream MRP), the LP models all flows in a single constraint system, so the solver jointly optimizes upstream procurement and downstream distribution.
The outflow term (Σ x[dest,ri,t]) is critical: when a WH sends a TRANSFER to a downstream store, those units are subtracted from the WH's inventory in the same period they are dispatched.
Figure: the inventory-balance constraint unifies the network — inflows (prior inventory, firm receipts, repair arrivals, route arrivals) minus outflows (transfers out, customer demand, safety-stock slack) yield the period's ending inventory, with shortage as elastic slack.
flowchart LR
subgraph IN["Inflows"]
I1["inv[i,l,t-1]"]
I2["firm receipts (SR)"]
I3["repair_in"]
I4["route arrivals x[t-LT]"]
end
subgraph OUT["Outflows"]
O1["transfer out"]
O2["customer demand"]
O3["safety-stock slack"]
end
IN --> BAL["inv[i,l,t] = balance"]
OUT --> BAL
BAL --> SH["short[i,l,t]<br/>(elastic)"]
2. Safety Stock (Soft Constraint)¶
Safety stock is soft — violations are allowed but penalized. This matches the MRP engine's behavior where SS is a planning target, not a hard requirement.
3. Bad-Stock Balance¶
bad[i,l,t] = bad[i,l,t-1]
+ bad_returns[i,l,t] // deterministic inflow: demand × return_rate
- Y[i,l,t] // repair dispatch
- scrap[i,l,t] // scrap
- repair_returns[i,l,t] // pre-committed returns (from MRP preprocess)
The bad-stock pool is tracked explicitly. Unlike the MRP engine (where repair_returns is a pre-computed parameter), the LP treats repair dispatch as a decision variable — the solver chooses when and how much to repair.
4. Repair Feasibility¶
Y[i,l,t] ≤ bad[i,l,t-1] + bad_returns[i,l,t] // can't repair more than available
Y[i,l,t] ≤ repair_capacity[i,l,t] // repair shop capacity
5. Repair Arrivals (Deterministic Delay)¶
Where TA = repair_turnaround (combined return + repair lead time).
6. Route Calendar¶
Arcs where the shipping calendar bitmask = 0 are excluded from the model (variable not created for that period). This matches the MRP's sourcing_pass calendar check.
7. Capacity (Soft)¶
When capacity_enforcement = "soft", the solver may exceed hard capacity up to the soft limit. Overtime cost is implied by the per-unit route cost.
8. Firm Orders Block Horizon¶
When firm_orders_block_horizon = true:
This prevents the solver from creating new recommendations before the latest firm order of the same order type.
Cost Model¶
Per-Unit Cost Approximation¶
In Phase 1, the LP uses a linear per-unit cost on each route:
The estimated_batch_size is computed as:
This approximation converts the fixed per-order cost (order_cost) into a per-unit equivalent. It is exact when the actual order quantity equals the estimated batch size. For quantities significantly above or below, there will be a cost estimation error. Phase 3 introduces MIP binary variables to model per-batch costs exactly.
Holding Cost¶
Annual holding cost percentage (default 25%) divided by 52 gives the weekly cost per unit of inventory.
Multi-Echelon Network Flow¶
The LP models the entire multi-echelon supply chain as a unified network. The topology is discovered from the route data using the same BFS as _add_upstream_network_nodes().
Figure: the supply chain is a min-cost flow network — procurement (BUY) feeds the root warehouse, TRANSFER arcs move stock through transshipment nodes, and customer demand at stores is the sink. The LP solver decides flows on all arcs simultaneously.
flowchart LR
SRC["Source: BUY route<br/>(procurement)"] --> R["RootWH<br/>(site 100)"]
R ==TRANSFER==> C["CountryWH<br/>(site 10)"]
C ==TRANSFER==> ST["Store<br/>(site 1)"]
ST --> DM["Customer demand<br/>(sink)"]
LP["LP solver<br/>(HIGHS / Pyomo)"] -.decides all flows.-> R
LP -.decides all flows.-> C
LP -.decides all flows.-> ST
3-Echelon Example¶
RootWH (site 100) --BUY--> [inventory] --TRANSFER--> CountryWH (site 10) --TRANSFER--> Store (site 1)
In the LP:
- RootWH has a BUY route → inflow
x[root, buy_route, t], no direct demand - CountryWH has a TRANSFER route from RootWH → inflow
x[countrywh, transfer_route, t], outflow to Store - Store has a TRANSFER route from CountryWH → inflow
x[store, transfer_route, t], customer demand
The solver decides BUY quantities at RootWH, TRANSFER quantities from RootWH→CountryWH, and TRANSFER quantities from CountryWH→Store simultaneously — no sequential 2-pass decomposition.
Outflow Tracking¶
When node A sends a TRANSFER to node B:
inv[A,t] = ... - x[B, transfer_route, t] // outflow from A
inv[B,t+LT] = ... + x[B, transfer_route, t] // inflow to B at t+LT
This ensures flow conservation: every unit leaving A arrives at B after the lead time.
Reverse Logistics¶
The LP explicitly models the repair/scrap decision:
| MRP Engine | LP Solver |
|---|---|
repair_returns[t] is pre-computed: demand[t-TA] × return_rate × yield |
Y[i,l,t] (repair dispatch) is a decision variable |
| All bad stock is repaired immediately | Solver chooses when and how much to repair |
| No scrap option | scrap[i,l,t] is a decision with configurable cost |
| Repair pool consumed sequentially | Repair dispatch bounded by bad-stock availability AND repair shop capacity |
When repair shop capacity is binding, the solver prioritizes repairing items with the highest transport-cost savings. This is impossible in the MRP engine.
Lot-Sizing (Post-Processor)¶
The LP produces continuous (fractional) order quantities. Lot-sizing is applied as a post-processor after the LP solve:
Figure: after the HIGHS solver returns fractional flows, the lot-sizing post-processor snaps (or keeps) quantities to order multiples before writing identical-format rows to PIPE_supply_orders.
flowchart TD
LP["HIGHS LP solve"] --> X["Continuous x[i,l,r,t]<br/>(fractional)"]
X --> LS{"lot_sizing mode"}
LS -- "snap" --> SN["ceil/floor to mult_qty<br/>max(result, min_qty)<br/>min(result, max_qty)"]
LS -- "none" --> NN["keep fractional"]
LS -- "relax_round" --> RR["round to nearest mult_qty"]
SN --> OUT["PIPE_supply_orders"]
NN --> OUT
RR --> OUT
snap(default): Apply the same ceil/floor logic as the Rustallocate()stepceil(qty / mult_qty) × mult_qtymax(result, min_qty)min(result, max_qty)floor to mult_qty-
If result < min_qty → 0
-
none: Keep LP fractional quantities as-is -
relax_round(Phase 2): Round to nearestmult_qty(up or down)
Item-Level Decomposition¶
For large instances (>5000 SKU-location pairs), the LP can be decomposed by item:
- Solve each item independently (all locations + routes for that item)
- Check shared capacity constraints for violations
- If violations found, increase shadow prices and re-solve affected items
- Repeat until convergence (max 3 iterations)
This reduces solve time from exponential in total SKU count to linear in the number of items, with typically 2-3 capacity reconciliation iterations.
Comparison: LP Solver vs MRP Engine¶
| Feature | MRP Engine (Rust) | LP Transport Solver |
|---|---|---|
| Optimization | Greedy, source-priority order | Global cost minimization |
| Multi-echelon | Two Rust passes + Python allocation (upstream, allocation, stores). Supports N echelons via BFS discovery up to max_echelons=4 (default = 5 total levels). See MRP Engine Architecture |
Unified LP network flow; same BFS input, all echelons optimized simultaneously |
| Source priority | Enforced (Repair→Return→Transfer→Build→Buy) | Ignored — picks cheapest route |
| Transfer allocation | Python _allocate_transfers() proportional |
LP decides optimal quantities |
| Repair dispatch | Pre-computed parameter | Decision variable |
| Scrap | Not modeled | Decision with cost |
| Bad-stock pool | Informational only | Explicit balance constraint |
| Per-batch cost | Not optimized | Approximated per-unit (Phase 1), exact MIP (Phase 3) |
| Lot-sizing | Inline during sourcing | Post-processor snap |
| Pegging | Propagation-based | Propagation-based (matches) |
| Output tables | PIPE_supply_orders, etc. | Same tables, identical format |