Using the Optimizer

The LXOptimizer class is the main interface for solving optimization models in LumiX.

Overview

The optimizer provides a fluent API for:

  • Selecting solvers

  • Configuring solver options

  • Enabling advanced features

  • Solving models

  • Accessing solutions

Basic Usage

Simple Solve

The simplest way to solve a model:

from lumix import LXOptimizer

optimizer = LXOptimizer().use_solver("ortools")
solution = optimizer.solve(model)

With Parameters

Pass solver-specific parameters:

solution = optimizer.solve(
    model,
    time_limit=300,       # 5 minutes maximum
    gap_tolerance=0.01,   # 1% MIP gap
)

Fluent Configuration

Chain methods for complex configuration:

optimizer = (
    LXOptimizer()
    .use_solver("gurobi")
    .enable_sensitivity()
    .enable_rational_conversion()
)

solution = optimizer.solve(model, time_limit=600)

Optimizer Methods

use_solver()

Select which solver to use:

# Available solvers
optimizer = LXOptimizer().use_solver("ortools")   # Default, free
optimizer = LXOptimizer().use_solver("gurobi")    # Commercial
optimizer = LXOptimizer().use_solver("cplex")     # Commercial
optimizer = LXOptimizer().use_solver("glpk")      # Free, basic
optimizer = LXOptimizer().use_solver("cpsat")     # CP solver

Signature:

def use_solver(
    self,
    name: Literal["ortools", "gurobi", "cplex", "cpsat", "glpk"],
    **kwargs
) -> Self

Parameters:

  • name: Solver name (type-checked literal)

  • **kwargs: Solver-specific initialization parameters

enable_rational_conversion()

Convert floating-point coefficients to rational numbers:

optimizer = (
    LXOptimizer()
    .use_solver("cpsat")  # CP-SAT requires integers
    .enable_rational_conversion(max_denom=10000)
)

Why? Some solvers (like CP-SAT) only work with integer coefficients. This feature automatically converts floats like 0.333 to fractions like 1/3.

Signature:

def enable_rational_conversion(
    self,
    max_denom: int = 10000
) -> Self

Parameters:

  • max_denom: Maximum denominator for rational approximation (default: 10000)

Example:

# Model with float coefficients: 0.333 * x
model = build_model_with_floats()

# Automatically converts to: 333/1000 * x
optimizer = LXOptimizer().enable_rational_conversion()
solution = optimizer.solve(model)

enable_linearization()

Automatically linearize nonlinear terms:

optimizer = (
    LXOptimizer()
    .use_solver("ortools")  # Doesn't support quadratic
    .enable_linearization(
        big_m=1e6,
        pwl_segments=20,
        pwl_method="sos2",
        adaptive_breakpoints=True
    )
)

Why? Not all solvers support nonlinear terms (bilinear products, absolute values, min/max). This feature automatically linearizes them.

Signature:

def enable_linearization(
    self,
    big_m: float = 1e6,
    pwl_segments: int = 20,
    pwl_method: Literal["sos2", "incremental", "logarithmic"] = "sos2",
    prefer_sos2: bool = True,
    adaptive_breakpoints: bool = True,
    mccormick_tighten_bounds: bool = True,
    **kwargs
) -> Self

Parameters:

  • big_m: Big-M constant for conditional constraints (default: 1e6)

  • pwl_segments: Number of segments for piecewise-linear approximations (default: 20)

  • pwl_method: Method for PWL (“sos2”, “incremental”, “logarithmic”)

  • prefer_sos2: Use SOS2 when solver supports it (default: True)

  • adaptive_breakpoints: Use adaptive breakpoint generation (default: True)

  • mccormick_tighten_bounds: Apply bound tightening for McCormick envelopes (default: True)

Example:

# Model with x * y bilinear products
model = build_bilinear_model()

optimizer = (
    LXOptimizer()
    .use_solver("ortools")
    .enable_linearization(big_m=1e5, pwl_segments=30)
)

# Automatically linearizes x*y using McCormick envelopes
solution = optimizer.solve(model)

enable_sensitivity()

Enable sensitivity analysis (shadow prices, reduced costs):

optimizer = LXOptimizer().enable_sensitivity()
solution = optimizer.solve(model)

# Access sensitivity information
for constraint_name, shadow_price in solution.shadow_prices.items():
    print(f"{constraint_name}: {shadow_price}")

Note: Only supported by solvers with sensitivity analysis capability (Gurobi, CPLEX, GLPK).

solve()

Solve the optimization model:

solution = optimizer.solve(
    model,
    time_limit=600,
    gap_tolerance=0.01,
    **solver_params
)

Signature:

def solve(
    self,
    model: LXModel,
    **solver_params: Any
) -> LXSolution

Common Parameters:

  • time_limit: Maximum solve time in seconds

  • gap_tolerance: MIP gap tolerance (for integer programs)

Solver-Specific Parameters:

Gurobi:

solution = optimizer.solve(
    model,
    Threads=4,              # Number of threads
    MIPFocus=1,             # 1=feasibility, 2=optimality, 3=bound
    Presolve=2,             # -1=auto, 0=off, 1=conservative, 2=aggressive
    Method=-1,              # -1=auto, 0=primal, 1=dual, 2=barrier
    LogToConsole=1,         # 0=off, 1=on
)

