Skip to content

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.py is never modified — the LP runner imports its functions directly
  • When solver_type is NULL or 'mrp', the existing code path runs identically
  • If Pyomo/HIGHS is not installed, supply_solver_runner.py raises ImportError on entry, which run_pipeline.py catches 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 allows model.py to 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_orders
  • PIPE_supply_pegging
  • PIPE_supply_inventory_projection
  • PIPE_supply_exceptions
  • PIPE_supply_capacity

The output format is the identical dict structure that _call_engine() returns.


Model Builder (model.py)

Key Design Decisions

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

  2. Unified network flow: A single inventory_balance constraint handles both leaf nodes (stores with demand) and upstream nodes (WHs with TRANSFER outflows). The constraint includes both flow_in and flow_out terms.

  3. Cost model: Per-unit cost = unit_cost + (order_cost + setup_cost) / estimated_batch_size. The batch size estimate uses average demand per positive week, not min_qty, to avoid making the LP prefer shortage over ordering when min_qty is small.

  4. Safety stock is soft: Modeled as inv + ss_slack ≥ SS with a penalty in the objective. This matches the MRP's behavior where SS violations are flagged but not forbidden.

  5. Reverse logistics is conditional: The bad, Y, and scrap variables are only created when at least one plan has a Repair/Return route or non-zero initial_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:

flow_out = sum(m.x[dest_si, ri, t] for dest_si, ri, t in _departures_from_fast(si, t))

Solver (solver.py)

HIGHS Configuration

  • Solver: pyo.SolverFactory('highs')
  • Time limit: timelimit parameter (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):

  1. Group sku_plans by item_id
  2. Build and solve a separate LP for each item (all locations + routes for that item)
  3. 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:

  1. For each (item, location, week) with demand, identify supply sources:
  2. Carry-over from previous week's inventory
  3. Orders arriving in this week
  4. Repair arrivals

  5. Assign demand proportionally to each supply source (proportional allocation)

  6. For carry-over inventory, trace back to the original BUY/BUILD/REPAIR orders that created it

  7. For TRANSFER orders, propagate pegging upstream: the store's TRANSFER demand maps to the WH's BUY orders

  8. Output PegLink dicts 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.py catches the exception and falls back to 'mrp'
  • The API CRUD endpoints include solver_type in 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'])}")