"""Base solver interface for LumiX."""
from abc import ABC, abstractmethod
from typing import Any, Dict, Generic, Literal, Optional, TypeVar
from typing_extensions import Self
from ..core.model import LXModel
from ..linearization.config import LXLinearizerConfig
from ..solution.solution import LXSolution
from ..utils.logger import LXModelLogger
from ..utils.orm import LXORMContext
from ..utils.rational import LXRationalConverter
from .capabilities import LXSolverCapability
TModel = TypeVar("TModel")
[docs]
class LXSolverInterface(ABC, Generic[TModel]):
"""
Abstract base class for all solver interfaces.
Provides:
- Unified API across solvers (OR-Tools, Gurobi, CPLEX)
- Capability detection
- Automatic linearization when needed
- Type-safe solution mapping
"""
[docs]
def __init__(self, capability: LXSolverCapability):
"""
Initialize solver interface.
Args:
capability: Solver capability description
"""
self.capability = capability
self.logger = LXModelLogger(f"lumix.{capability.name}")
[docs]
@abstractmethod
def build_model(self, model: LXModel[TModel]) -> Any:
"""
Build solver-specific model from LumiX model.
Args:
model: OPtiXNG model
Returns:
Solver-specific model object
"""
pass
[docs]
@abstractmethod
def solve(
self,
model: LXModel[TModel],
time_limit: Optional[float] = None,
gap_tolerance: Optional[float] = None,
**solver_params: Any,
) -> LXSolution[TModel]:
"""
Solve the optimization model.
Args:
model: LumiX model
time_limit: Time limit in seconds
gap_tolerance: MIP gap tolerance
**solver_params: Additional solver-specific parameters
Returns:
Solution object
"""
pass
[docs]
@abstractmethod
def get_solver_model(self) -> Any:
"""
Get underlying solver model for advanced usage.
Returns:
Solver-specific model object
"""
pass
[docs]
class LXOptimizer(Generic[TModel]):
"""
Main optimizer with full generic support.
Provides high-level interface to:
- Select solver
- Configure rational conversion
- Enable sensitivity analysis
- Solve models
Examples:
optimizer = LXOptimizer[Product](orm_context)
.use_solver("gurobi")
.enable_sensitivity()
.enable_rational_conversion()
solution = optimizer.solve(model)
"""
[docs]
def __init__(self, orm: Optional[LXORMContext[TModel]] = None):
"""
Initialize optimizer.
Args:
orm: Optional ORM context for data access
"""
self.orm = orm
self.solver_name: str = "ortools"
self.use_rationals: bool = False
self.enable_sens: bool = False
self.use_linearization: bool = False
self.rational_converter: Optional[LXRationalConverter] = None
self.linearizer_config: Optional[LXLinearizerConfig] = None
self.logger = LXModelLogger("lumix.optimizer")
self._solver: Optional[LXSolverInterface[TModel]] = None
[docs]
def use_solver(self, name: Literal["ortools", "gurobi", "cplex", "cpsat", "glpk"], **kwargs) -> Self:
"""
Set solver with literal type checking.
Args:
name: Solver name ("ortools", "gurobi", "cplex", "cpsat", "glpk")
Returns:
Self for chaining
"""
self.solver_name = name
self._solver_params = kwargs
return self
[docs]
def enable_rational_conversion(self, max_denom: int = 10000) -> Self:
"""
Enable float-to-rational conversion.
Args:
max_denom: Maximum denominator for rational approximation
Returns:
Self for chaining
"""
self.use_rationals = True
self.rational_converter = LXRationalConverter(max_denom)
return self
[docs]
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: Any,
) -> Self:
"""
Enable automatic linearization for nonlinear terms.
Mirrors enable_rational_conversion() API pattern for consistency.
When enabled, the optimizer will automatically:
- Detect nonlinear terms in the model
- Check if solver lacks native support
- Apply appropriate linearization techniques:
- Binary × Binary: AND logic
- Binary × Continuous: Big-M method
- Continuous × Continuous: McCormick envelopes
- Piecewise-linear approximations for exp, log, sin, cos, etc.
Args:
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 formulation when solver supports it (default: True)
adaptive_breakpoints: Use adaptive breakpoint generation for PWL (default: True)
mccormick_tighten_bounds: Apply bound tightening for McCormick envelopes (default: True)
**kwargs: Additional linearization configuration options
Returns:
Self for chaining
Example::
optimizer = (
LXOptimizer[Product]()
.use_solver("ortools")
.enable_linearization(
big_m=1e5,
pwl_segments=30,
adaptive_breakpoints=True
)
)
solution = optimizer.solve(model)
"""
self.use_linearization = True
self.linearizer_config = LXLinearizerConfig(
big_m_value=big_m,
pwl_num_segments=pwl_segments,
pwl_method=pwl_method,
prefer_sos2=prefer_sos2,
adaptive_breakpoints=adaptive_breakpoints,
mccormick_tighten_bounds=mccormick_tighten_bounds,
**kwargs,
)
return self
[docs]
def enable_sensitivity(self) -> Self:
"""
Enable sensitivity analysis.
Returns:
Self for chaining
"""
self.enable_sens = True
return self
[docs]
def solve(self, model: LXModel[TModel], **solver_params: Any) -> LXSolution[TModel]:
"""
Solve with full type safety.
Args:
model: LXModel to solve
**solver_params: Solver-specific parameters
Returns:
Type-safe solution
Raises:
ImportError: If solver not installed
ValueError: If model is invalid
"""
# Create solver instance (or reuse existing one if already set)
if self._solver is None:
self._solver = self._create_solver()
# Log model info
self.logger.log_model_creation(
model.name, len(model.variables), len(model.constraints)
)
# Solve
self.logger.log_solve_start(self.solver_name)
solution = self._solver.solve(model, enable_sensitivity=self.enable_sens, **solver_params)
self.logger.log_solve_end(solution.status, solution.objective_value, solution.solve_time)
# Populate goal deviations if model has prepared goal programming
if (hasattr(model, 'populate_goal_deviations') and
hasattr(model, '_goal_programming_prepared') and
model._goal_programming_prepared):
model.populate_goal_deviations(solution)
return solution
def _create_solver(self) -> LXSolverInterface[TModel]:
"""
Create solver instance based on configured solver name.
Returns:
Solver interface instance
Raises:
ImportError: If solver not available
"""
if self.solver_name == "ortools":
from .ortools_solver import LXORToolsSolver
return LXORToolsSolver()
elif self.solver_name == "gurobi":
from .gurobi_solver import LXGurobiSolver
return LXGurobiSolver()
elif self.solver_name == "cplex":
from .cplex_solver import LXCPLEXSolver
return LXCPLEXSolver()
elif self.solver_name == "cpsat":
from .cpsat_solver import LXCPSATSolver
# Pass rational conversion settings to CP-SAT
rational_max_denom = 10000 # default
if self.rational_converter is not None:
rational_max_denom = self.rational_converter.max_denominator
return LXCPSATSolver(
enable_rational_conversion=self.use_rationals,
rational_max_denom=rational_max_denom
)
elif self.solver_name == "glpk":
from .glpk_solver import LXGLPKSolver
return LXGLPKSolver()
else:
raise ValueError(f"Unknown solver: {self.solver_name}")
__all__ = ["LXSolverInterface", "LXOptimizer"]