LP Transport Solver — Developer Guide¶
Architecture¶
The LP transport solver is an alternative supply planning engine that replaces the Rust MRP engine's 2-pass sequential approach with a single unified linear program solved by HIGHS.
File Structure¶
files/
supply_solver/
__init__.py — Package exports: SolverConfig, build_model, solve_model, extract_results
config.py — SolverConfig dataclass + solver_config_from_supply_cfg()
model.py — Pyomo ConcreteModel builder (variables, constraints, objective)
solver.py — HIGHS invocation + item-level decomposition wrapper
extract.py — LP result → Rust-compatible output dict + full pegging
granularity.py — Period index builder (delegates to supply_disaggregate when available)
supply_solver_runner.py — Main runner (same signature as supply_runner.run_supply_plan)
DDL/
supply_solver_migration.sql — Adds solver_type column to config_supply_scenario
Integration Points¶
flowchart TD
subgraph "Existing (untouched)"
SR[supply_runner.py]
RUST[supply/ Rust crate]
AR[allocation_runner.py]
end
subgraph "New LP solver"
SSR[supply_solver_runner.py]
SS[supply_solver/ package]
end
subgraph "Switch"
RP[run_pipeline.py] -->|"solver_type='mrp'"| SR
RP -->|"solver_type='lp_transport'"| SSR
end
SR --> RUST
SSR -->|"imports _load_sku_plans, _write_results, etc."| SR
SSR --> SS
SR --> AR
SSR --> AR
style SR fill:#cfc
style RUST fill:#cfc
style SSR fill:#adf
style SS fill:#adf
Safety Guarantee¶
supply_runner.pyis never modified — the LP runner imports its functions directly- When
solver_typeis NULL or'mrp', the existing code path runs identically - If Pyomo/HIGHS is not installed,
supply_solver_runner.pyraisesImportErroron entry, whichrun_pipeline.pycatches as a step failure — same error handling as any other step
Data Flow¶
Input: Shared with MRP¶
The LP runner calls the exact same data-loading functions as the MRP runner:
| Function | Source | What it provides |
|---|---|---|
_resolve_pipeline_context() |
supply_runner.py:988 | scenario IDs from pipeline row |
_load_supply_config() |
supply_runner.py:3924 | merged config with defaults |
_load_sku_plans() |
supply_runner.py:1220 | per-SKU 52w arrays + embedded routes |
_load_calendars() |
supply_runner.py:3854 | calendar → bitmask |
_load_capacity() |
supply_runner.py:~3820 | capacity constraints |
_load_route_resource_map() |
supply_runner.py:~3840 | route_id → (resource_id, qty_per_unit) |
_add_upstream_network_nodes() |
supply_runner.py:1916 | multi-echelon BFS expansion |
_aggregate_demands_to_upstream_whs() |
supply_runner.py | demand rollup |
_apply_calendar_pull_forward() |
supply_runner.py | pre-position for closures |
_embed_routes_per_sku() |
supply_runner.py | embed routes into upstream nodes |
_fix_repair_route_return_rates() |
supply_runner.py | ensure repair autofill fires |
Additional Pre-Processing¶
The LP runner adds one pre-processing step not needed by the MRP:
- Calendar bitmask embedding: Copies the calendar bitmask from
_load_calendars()into each route option dict as_shipping_calendar_bitmask. This allowsmodel.pyto exclude closed weeks without a separate lookup.
Output: Same as MRP¶
The LP runner calls _write_results() from supply_runner.py, which writes to the same ClickHouse tables:
PIPE_supply_ordersPIPE_supply_peggingPIPE_supply_inventory_projectionPIPE_supply_exceptionsPIPE_supply_capacity
The output format is the identical dict structure that _call_engine() returns.
Model Builder (model.py)¶
Key Design Decisions¶
-
Arc-based variables:
x[si, ri, t]is only created for valid arcs (calendar-open weeks). Closed weeks have no variable — the solver cannot flow on them. -
Unified network flow: A single
inventory_balanceconstraint handles both leaf nodes (stores with demand) and upstream nodes (WHs with TRANSFER outflows). The constraint includes bothflow_inandflow_outterms. -
Cost model: Per-unit cost =
unit_cost + (order_cost + setup_cost) / estimated_batch_size. The batch size estimate uses average demand per positive week, notmin_qty, to avoid making the LP prefer shortage over ordering whenmin_qtyis small. -
Safety stock is soft: Modeled as
inv + ss_slack ≥ SSwith a penalty in the objective. This matches the MRP's behavior where SS violations are flagged but not forbidden. -
Reverse logistics is conditional: The
bad,Y, andscrapvariables are only created when at least one plan has a Repair/Return route or non-zeroinitial_oh_bad. This avoids creating unnecessary variables for items without reverse logistics.
Outflow Tracking¶
The model builds a dest_route_map that maps each source node to all routes that draw from it:
# For route on dest_si with source_location_id matching items[src_si]:
dest_route_map[src_si].append((dest_si, ri))
The inventory balance then subtracts:
Solver (solver.py)¶
HIGHS Configuration¶
- Solver:
pyo.SolverFactory('highs') - Time limit:
timelimitparameter (default 300s) - The solver handles both LP (Phase 1) and MIP (Phase 3) automatically
Item-Level Decomposition¶
When decompose_by_item=True and SKU count > decomposition_threshold (default 5000):
- Group sku_plans by item_id
- Build and solve a separate LP for each item (all locations + routes for that item)
- Merge results into a single output dict
No capacity reconciliation is implemented in Phase 1 — shared capacity constraints are only enforced within each item's sub-model. Phase 2 adds iterative Lagrangian reconciliation.
Result Extraction (extract.py)¶
Order Generation¶
For each arc with x[si, ri, t] > 1e-6:
1. Apply lot-sizing snap (if lot_sizing='snap')
2. Create a SupplyOrder dict with the same fields as the Rust engine output
3. Assign order_type from route's source_type (Buy→BUY, Transfer→TRANSFER, etc.)
For repair dispatches with Y[si, t] > 1e-6:
1. Create REPAIR orders with arrival = t + repair_turnaround
2. Use repair route's unit_cost and order_cost
Pegging¶
Full propagation-based pegging:
- For each
(item, location, week)with demand, identify supply sources: - Carry-over from previous week's inventory
- Orders arriving in this week
-
Repair arrivals
-
Assign demand proportionally to each supply source (proportional allocation)
-
For carry-over inventory, trace back to the original BUY/BUILD/REPAIR orders that created it
-
For TRANSFER orders, propagate pegging upstream: the store's TRANSFER demand maps to the WH's BUY orders
-
Output
PegLinkdicts with:order_id,sku_id,location_id,demand_week,supply_week,qty
Runner (supply_solver_runner.py)¶
Flow¶
1. Resolve pipeline context (same as MRP)
2. Run netting pre-step (same as MRP)
3. Load all input data (same functions as MRP)
4. Build SolverConfig from supply_cfg
5. Embed calendar bitmasks into routes
6. Build Pyomo model
7. Solve with HIGHS
8. Extract results
9. Enrich projections (demand, supply_received, safety_stock)
10. Expiry post-processing (reuse _compute_expiry_quantities)
11. Expedite order generation (reuse _generate_expedite_orders)
12. Write results (reuse _write_results)
13. Persist capacity usage
14. Return summary dict
Key Difference from MRP¶
For how the MRP engine actually works under the hood (including what "2-pass" really means and its depth limit), see MRP Engine Architecture.
The LP runner skips:
- The MRP 2-pass orchestration (Pass 1 upstream → Python _allocate_transfers → Pass 2 store)
- _allocate_transfers() — the LP produces TRANSFER flows directly as decision variables
- _merge_pass_results() — a single LP result needs no merging
The LP runner reuses:
- All data loading (identical input)
- All post-processing (expiry, expedite, lot-sizing)
- All persistence (_write_results, capacity usage)
DB Schema Extension¶
ALTER TABLE config_supply_scenario
ADD COLUMN solver_type VARCHAR(20) DEFAULT 'mrp'
CHECK (solver_type IN ('mrp', 'lp_transport'));
- Default
'mrp'ensures backward compatibility - When the column doesn't exist (pre-migration DB),
run_pipeline.pycatches the exception and falls back to'mrp' - The API CRUD endpoints include
solver_typein create/update/get/list operations
Dependencies¶
| Package | Version | Purpose |
|---|---|---|
pyomo |
≥6.7.0 | LP/MIP modeling framework |
highspy |
≥1.5.0 | HIGHS solver binary |
If either is missing, supply_solver_runner.py raises ImportError at entry. The MRP engine is unaffected.
Testing¶
Manual Test¶
from supply_solver.config import SolverConfig
from supply_solver.model import build_model
from supply_solver.solver import solve_model
from supply_solver.extract import extract_results
# Build a minimal supply plan
sku_plans = [{
"sku_id": 100, "location_id": 1,
"initial_inventory": 10.0,
"demand": [5.0]*52,
"safety_stock": [2.0]*52,
"scheduled_receipts": [0.0]*52,
"repair_returns": [0.0]*52,
"bad_returns": [0.0]*52,
"initial_oh_bad": 0.0,
"firm_orders": [0.0]*52,
"failure_rate": [0.0]*52,
"active": True,
"policy": {"type": "Continuous"},
"routes": [{
"route_id": 1, "source_type": "Buy", "lead_time": 2,
"unit_cost": 10.0, "order_cost": 50.0,
"min_qty": 1.0, "mult_qty": 1.0, "max_qty": 999999.0,
"_shipping_calendar_bitmask": (1 << 52) - 1,
}],
"latest_firm_by_type": [255]*7, "min_lt_by_type": [0]*7,
}]
cfg = SolverConfig()
model = build_model(sku_plans, [], cfg)
model = solve_model(model, cfg)
result = extract_results(model)
print(f"Orders: {len(result['orders'])}, Exceptions: {len(result['exceptions'])}")