"""Solution classes for LumiX."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Dict, Generic, Optional, TypeVar, Union
from ..core.variables import LXVariable
if TYPE_CHECKING:
from ..core.model import LXModel
from ..visualization.solution import LXSolutionVisualizer
TModel = TypeVar("TModel")
TValue = TypeVar("TValue", int, float)
TIndex = TypeVar("TIndex")
[docs]
@dataclass
class LXSolution(Generic[TModel]):
"""
Type-safe solution with automatic mapping.
Provides access to:
- Variable values (by name or LXVariable object)
- Mapped values (variables mapped by index keys)
- Shadow prices (dual values for constraints)
- Reduced costs (for sensitivity analysis)
Examples:
Basic usage::
solution = optimizer.solve(model)
# Access by variable name
prod_value = solution.variables["production"]
# Access by LXVariable object
prod_value = solution.get_variable(production)
# Access multi-indexed variables
duty_value = solution.variables["duty"][(driver_id, date)]
# Access mapped values (indexed by keys)
for key, value in solution.get_mapped(duty).items():
if value > 0.5:
print(f"Variable {key} = {value}")
"""
objective_value: float
status: str
solve_time: float
# Type-safe variable values
variables: Dict[str, Union[float, Dict[Any, float]]] = field(default_factory=dict)
# Mapped values by index keys (same structure as variables)
# Note: Uses index keys (e.g., product.id) not model instances
# to avoid hashability issues with non-frozen dataclasses
mapped: Dict[str, Dict[Any, float]] = field(default_factory=dict)
# Sensitivity data
shadow_prices: Dict[str, float] = field(default_factory=dict)
reduced_costs: Dict[str, float] = field(default_factory=dict)
# Solver-specific information
gap: Optional[float] = None
iterations: Optional[int] = None
nodes: Optional[int] = None
# Goal programming information
goal_deviations: Dict[str, Dict[str, Union[float, Dict[Any, float]]]] = field(
default_factory=dict
)
[docs]
def get_variable(self, var: LXVariable[TModel, TValue]) -> Union[TValue, Dict[Any, TValue]]:
"""
Get variable value with full type inference.
Args:
var: LXVariable to get value for
Returns:
Variable value (scalar or dict for indexed variables)
"""
return self.variables.get(var.name, 0) # type: ignore
[docs]
def get_mapped(self, var: LXVariable[TModel, TValue]) -> Dict[Any, TValue]:
"""
Get values mapped by index keys.
Returns the same structure as variables, indexed by the keys
extracted via the variable's index_func (e.g., product.id).
Note:
This returns index keys, not model instances, to avoid
hashability issues with non-frozen dataclasses.
Args:
var: LXVariable to get mapped values for
Returns:
Dictionary mapping index keys to values
Examples:
For production indexed by product.id::
for product_id, qty in solution.get_mapped(production).items():
print(f"Product {product_id}: {qty} units")
"""
return self.mapped.get(var.name, {}) # type: ignore
[docs]
def get_shadow_price(self, constraint_name: str) -> Optional[float]:
"""
Get shadow price (dual value) for constraint.
Args:
constraint_name: Constraint name
Returns:
Shadow price if available
"""
return self.shadow_prices.get(constraint_name)
[docs]
def get_reduced_cost(self, var_name: str) -> Optional[float]:
"""
Get reduced cost for variable.
Args:
var_name: Variable name
Returns:
Reduced cost if available
"""
return self.reduced_costs.get(var_name)
[docs]
def get_goal_deviations(
self, goal_name: str
) -> Optional[Dict[str, Union[float, Dict[Any, float]]]]:
"""
Get deviation values for a goal constraint.
Returns both positive and negative deviations for the specified goal.
Args:
goal_name: Name of the goal constraint
Returns:
Dictionary with keys 'pos' and 'neg' containing deviation values,
or None if goal not found
Example:
>>> deviations = solution.get_goal_deviations("production_target")
>>> pos_dev = deviations["pos"] # Over-production
>>> neg_dev = deviations["neg"] # Under-production
"""
return self.goal_deviations.get(goal_name)
[docs]
def is_goal_satisfied(
self, goal_name: str, tolerance: float = 1e-6
) -> Optional[bool]:
"""
Check if a goal is satisfied within tolerance.
A goal is satisfied if both positive and negative deviations are
within the specified tolerance.
Args:
goal_name: Name of the goal constraint
tolerance: Tolerance for deviation (default: 1e-6)
Returns:
True if goal is satisfied, False if not, None if goal not found
Example:
>>> if solution.is_goal_satisfied("demand_goal", tolerance=0.01):
... print("Demand goal achieved!")
"""
deviations = self.get_goal_deviations(goal_name)
if deviations is None:
return None
pos_dev = deviations.get("pos", 0)
neg_dev = deviations.get("neg", 0)
# Handle both scalar and dict values
if isinstance(pos_dev, dict):
pos_satisfied = all(abs(v) <= tolerance for v in pos_dev.values())
else:
pos_satisfied = abs(pos_dev) <= tolerance
if isinstance(neg_dev, dict):
neg_satisfied = all(abs(v) <= tolerance for v in neg_dev.values())
else:
neg_satisfied = abs(neg_dev) <= tolerance
return pos_satisfied and neg_satisfied
[docs]
def get_total_deviation(self, goal_name: str) -> Optional[float]:
"""
Get total absolute deviation for a goal.
Sum of absolute values of all positive and negative deviations.
Args:
goal_name: Name of the goal constraint
Returns:
Total deviation, or None if goal not found
Example:
>>> total_dev = solution.get_total_deviation("production_target")
>>> print(f"Total deviation: {total_dev}")
"""
deviations = self.get_goal_deviations(goal_name)
if deviations is None:
return None
total = 0.0
for dev_type in ["pos", "neg"]:
dev = deviations.get(dev_type, 0)
if isinstance(dev, dict):
total += sum(abs(v) for v in dev.values())
else:
total += abs(dev)
return total
[docs]
def is_optimal(self) -> bool:
"""Check if solution is optimal."""
return self.status.lower() in ["optimal", "opt_optimal"]
[docs]
def is_feasible(self) -> bool:
"""Check if solution is feasible."""
return self.status.lower() in ["optimal", "feasible", "opt_optimal"]
[docs]
def visualize(
self, model: "Optional[LXModel[TModel]]" = None
) -> "LXSolutionVisualizer[TModel]":
"""
Create interactive visualization for this solution.
Requires the visualization extra: pip install lumix-opt[viz]
Args:
model: Optional optimization model (for constraint info)
Returns:
LXSolutionVisualizer instance
Examples:
Basic usage::
solution.visualize().show()
With model for constraint details::
solution.visualize(model).show()
Export to HTML::
solution.visualize(model).to_html("solution.html")
"""
from ..visualization import LXSolutionVisualizer
return LXSolutionVisualizer(self, model)
[docs]
def summary(self) -> str:
"""
Get solution summary.
Returns:
String summary
"""
nonzero = sum(
1
for v in self.variables.values()
if (isinstance(v, dict) and any(abs(val) > 1e-6 for val in v.values()))
or (isinstance(v, (int, float)) and abs(v) > 1e-6)
)
summary_lines = [
f"Status: {self.status}",
f"Objective: {self.objective_value:.6f}",
f"Solve time: {self.solve_time:.3f}s",
f"Non-zero variables: {nonzero}/{len(self.variables)}",
]
if self.gap is not None:
summary_lines.append(f"Gap: {self.gap * 100:.2f}%")
if self.iterations is not None:
summary_lines.append(f"Iterations: {self.iterations}")
if self.nodes is not None:
summary_lines.append(f"Nodes: {self.nodes}")
# Add goal programming summary
if self.goal_deviations:
summary_lines.append(f"\nGoal Constraints: {len(self.goal_deviations)}")
satisfied = sum(
1
for goal_name in self.goal_deviations.keys()
if self.is_goal_satisfied(goal_name, tolerance=1e-6)
)
summary_lines.append(
f"Goals Satisfied: {satisfied}/{len(self.goal_deviations)}"
)
return "\n".join(summary_lines)
__all__ = ["LXSolution"]