"""Model builder class for LumiX optimization models.
This module provides the LXModel class, which is the central component for building
optimization models in LumiX. It implements the Builder pattern with fluent API for
creating type-safe, data-driven optimization models.
The model serves as a container for:
- Variable families (decision variables indexed by data)
- Constraint families (constraints indexed by data)
- Objective function (linear or quadratic expression)
- Goal programming metadata (for multi-objective optimization)
Key Features:
- **Fluent API**: Method chaining for concise model building
- **Type Safety**: Generic type parameter for compile-time type checking
- **Goal Programming**: Native support for multi-objective optimization
- **Auto-expansion**: Variable and constraint families expand automatically
Architecture:
LXModel uses the Builder pattern where each method returns `self` to enable
method chaining. The model doesn't create solver variables directly - instead,
it stores variable and constraint *families* that are expanded during solving.
Examples:
Simple production planning model::
from lumix import LXModel, LXVariable, LXConstraint, LXLinearExpression
# Create model
model = LXModel("production_plan")
# Add variables
production = LXVariable[Product, float]("production")\\
.continuous()\\
.bounds(lower=0)\\
.from_data(products)
model.add_variable(production)
# Add constraints
capacity = LXConstraint("capacity")\\
.expression(
LXLinearExpression().add_term(production, lambda p: p.usage)
)\\
.le()\\
.rhs(max_capacity)
model.add_constraint(capacity)
# Set objective
model.maximize(
LXLinearExpression().add_term(production, lambda p: p.profit)
)
Fluent API with method chaining::
model = (
LXModel[Product]("production")
.add_variable(production)
.add_constraint(capacity)
.maximize(profit_expr)
)
Goal programming for multi-objective optimization::
model = LXModel("multi_objective")\\
.set_goal_mode("weighted")
# Mark constraints as goals with priorities
model.add_constraint(
profit_constraint.as_goal(priority=1, weight=1.0)
)
model.add_constraint(
quality_constraint.as_goal(priority=2, weight=0.8)
)
# Solve with goal programming solver
solution = optimizer.solve(model)
Note:
The model is solver-agnostic. The same model can be solved with different
solvers (OR-Tools, Gurobi, CPLEX, GLPK) by simply changing the optimizer
configuration.
See Also:
- :class:`~lumix.core.variables.LXVariable`: Variable family builder
- :class:`~lumix.core.constraints.LXConstraint`: Constraint family builder
- :class:`~lumix.core.expressions.LXLinearExpression`: Linear expression builder
- :class:`~lumix.solvers.base.LXOptimizer`: Solver interface
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Generic, List, Optional, TypeVar
from typing_extensions import Self
from .constraints import LXConstraint
from .enums import LXObjectiveSense
from .expressions import LXLinearExpression, LXQuadraticExpression
from .variables import LXVariable
if TYPE_CHECKING:
from ..goal_programming.goal import LXGoalMode
from ..goal_programming.relaxation import RelaxedConstraint
from ..solution.solution import LXSolution
TModel = TypeVar("TModel")
[docs]
class LXModel(Generic[TModel]):
"""
Main model builder with full type safety and IDE support.
Creates and manages optimization models with:
- Variables (single or multi-indexed)
- Constraints (linear, indexed, multi-model)
- Objective function (linear or quadratic)
Examples::
# Simple model
model = LXModel("production_plan")
model.add_variable(production)
model.add_constraint(capacity_constraint)
model.maximize(
LXLinearExpression().add_term(production, lambda p: p.selling_price)
)
# Type-safe model
model = LXModel[Product]("production_plan")
.add_variable(production)
.add_constraint(capacity_constraint)
.maximize(
LXLinearExpression()
.add_term(production, lambda p: p.selling_price - p.cost)
)
"""
[docs]
def __init__(self, name: str):
"""
Initialize model.
Args:
name: Model name
"""
self.name = name
# Note: List of variable families. Each LXVariable represents a family of solver variables
# (e.g., production[product1], production[product2], ...). A model typically contains
# multiple families (e.g., production, inventory, transportation).
self.variables: List[LXVariable] = []
self.constraints: List[LXConstraint] = []
self.objective_expr: Optional[LXLinearExpression | LXQuadraticExpression] = None
self.objective_sense: LXObjectiveSense = LXObjectiveSense.MAXIMIZE
# Goal programming support
self.goal_mode: Optional[str] = None # "weighted" or "sequential"
self._relaxed_constraints: List["RelaxedConstraint"] = []
self._goal_programming_prepared: bool = False
[docs]
def __deepcopy__(self, memo):
"""Custom deepcopy that enables what-if analysis with ORM data sources.
This method orchestrates deep copying of the entire model including:
1. All variable families (with ORM data materialization)
2. All constraint families (with ORM data materialization)
3. Objective expression
4. Goal programming metadata
This is the central method that makes what-if analysis possible by creating
independent copies of models that can be modified without affecting the original.
Args:
memo: Dictionary for tracking circular references during deepcopy
Returns:
Deep copy of this model with all ORM dependencies resolved
Note:
After copying, all variables and constraints will have their ORM sessions
detached and data materialized. The copy is completely independent and safe
for serialization/pickling.
Example:
>>> original_model = build_model_with_orm(session)
>>> modified_model = deepcopy(original_model)
>>> # modified_model can now be changed without affecting original_model
"""
from copy import deepcopy
# Create new instance without calling __init__
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
# Copy simple attributes
result.name = self.name
result.objective_sense = self.objective_sense
result.goal_mode = self.goal_mode
result._goal_programming_prepared = self._goal_programming_prepared
# Deep copy all variable families
# Each variable's __deepcopy__ will materialize and detach ORM data
result.variables = [deepcopy(var, memo) for var in self.variables]
# Deep copy all constraint families
# Each constraint's __deepcopy__ will materialize and detach ORM data
result.constraints = [deepcopy(constr, memo) for constr in self.constraints]
# Deep copy objective expression (if present)
result.objective_expr = (
deepcopy(self.objective_expr, memo)
if self.objective_expr is not None
else None
)
# Deep copy relaxed constraints (for goal programming)
result._relaxed_constraints = [
deepcopy(rc, memo) for rc in self._relaxed_constraints
]
return result
[docs]
def __getstate__(self):
"""Support for pickle protocol - detach ORM sessions before pickling.
Returns:
Dictionary of instance state safe for pickling
"""
# The default pickle behavior will work since all nested objects
# (variables, constraints, expressions) have their own __getstate__
# methods that handle ORM detachment
return self.__dict__.copy()
[docs]
def __setstate__(self, state):
"""Support for pickle protocol - restore from pickled state.
Args:
state: Dictionary of instance state from pickling
"""
self.__dict__.update(state)
[docs]
def add_variable(self, var: LXVariable) -> Self:
"""
Add variable with full type checking.
Args:
var: Variable to add
Returns:
Self for chaining
"""
self.variables.append(var)
return self
[docs]
def add_variables(self, *variables: LXVariable) -> Self:
"""
Add multiple variable families.
Args:
*variables: Variables to add
Returns:
Self for chaining
"""
self.variables.extend(variables)
return self
[docs]
def add_constraint(self, constraint: LXConstraint) -> Self:
"""
Add constraint with full type checking.
Args:
constraint: Constraint to add
Returns:
Self for chaining
"""
self.constraints.append(constraint)
return self
[docs]
def add_constraints(self, *constraints: LXConstraint) -> Self:
"""
Add multiple constraints.
Args:
*constraints: Constraints to add
Returns:
Self for chaining
"""
self.constraints.extend(constraints)
return self
[docs]
def minimize(self, expr: LXLinearExpression | LXQuadraticExpression) -> Self:
"""
Set objective to minimize.
Args:
expr: Objective expression (linear or quadratic)
Returns:
Self for chaining
"""
self.objective_sense = LXObjectiveSense.MINIMIZE
self.objective_expr = expr
return self
[docs]
def maximize(self, expr: LXLinearExpression | LXQuadraticExpression) -> Self:
"""
Set objective to maximize.
Args:
expr: Objective expression (linear or quadratic)
Returns:
Self for chaining
"""
self.objective_sense = LXObjectiveSense.MAXIMIZE
self.objective_expr = expr
return self
[docs]
def get_variable(self, name: str) -> Optional[LXVariable]:
"""
Get variable family by name.
Args:
name: Variable name
Returns:
LXVariable if found, None otherwise
"""
for var in self.variables:
if var.name == name:
return var
return None
[docs]
def get_constraint(self, name: str) -> Optional[LXConstraint]:
"""
Get constraint by name.
Args:
name: Constraint name
Returns:
LXConstraint if found, None otherwise
"""
for const in self.constraints:
if const.name == name:
return const
return None
[docs]
def set_goal_mode(self, mode: str) -> Self:
"""
Set goal programming mode.
Args:
mode: Goal programming mode ("weighted" or "sequential")
Returns:
Self for chaining
Raises:
ValueError: If mode is not "weighted" or "sequential"
Example:
>>> model.set_goal_mode("weighted")
>>> # Solve with weighted objectives (single solve)
>>> model.set_goal_mode("sequential")
>>> # Solve lexicographically (multiple solves)
"""
if mode not in ("weighted", "sequential"):
raise ValueError(
f"Invalid goal mode: {mode}. Must be 'weighted' or 'sequential'"
)
self.goal_mode = mode
return self
[docs]
def prepare_goal_programming(self) -> Self:
"""
Prepare model for goal programming by relaxing goal constraints.
This method:
1. Identifies constraints marked as goals (via .as_goal())
2. Relaxes them by adding deviation variables
3. Builds the appropriate objective function based on mode
4. Adds deviation variables to the model
5. Replaces goal constraints with relaxed versions
This is automatically called by the solver, but can be called
manually for inspection or debugging.
Returns:
Self for chaining
Example:
>>> model.set_goal_mode("weighted")
>>> model.prepare_goal_programming()
>>> # Model now has deviation variables and goal objective
"""
if self._goal_programming_prepared:
# Already prepared, skip
return self
# Import here to avoid circular dependencies
from ..goal_programming.goal import LXGoalMode
from ..goal_programming.objective_builder import build_weighted_objective
from ..goal_programming.relaxation import relax_constraint
# Collect goal constraints
goal_constraints = [c for c in self.constraints if c.is_goal()]
if not goal_constraints:
# No goals, nothing to do
return self
# Determine mode (default to weighted if not set)
if self.goal_mode is None:
self.goal_mode = "weighted"
# Relax each goal constraint
self._relaxed_constraints = []
regular_constraints = []
for constraint in self.constraints:
if constraint.is_goal():
# Relax this constraint
relaxed = relax_constraint(constraint, constraint.goal_metadata)
self._relaxed_constraints.append(relaxed)
# Add deviation variables to model
self.add_variable(relaxed.pos_deviation)
self.add_variable(relaxed.neg_deviation)
# Add relaxed constraint (equality with deviations)
regular_constraints.append(relaxed.constraint)
else:
# Keep regular constraint as-is
regular_constraints.append(constraint)
# Replace constraints with relaxed versions
self.constraints = regular_constraints
# Build objective based on mode
if self.goal_mode == "weighted":
# Build weighted objective
goal_objective = build_weighted_objective(self._relaxed_constraints)
# Set as objective (or combine with existing if present)
if self.objective_expr is None:
# No existing objective, use goal objective
self.minimize(goal_objective)
else:
# Combine with existing objective
# For now, we'll just replace it (user can handle this manually)
# Future enhancement: provide combine_objectives helper
self.minimize(goal_objective)
# For sequential mode, objective will be set during solving
# (handled by LXGoalProgrammingSolver)
self._goal_programming_prepared = True
return self
[docs]
def has_goals(self) -> bool:
"""
Check if model has any goal constraints.
Returns:
True if at least one constraint is marked as a goal
"""
return any(c.is_goal() for c in self.constraints)
[docs]
def populate_goal_deviations(self, solution: "LXSolution") -> "LXSolution":
"""
Populate goal deviation values in the solution.
Extracts deviation variable values from the solution and organizes
them by goal name for easy access via solution.get_goal_deviations().
This method is automatically called after solving if the model has goals.
Args:
solution: Solution object to populate
Returns:
The solution object with goal_deviations populated
"""
if not self._goal_programming_prepared or not self._relaxed_constraints:
return solution
# Import here to avoid circular dependency
from ..solution.solution import LXSolution
# Extract deviation values for each relaxed constraint
for relaxed in self._relaxed_constraints:
constraint_name = relaxed.constraint.name
# Get positive and negative deviation values
pos_dev_values = solution.get_variable(relaxed.pos_deviation)
neg_dev_values = solution.get_variable(relaxed.neg_deviation)
# Store in solution's goal_deviations
solution.goal_deviations[constraint_name] = {
"pos": pos_dev_values,
"neg": neg_dev_values,
}
return solution
[docs]
def summary(self) -> str:
"""
Get model summary.
Returns:
String summary of model
"""
summary_str = (
f"LXModel: {self.name}\n"
f" Variable Families: {len(self.variables)}\n"
f" Constraint Families: {len(self.constraints)}\n"
f" Objective: {self.objective_sense.value}\n"
)
# Add goal programming info if applicable
if self.has_goals():
goal_count = sum(1 for c in self.constraints if c.is_goal())
summary_str += f" Goal Constraints: {goal_count}\n"
if self.goal_mode:
summary_str += f" Goal Mode: {self.goal_mode}\n"
return summary_str
__all__ = ["LXModel"]