"""
Constraint Module for OptiX Optimization Framework
===================================================
This module provides constraint classes for representing and managing linear constraints
in optimization problems. It implements standard linear constraints and goal programming
constraints with support for multiple relational operators, deviation variables, and
comprehensive scenario management for sensitivity analysis.
The module is a core component of the OptiX framework's constraint system, enabling
the definition of mathematical relationships between variables that must be satisfied
by the optimization solver, with the ability to analyze these constraints under
different parameter scenarios.
Classes:
RelationalOperators: Enumeration of comparison operators (>, >=, =, <, <=)
OXConstraint: Standard linear constraint with expression, relational operator, and scenario support
OXGoalConstraint: Goal programming constraint with positive and negative deviation variables
Key Features:
- **Scenario-Based Constraint Management**: Support for multiple constraint scenarios
with different RHS values, operators, and names for comprehensive sensitivity analysis
- **Dynamic Attribute Access**: Transparent scenario switching for constraint parameters
enabling seamless what-if analysis without constraint object duplication
- Support for all standard relational operators
- Automatic conversion from regular constraints to goal constraints
- Fraction-based arithmetic for precise coefficient handling
- Integration with OXpression for complex mathematical expressions
- Deviation variable management for goal programming
Module Dependencies:
- dataclasses: For structured constraint definitions
- enum: For relational operator enumeration
- fractions: For precise arithmetic operations
- base: For core OptiX object system
- constraints.OXpression: For mathematical expression handling
- variables.OXDeviationVar: For goal programming deviation variables
Example:
Basic constraint creation and scenario-based usage:
.. code-block:: python
from constraints import OXConstraint, OXpression, RelationalOperators
from variables import OXVariable
# Create variables
x = OXVariable(name="x", lower_bound=0)
y = OXVariable(name="y", lower_bound=0)
# Create expression: 2x + 3y
expr = OXpression(variables=[x.id, y.id], weights=[2, 3])
# Create constraint with base scenario: 2x + 3y <= 10
constraint = OXConstraint(
expression=expr,
relational_operator=RelationalOperators.LESS_THAN_EQUAL,
rhs=10,
name="Base capacity constraint"
)
# Create scenarios for sensitivity analysis
constraint.create_scenario("High_Capacity", rhs=15, name="Expanded capacity")
constraint.create_scenario("Low_Capacity", rhs=8, name="Reduced capacity")
constraint.create_scenario("Equality",
relational_operator=RelationalOperators.EQUAL,
rhs=12,
name="Exact capacity requirement"
)
# Switch between scenarios
print(f"Base: {constraint.rhs}") # 10
constraint.active_scenario = "High_Capacity"
print(f"High: {constraint.rhs} - {constraint.name}") # 15 - Expanded capacity
constraint.active_scenario = "Equality"
print(f"Equal: {constraint.rhs}, Op: {constraint.relational_operator}") # 12, =
# Convert to goal constraint for goal programming
goal_constraint = constraint.to_goal()
print(goal_constraint.negative_deviation_variable.desired) # True
"""
from dataclasses import dataclass, field, fields
from enum import StrEnum
from fractions import Fraction
from typing import Any
from base import OXObject, OXception
from .OXpression import OXpression
from variables.OXDeviationVar import OXDeviationVar
#: List of field names that are excluded from scenario management to prevent infinite loops
#: and maintain object integrity. These fields are always accessed from the base object.
NON_SCENARIO_FIELDS = ["active_scenario", "scenarios", "id", "class_name", "positive_deviation_variable", "negative_deviation_variable"]
[docs]
class RelationalOperators(StrEnum):
"""Enumeration of relational operators for constraints.
These operators define the relationship between the left-hand side (expression)
and right-hand side (rhs) of a constraint.
Attributes:
GREATER_THAN (str): The ">" operator.
GREATER_THAN_EQUAL (str): The ">=" operator.
EQUAL (str): The "=" operator.
LESS_THAN (str): The "<" operator.
LESS_THAN_EQUAL (str): The "<=" operator.
"""
GREATER_THAN = ">"
GREATER_THAN_EQUAL = ">="
EQUAL = "="
LESS_THAN = "<"
LESS_THAN_EQUAL = "<="
[docs]
@dataclass
class OXConstraint(OXObject):
"""A constraint in an optimization problem with scenario support.
A constraint represents a relationship between an expression and a value,
such as "2x + 3y <= 10". This class supports multiple scenarios, allowing
different constraint parameters (RHS values, names, operators) to be defined
for different optimization scenarios.
The scenario system enables sensitivity analysis and what-if modeling by
maintaining multiple constraint configurations within the same constraint object.
Attributes:
expression (OXpression): The left-hand side of the constraint.
relational_operator (RelationalOperators): The operator (>, >=, =, <, <=).
rhs (float | int): The right-hand side value.
name (str): A descriptive name for the constraint.
active_scenario (str): The name of the currently active scenario.
scenarios (dict[str, dict[str, Any]]): Dictionary mapping scenario names
to dictionaries of attribute values for that scenario.
Examples:
Basic constraint creation:
>>> from constraints.OXpression import OXpression
>>> expr = OXpression(variables=[x.id, y.id], weights=[2, 3])
>>> constraint = OXConstraint(
... expression=expr,
... relational_operator=RelationalOperators.LESS_THAN_EQUAL,
... rhs=10,
... name="Capacity constraint"
... )
Scenario-based constraint management:
>>> # Create constraint with base values
>>> constraint = OXConstraint(
... expression=expr,
... relational_operator=RelationalOperators.LESS_THAN_EQUAL,
... rhs=100,
... name="Production capacity"
... )
>>>
>>> # Create scenarios with different RHS values
>>> constraint.create_scenario("High_Capacity", rhs=150, name="High capacity scenario")
>>> constraint.create_scenario("Low_Capacity", rhs=80, name="Reduced capacity scenario")
>>>
>>> # Switch between scenarios
>>> print(constraint.rhs) # 100 (Default scenario)
>>>
>>> constraint.active_scenario = "High_Capacity"
>>> print(constraint.rhs) # 150
>>> print(constraint.name) # "High capacity scenario"
>>>
>>> constraint.active_scenario = "Low_Capacity"
>>> print(constraint.rhs) # 80
"""
expression: OXpression = field(default_factory=OXpression)
relational_operator: RelationalOperators = RelationalOperators.EQUAL
rhs: float | int = 0
name: str = ""
active_scenario: str = "Default"
scenarios: dict[str, dict[str, Any]] = field(default_factory=dict)
[docs]
def __getattribute__(self, item):
"""Custom attribute access that checks the active scenario first.
When an attribute is accessed, this method first checks if it exists
in the active scenario, and if not, falls back to the object's own attribute.
This enables transparent scenario switching for constraint parameters.
Args:
item (str): The name of the attribute to access.
Returns:
Any: The value of the attribute in the active scenario, or the
object's own attribute if not found in the active scenario.
Examples:
>>> constraint = OXConstraint(rhs=100)
>>> constraint.create_scenario("High_RHS", rhs=150)
>>> print(constraint.rhs) # 100 (Default)
>>> constraint.active_scenario = "High_RHS"
>>> print(constraint.rhs) # 150 (from scenario)
"""
if item in NON_SCENARIO_FIELDS: # Prevent Infinite Loop!
return super().__getattribute__(item)
active_scenario_values = self.scenarios.get(self.active_scenario, {})
if len(active_scenario_values) > 0:
if item in active_scenario_values:
return active_scenario_values[item]
return super().__getattribute__(item)
[docs]
def create_scenario(self, scenario_name: str, **kwargs):
"""Create a new scenario with the specified constraint attribute values.
If the "Default" scenario doesn't exist yet, it is created first,
capturing the constraint's current attribute values. This enables
systematic scenario-based analysis while preserving the original
constraint configuration.
Args:
scenario_name (str): The name of the new scenario.
**kwargs: Constraint attribute-value pairs for the new scenario.
Common attributes include:
- rhs (float | int): Right-hand side value
- name (str): Constraint name for this scenario
- relational_operator (RelationalOperators): Constraint operator
Raises:
OXception: If an attribute in kwargs doesn't exist in the constraint object.
Examples:
Creating RHS scenarios for sensitivity analysis:
>>> constraint = OXConstraint(
... expression=expr,
... relational_operator=RelationalOperators.LESS_THAN_EQUAL,
... rhs=100,
... name="Base capacity"
... )
>>>
>>> # Create scenarios with different RHS values
>>> constraint.create_scenario("High_Demand", rhs=150, name="Peak capacity")
>>> constraint.create_scenario("Low_Demand", rhs=75, name="Reduced capacity")
>>> constraint.create_scenario("Critical", rhs=200, name="Emergency capacity")
>>>
>>> # Switch scenarios and access values
>>> constraint.active_scenario = "High_Demand"
>>> print(f"RHS: {constraint.rhs}, Name: {constraint.name}")
>>> # Output: RHS: 150, Name: Peak capacity
Creating operator scenarios for constraint type analysis:
>>> constraint.create_scenario("Equality",
... relational_operator=RelationalOperators.EQUAL,
... name="Exact capacity requirement"
... )
>>> constraint.create_scenario("Lower_Bound",
... relational_operator=RelationalOperators.GREATER_THAN_EQUAL,
... name="Minimum capacity requirement"
... )
"""
if 'Default' not in self.scenarios:
self.scenarios['Default'] = {}
obj_fields = fields(self)
for field in obj_fields:
if field.name not in NON_SCENARIO_FIELDS:
self.scenarios['Default'][field.name] = getattr(self, field.name)
self.scenarios[scenario_name] = {}
for key, value in kwargs.items():
if key not in NON_SCENARIO_FIELDS:
if hasattr(self, key):
self.scenarios[scenario_name][key] = value
else:
raise OXception(f"Constraint {self} has no attribute {key}")
[docs]
def reverse(self):
"""Reverse the relational operator of the constraint.
This method changes the relational operator to its opposite:
- GREATER_THAN becomes LESS_THAN
- GREATER_THAN_EQUAL becomes LESS_THAN_EQUAL
- EQUAL remains EQUAL
- LESS_THAN becomes GREATER_THAN
- LESS_THAN_EQUAL becomes GREATER_THAN_EQUAL
Returns:
OXConstraint: A new constraint with the reversed operator.
"""
if self.relational_operator == RelationalOperators.EQUAL:
raise OXception("Cannot reverse an equality constraint.")
reversed_operator = {
RelationalOperators.GREATER_THAN: RelationalOperators.LESS_THAN_EQUAL,
RelationalOperators.GREATER_THAN_EQUAL: RelationalOperators.LESS_THAN,
RelationalOperators.LESS_THAN: RelationalOperators.GREATER_THAN_EQUAL,
RelationalOperators.LESS_THAN_EQUAL: RelationalOperators.GREATER_THAN
}[self.relational_operator]
return OXConstraint(
expression=self.expression,
relational_operator=reversed_operator,
rhs=self.rhs,
name=f"Inverse of {self.name}"
)
@property
def rhs_numerator(self):
"""Get the numerator of the right-hand side as a fraction.
Returns:
int: The numerator of the right-hand side.
"""
return Fraction(self.rhs).numerator
@property
def rhs_denominator(self):
"""Get the denominator of the right-hand side as a fraction.
Returns:
int: The denominator of the right-hand side.
"""
return Fraction(self.rhs).denominator
[docs]
def to_goal(self, upper_bound: int | float | Fraction = 100) -> "OXGoalConstraint":
"""Convert this constraint to a goal constraint for goal programming.
The conversion sets the relational operator to EQUAL and sets the
desired deviation variables based on the original operator.
Returns:
OXGoalConstraint: A new goal constraint based on this constraint.
See Also:
:class:`OXGoalConstraint`
"""
result = OXGoalConstraint()
result.expression = self.expression
result.relational_operator = RelationalOperators.EQUAL
result.rhs = self.rhs
result.name = self.name
result.positive_deviation_variable.name = f"Positive deviation of {self.name}"
result.negative_deviation_variable.name = f"Negative deviation of {self.name}"
if self.relational_operator in [RelationalOperators.LESS_THAN, RelationalOperators.LESS_THAN_EQUAL]:
result.negative_deviation_variable.desired = True
result.negative_deviation_variable.upper_bound = upper_bound
result.positive_deviation_variable.upper_bound = upper_bound
elif self.relational_operator in [RelationalOperators.GREATER_THAN, RelationalOperators.GREATER_THAN_EQUAL]:
result.positive_deviation_variable.desired = True
result.positive_deviation_variable.upper_bound = upper_bound
result.negative_deviation_variable.upper_bound = upper_bound
return result
[docs]
@dataclass
class OXGoalConstraint(OXConstraint):
"""A goal constraint for goal programming.
A goal constraint extends a regular constraint by adding deviation variables
that measure how much the constraint is violated. In goal programming, the
objective is typically to minimize undesired deviations.
Attributes:
positive_deviation_variable (OXDeviationVar): The variable representing
positive deviation from the goal.
negative_deviation_variable (OXDeviationVar): The variable representing
negative deviation from the goal.
Examples:
>>> goal = constraint.to_goal()
>>> print(goal.positive_deviation_variable.desired)
False
>>> print(goal.negative_deviation_variable.desired)
True
See Also:
:class:`OXConstraint`
:class:`variables.OXDeviationVar.OXDeviationVar`
"""
positive_deviation_variable: OXDeviationVar = field(default_factory=OXDeviationVar)
negative_deviation_variable: OXDeviationVar = field(default_factory=OXDeviationVar)
@property
def desired_variables(self) -> list[OXDeviationVar]:
"""Get the list of desired deviation variables.
Returns:
list[OXDeviationVar]: A list of deviation variables marked as desired.
"""
result = []
if self.positive_deviation_variable.desired:
result.append(self.positive_deviation_variable)
if self.negative_deviation_variable.desired:
result.append(self.negative_deviation_variable)
return result
@property
def undesired_variables(self) -> list[OXDeviationVar]:
"""Get the list of undesired deviation variables.
Returns:
list[OXDeviationVar]: A list of deviation variables not marked as desired.
"""
result = []
if not self.positive_deviation_variable.desired:
result.append(self.positive_deviation_variable)
if not self.negative_deviation_variable.desired:
result.append(self.negative_deviation_variable)
return result