CPLEX:

solution = optimizer.solve(
    model,
    threads=4,
    mip_emphasis=1,         # 0=balanced, 1=feasibility, 2=optimality, 3=bound, 4=hidden
    preprocessing_presolve=1,  # 0=off, 1=on
)

OR-Tools:

solution = optimizer.solve(
    model,
    num_search_workers=4,   # Parallel workers
    log_search_progress=True,
)

Returns:

LXSolution object containing:

  • status: Solution status (“optimal”, “feasible”, “infeasible”, etc.)

  • objective_value: Optimal objective value

  • variable_values: Dictionary of variable values

  • solve_time: Time taken to solve

  • shadow_prices: Shadow prices (if sensitivity enabled)

  • reduced_costs: Reduced costs (if sensitivity enabled)

Complete Examples

Example 1: Production Planning with Gurobi

from lumix import LXModel, LXOptimizer

# Build model
model = build_production_model(products, resources)

# Configure optimizer
optimizer = (
    LXOptimizer()
    .use_solver("gurobi")
    .enable_sensitivity()
)

# Solve with parameters
solution = optimizer.solve(
    model,
    time_limit=300,
    gap_tolerance=0.001,   # 0.1% gap
    Threads=8,
    MIPFocus=2,            # Focus on optimality
)

# Check results
if solution.is_optimal():
    print(f"Maximum profit: ${solution.objective_value:,.2f}")

    # Show production quantities
    for product in products:
        value = solution.get_value(production, product)
        print(f"Produce {value:.0f} units of {product.name}")

    # Show shadow prices (resource values)
    for resource in resources:
        price = solution.get_shadow_price(capacity, resource)
        print(f"{resource.name} shadow price: ${price:.2f}")

Example 2: Scheduling with CP-SAT

from lumix import LXModel, LXOptimizer

# Build integer scheduling model
model = build_scheduling_model(tasks, workers, days)

# Use CP-SAT (requires integer coefficients)
optimizer = (
    LXOptimizer()
    .use_solver("cpsat")
    .enable_rational_conversion()  # Convert floats to rationals
)

# Solve
solution = optimizer.solve(model, time_limit=60)

# Show assignments
for task in tasks:
    for worker in workers:
        for day in days:
            if solution.get_value(assignment, (task, worker, day)) > 0.5:
                print(f"{task.name} assigned to {worker.name} on {day}")

Example 3: Nonlinear Model with Linearization

from lumix import LXModel, LXOptimizer

# Model with bilinear terms (price * quantity)
model = build_pricing_model(products)

# Use OR-Tools with automatic linearization
optimizer = (
    LXOptimizer()
    .use_solver("ortools")
    .enable_linearization(
        big_m=1e6,
        pwl_segments=30,
        mccormick_tighten_bounds=True
    )
)

# Solve (automatically linearizes bilinear terms)
solution = optimizer.solve(model)

# Results
for product in products:
    price = solution.get_value(price_var, product)
    quantity = solution.get_value(quantity_var, product)
    revenue = price * quantity
    print(f"{product.name}: price=${price:.2f}, qty={quantity:.0f}, revenue=${revenue:,.2f}")

Best Practices

  1. Solver Selection

    • Start with OR-Tools (free, good for most problems)

    • Use Gurobi/CPLEX for large-scale or performance-critical problems

    • Use CP-SAT for pure integer/scheduling problems

    • Use GLPK only for very small problems or teaching

  2. Time Limits

    • Always set a time limit for MIP problems (can run indefinitely)

    • Use gap_tolerance for MIP to stop when “good enough” solution found

  3. Linearization

    • Enable only when needed (solver lacks native support)

    • Tune big_m based on your problem’s value ranges

    • Increase pwl_segments for better nonlinear function approximations

  4. Sensitivity Analysis

    • Enable only for LP problems or after MIP solve

    • Not all solvers support it (check capabilities)

  5. Reusing Optimizers

    • Create optimizer once, solve multiple models

    • Configuration persists across solves

    optimizer = LXOptimizer().use_solver("gurobi").enable_sensitivity()
    
    solution1 = optimizer.solve(model1)
    solution2 = optimizer.solve(model2)  # Same configuration
    

Error Handling

Import Errors

try:
    optimizer = LXOptimizer().use_solver("gurobi")
    solution = optimizer.solve(model)
except ImportError as e:
    print(f"Solver not available: {e}")
    # Fall back to OR-Tools
    optimizer = LXOptimizer().use_solver("ortools")
    solution = optimizer.solve(model)

Infeasible Models

solution = optimizer.solve(model)

if solution.status == "infeasible":
    print("Model is infeasible!")
    # Debug: Check constraint conflicts
    # or relax some constraints

Time Limit Exceeded

solution = optimizer.solve(model, time_limit=60)

if solution.status == "time_limit":
    print(f"Time limit reached. Best objective: {solution.objective_value}")
    # Use best solution found so far (if available)

Next Steps