Extending Solvers¶
How to add new solver implementations to LumiX.
Overview¶
Adding a new solver involves:
Define Capabilities: Describe what the solver supports
Implement Interface: Create solver class implementing
LXSolverInterfaceRegister Solver: Add to optimizer’s solver factory
Write Tests: Comprehensive testing
Document: API docs and user guide
Step-by-Step Guide¶
Step 1: Define Capabilities¶
Create a capability object describing the solver:
# In src/lumix/solvers/capabilities.py
from .capabilities import LXSolverCapability, LXSolverFeature
MY_SOLVER_CAPABILITIES = LXSolverCapability(
name="MySolver",
features=(
LXSolverFeature.LINEAR
| LXSolverFeature.INTEGER
| LXSolverFeature.BINARY
| LXSolverFeature.QUADRATIC_CONVEX
| LXSolverFeature.SOS1
| LXSolverFeature.SOS2
| LXSolverFeature.INDICATOR
| LXSolverFeature.SENSITIVITY_ANALYSIS
),
max_variables=10_000_000,
max_constraints=10_000_000,
supports_warmstart=True,
supports_parallel=True,
supports_callbacks=False,
)
Checklist:
[ ] Identify all supported problem types (LP, MIP, QP, SOCP, etc.)
[ ] Check for special constraint support (SOS, indicator, etc.)
[ ] Verify warm start capability
[ ] Check parallel/multi-threading support
[ ] Determine callback support
[ ] Identify any limitations (max variables, etc.)
Step 2: Create Solver File¶
Create a new file for your solver implementation:
touch src/lumix/solvers/mysolver_solver.py
Basic structure:
"""MySolver solver implementation for LumiX."""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional, Union
# Import solver (with try/except for graceful failure)
try:
import mysolver
except ImportError:
mysolver = None
from ..core.constraints import LXConstraint
from ..core.enums import LXConstraintSense, LXObjectiveSense, LXVarType
from ..core.expressions import LXLinearExpression
from ..core.model import LXModel
from ..core.variables import LXVariable
from ..solution.solution import LXSolution
from .base import LXSolverInterface
from .capabilities import MY_SOLVER_CAPABILITIES
class LXMySolver(LXSolverInterface):
"""
MySolver implementation for LumiX.
Supports:
- Linear Programming (LP)
- Mixed-Integer Programming (MIP)
- Quadratic Programming (QP)
- Binary variables
- Single and indexed variable families
- Single and indexed constraint families
"""
def __init__(self) -> None:
"""Initialize MySolver solver."""
super().__init__(MY_SOLVER_CAPABILITIES)
# Check if solver is installed
if mysolver is None:
raise ImportError(
"MySolver is not installed. "
"Install it with: pip install mysolver"
)
# Internal state
self._model: Optional[mysolver.Model] = None
self._variable_map: Dict[str, Union[Any, Dict[Any, Any]]] = {}
self._constraint_map: Dict[str, Union[Any, Dict[Any, Any]]] = {}
def build_model(self, model: LXModel) -> mysolver.Model:
"""Build MySolver native model from LXModel."""
# Implementation details below
pass
def solve(
self,
model: LXModel,
time_limit: Optional[float] = None,
gap_tolerance: Optional[float] = None,
**solver_params: Any,
) -> LXSolution:
"""Solve optimization model with MySolver."""
# Implementation details below
pass
def get_solver_model(self) -> mysolver.Model:
"""Get underlying MySolver model."""
return self._model
Step 3: Implement build_model()¶
Translate LXModel to solver-specific format:
def build_model(self, model: LXModel) -> mysolver.Model:
"""Build MySolver native model from LXModel."""
# Create solver model
self._model = mysolver.Model(model.name)
# Reset internal state
self._variable_map = {}
self._constraint_map = {}
# Build variables
for lx_var in model.variables:
instances = lx_var.get_instances()
if not instances:
# Single variable (not indexed)
self._create_single_variable(lx_var)
else:
# Variable family (indexed by data)
self._create_indexed_variables(lx_var, instances)
# Build constraints
for lx_constraint in model.constraints:
instances = lx_constraint.get_instances()
if not instances:
# Single constraint
self._create_single_constraint(lx_constraint)
else:
# Constraint family (indexed by data)
self._create_indexed_constraints(lx_constraint, instances)
# Set objective
self._set_objective(model)
return self._model
Variable Creation:
def _create_single_variable(self, lx_var: LXVariable) -> None:
"""Create a single (non-indexed) variable."""
# Get bounds
lower = (
lx_var.lower_bound_func(None)
if callable(lx_var.lower_bound_func)
else lx_var.lower_bound_func
)
upper = (
lx_var.upper_bound_func(None)
if callable(lx_var.upper_bound_func)
else lx_var.upper_bound_func
)
# Map variable type
var_type = self._map_var_type(lx_var.var_type)
# Create solver variable
solver_var = self._model.add_variable(
name=lx_var.name,
var_type=var_type,
lower_bound=lower,
upper_bound=upper,
)
# Store in map
self._variable_map[lx_var.name] = solver_var
def _create_indexed_variables(
self,
lx_var: LXVariable,
instances: List
) -> None:
"""Create indexed variable family."""
var_map = {}
for instance in instances:
# Get index
index = lx_var.index_func(instance)
# Get instance-specific bounds
lower = (
lx_var.lower_bound_func(instance)
if callable(lx_var.lower_bound_func)
else lx_var.lower_bound_func
)
upper = (
lx_var.upper_bound_func(instance)
if callable(lx_var.upper_bound_func)
else lx_var.upper_bound_func
)
# Create variable
var_type = self._map_var_type(lx_var.var_type)
solver_var = self._model.add_variable(
name=f"{lx_var.name}[{index}]",
var_type=var_type,
lower_bound=lower,
upper_bound=upper,
)
var_map[index] = solver_var
# Store family
self._variable_map[lx_var.name] = var_map
Variable Type Mapping:
def _map_var_type(self, lx_type: LXVarType):
"""Map LumiX variable type to MySolver type."""
mapping = {
LXVarType.CONTINUOUS: mysolver.VarType.CONTINUOUS,
LXVarType.INTEGER: mysolver.VarType.INTEGER,
LXVarType.BINARY: mysolver.VarType.BINARY,
}
return mapping[lx_type]
Constraint Creation:
def _create_single_constraint(self, lx_constraint: LXConstraint) -> None:
"""Create a single (non-indexed) constraint."""
# Build expression
expr = self._build_expression(lx_constraint.lhs, None)
# Get RHS
rhs = (
lx_constraint.rhs_func(None)
if callable(lx_constraint.rhs_func)
else lx_constraint.rhs_func
)
# Map sense
sense = self._map_sense(lx_constraint.sense)
# Create constraint
solver_constr = self._model.add_constraint(
expr, sense, rhs, name=lx_constraint.name
)
self._constraint_map[lx_constraint.name] = solver_constr
def _create_indexed_constraints(
self,
lx_constraint: LXConstraint,
instances: List
) -> None:
"""Create indexed constraint family."""
constr_map = {}
for instance in instances:
# Get index
index = lx_constraint.index_func(instance)
# Build expression for this instance
expr = self._build_expression(lx_constraint.lhs, instance)
# Get RHS for this instance
rhs = (
lx_constraint.rhs_func(instance)
if callable(lx_constraint.rhs_func)
else lx_constraint.rhs_func
)
# Create constraint
sense = self._map_sense(lx_constraint.sense)
solver_constr = self._model.add_constraint(
expr, sense, rhs, name=f"{lx_constraint.name}[{index}]"
)
constr_map[index] = solver_constr
self._constraint_map[lx_constraint.name] = constr_map
Expression Building:
def _build_expression(
self,
lx_expr: LXLinearExpression,
constraint_instance: Optional[Any]
):
"""Build solver expression from LumiX expression."""
solver_expr = 0
# Add linear terms
for var_name, (var_family, coeff_func, where_func) in lx_expr.terms.items():
var_instances = var_family.get_instances()
if not var_instances:
# Single variable
coeff = coeff_func(constraint_instance)
solver_var = self._variable_map[var_name]
solver_expr += coeff * solver_var
else:
# Variable family
for var_instance in var_instances:
# Check where clause
if where_func and not where_func(var_instance, constraint_instance):
continue
# Get coefficient
if constraint_instance is not None:
coeff = coeff_func(var_instance, constraint_instance)
else:
coeff = coeff_func(var_instance)
# Get solver variable
var_index = var_family.index_func(var_instance)
solver_var = self._variable_map[var_name][var_index]
# Add term
solver_expr += coeff * solver_var
# Add constant
if lx_expr.constant:
solver_expr += lx_expr.constant
return solver_expr
Sense Mapping:
def _map_sense(self, lx_sense: LXConstraintSense):
"""Map LumiX constraint sense to MySolver sense."""
mapping = {
LXConstraintSense.LE: mysolver.Sense.LE,
LXConstraintSense.GE: mysolver.Sense.GE,
LXConstraintSense.EQ: mysolver.Sense.EQ,
}
return mapping[lx_sense]
Objective:
def _set_objective(self, model: LXModel) -> None:
"""Set objective function."""
if model.objective_expr is None:
return
# Build objective expression
obj_expr = self._build_expression(model.objective_expr, None)
# Set objective sense
if model.objective_sense == LXObjectiveSense.MAXIMIZE:
self._model.set_objective(mysolver.Sense.MAXIMIZE, obj_expr)
else:
self._model.set_objective(mysolver.Sense.MINIMIZE, obj_expr)
Step 4: Implement solve()¶
Solve the model and extract solution:
def solve(
self,
model: LXModel,
time_limit: Optional[float] = None,
gap_tolerance: Optional[float] = None,
enable_sensitivity: bool = False,
**solver_params: Any,
) -> LXSolution:
"""Solve optimization model."""
# Build model if not already built
if self._model is None:
self.build_model(model)
# Set common parameters
if time_limit is not None:
self._model.set_param("TimeLimit", time_limit)
if gap_tolerance is not None:
self._model.set_param("MIPGap", gap_tolerance)
# Set solver-specific parameters
for param, value in solver_params.items():
self._model.set_param(param, value)
# Solve
start_time = time.time()
self._model.optimize()
solve_time = time.time() - start_time
# Extract and return solution
return self._extract_solution(
model,
solve_time,
enable_sensitivity
)
Solution Extraction:
def _extract_solution(
self,
model: LXModel,
solve_time: float,
enable_sensitivity: bool
) -> LXSolution:
"""Extract solution from solver."""
# Get status
status = self._translate_status(self._model.status)
# Get objective value
if status in ["optimal", "feasible"]:
objective_value = self._model.objective_value
else:
objective_value = None
# Extract variable values
variable_values = {}
for var_name, var_map in self._variable_map.items():
if isinstance(var_map, dict):
# Indexed variable
variable_values[var_name] = {
idx: solver_var.value
for idx, solver_var in var_map.items()
}
else:
# Single variable
variable_values[var_name] = var_map.value
# Extract sensitivity (if enabled)
shadow_prices = None
reduced_costs = None
if enable_sensitivity and status == "optimal":
shadow_prices = self._extract_shadow_prices()
reduced_costs = self._extract_reduced_costs()
return LXSolution(
status=status,
objective_value=objective_value,
variable_values=variable_values,
solve_time=solve_time,
shadow_prices=shadow_prices,
reduced_costs=reduced_costs,
)
Status Translation:
def _translate_status(self, solver_status) -> str:
"""Translate solver status to LumiX status."""
mapping = {
mysolver.Status.OPTIMAL: "optimal",
mysolver.Status.FEASIBLE: "feasible",
mysolver.Status.INFEASIBLE: "infeasible",
mysolver.Status.UNBOUNDED: "unbounded",
mysolver.Status.TIME_LIMIT: "time_limit",
}
return mapping.get(solver_status, "unknown")
Step 5: Register Solver¶
Add to optimizer’s solver factory:
# In src/lumix/solvers/base.py
def _create_solver(self) -> LXSolverInterface[TModel]:
"""Create solver instance based on configured solver name."""
# ... existing solvers ...
elif self.solver_name == "mysolver":
from .mysolver_solver import LXMySolver
return LXMySolver()
else:
raise ValueError(f"Unknown solver: {self.solver_name}")
Update __init__.py:
# In src/lumix/solvers/__init__.py
from .mysolver_solver import LXMySolver
from .capabilities import MY_SOLVER_CAPABILITIES
__all__ = [
# ... existing exports ...
"LXMySolver",
"MY_SOLVER_CAPABILITIES",
]
Step 6: Write Tests¶
Create comprehensive test suite:
# In tests/test_mysolver.py
import pytest
from lumix import LXModel, LXOptimizer
@pytest.mark.skipif(
not mysolver_available(),
reason="MySolver not installed"
)
class TestMySolver:
def test_simple_lp(self):
"""Test simple LP problem."""
model = build_simple_lp()
optimizer = LXOptimizer().use_solver("mysolver")
solution = optimizer.solve(model)
assert solution.is_optimal()
assert abs(solution.objective_value - EXPECTED) < 1e-6
def test_mip(self):
"""Test MIP problem."""
model = build_mip_model()
optimizer = LXOptimizer().use_solver("mysolver")
solution = optimizer.solve(model)
assert solution.is_optimal()
# Verify integer constraints
for val in solution.variable_values["x"].values():
assert abs(val - round(val)) < 1e-6
def test_infeasible(self):
"""Test infeasible model detection."""
model = build_infeasible_model()
optimizer = LXOptimizer().use_solver("mysolver")
solution = optimizer.solve(model)
assert solution.status == "infeasible"
def test_sensitivity(self):
"""Test sensitivity analysis."""
if not MY_SOLVER_CAPABILITIES.has_feature(
LXSolverFeature.SENSITIVITY_ANALYSIS
):
pytest.skip("Sensitivity not supported")
model = build_simple_lp()
optimizer = (
LXOptimizer()
.use_solver("mysolver")
.enable_sensitivity()
)
solution = optimizer.solve(model)
assert solution.shadow_prices is not None
assert solution.reduced_costs is not None
Step 7: Document¶
Add API documentation and update user guide (similar to other solvers).
Best Practices¶
Error Handling¶
# Gracefully handle missing solver
try:
import mysolver
except ImportError:
mysolver = None
def __init__(self):
if mysolver is None:
raise ImportError(
"MySolver is not installed. "
"Install it with: pip install mysolver\\n"
"For licensing information, visit: https://mysolver.com"
)
Type Hints¶
Use comprehensive type hints:
def solve(
self,
model: LXModel[TModel],
time_limit: Optional[float] = None,
**params: Any
) -> LXSolution[TModel]:
...
Logging¶
Use the built-in logger:
self.logger.log_model_creation(model.name, num_vars, num_constrs)
self.logger.log_solve_start(self.capability.name)
self.logger.log_solve_end(status, obj_value, solve_time)
Testing Checklist¶
[ ] Simple LP
[ ] Simple MIP
[ ] Binary variables
[ ] Indexed variables
[ ] Indexed constraints
[ ] Multi-model expressions
[ ] Infeasible models
[ ] Unbounded models
[ ] Time limits
[ ] Gap tolerance
[ ] Sensitivity analysis (if supported)
[ ] Solver-specific parameters
Next Steps¶
Solvers Architecture - Understanding the architecture
Solvers Module API - API reference
Using Solvers - User guide examples
Existing solver implementations for reference