Source code for commonpower.modeling.mip_builder

"""
Functionality for creating MIP expressions and robust constraints.
"""
from __future__ import annotations

import random
from typing import Union

import pyomo.environ as pyo
from pyomo.core import ConcreteModel, Param, Var

from commonpower.modeling.base import ElementTypes, ModelElement, ModelEntity
from commonpower.modeling.robust_constraints import ConstraintScenario


[docs] class MIPExpressionBuilder: def __init__(self, entity: ModelEntity, M: int = 1e3, eps: float = 1e-5): """ The expression builder allows to convert logical expression into mixed integer constraints. In the process it also creates all necessary auxiliary variables. The structure of of interface is as follows: - Create expression builder instance. - Generate expressions. Constraints and an output variable are created (or an existing output variable referenced) for each expression. The corresponding ModelElements are internally stored in self.model_elements. - Obtain all generated ModelElements from self.model_elements. The MIP conversions here are based on @article{brown2007formulating, title={Formulating integer linear programs: A rogues' gallery}, author={Brown, Gerald G and Dell, Robert F}, journal={INFORMS Transactions on Education}, volume={7}, number={2}, pages={153--159}, year={2007}, publisher={INFORMS} } IMPORTANT NOTE: The Integrality Tolerance of the used solver has to be set such that IntFeasTol * M < eps for all the constraints to work correctly. Args: entity (ModelEntity): Entity used to obtain referenced pyomo model elements from. M (int, optional): Constant for bigM constraints. Defaults to 1e3. eps (float, optional): Slack value for strict inequalities (pyomo only allows for <=, >=, ==). Defaults to 1e-5. """ self.vars = [] self.model_elements = [] self.entity = entity self.M = M self.eps = eps # this is a slack value because pyomo only allows for <=, >=, ==
[docs] def from_geq( self, a: Union[str, int, float], b: Union[str, int, float], out: str = None, is_new: bool = True, M: int = None ) -> str: """ Generates constraints based on: \\ out = 1 if a >= b, out = 0 otherwise. The MILP formulation using bigM constraints is: \\ a >= b - M*(1-out) \\ a < b + M*out (we use: a + eps <= b + M*out) Args: a (Union[str, int, float]): Left hand side of the inequality. b (Union[str, int, float]): Right hand side of the inequality. out (str, optional): Name of the output variable. If not given, a name is autogenerated ('aux_' + 5 hex characters). is_new (bool, optional): Indicates if the variable is new. If so, a corresponding ModelElement added. Defaults to True. M (int, optional): Constant M for bigM type constraints. If not given, the class' M is used. Returns: str: Name of the output variable """ if not out: is_new = True # just in case someone tried to be nasty... out = "aux_" + "%05x" % random.randrange(16**5) # 5 hex characters if is_new: self.model_elements.append( ModelElement(out, ElementTypes.VAR, "auxiliary binary variable", domain=pyo.Binary) ) if not M: M = self.M def cbin1(scenario: ConstraintScenario): def cbin1_f(model, t): return self._parse_var(scenario, a, model, t) >= self._parse_var(scenario, b, model, t) - M * ( 1 - self._parse_out_var(scenario, out, model, t) ) return cbin1_f def cbin2(scenario: ConstraintScenario): def cbin2_f(model, t): return self._parse_var(scenario, a, model, t) + self.eps <= self._parse_var( scenario, b, model, t ) + M * self._parse_out_var(scenario, out, model, t) return cbin2_f self.model_elements.append( ModelElement( f"cmilp_{out}_1", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin1, ) ) self.model_elements.append( ModelElement( f"cmilp_{out}_2", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin2, ) ) return out
[docs] def from_gt( self, a: Union[str, int, float], b: Union[str, int, float], out: str = None, is_new: bool = True, M: int = None ) -> str: """ Generates constraints based on: \\ out = 1 if a > b, out = 0 otherwise. The MILP formulation using bigM constraints is: \\ a - eps >= b - M*(1-out) \\ a <= b + M*out Args: a (Union[str, int, float]): Left hand side of the inequality. b (Union[str, int, float]): Right hand side of the inequality. out (str, optional): Name of the output variable. If not given, a name is autogenerated. is_new (bool, optional): Indicates if the variable is new. If so, a corresponding ModelElement added. Defaults to True. M (int, optional): Constant M for bigM type constraints. If not given, the class' M is used. Returns: str: Name of the output variable """ if not out: is_new = True # just in case... out = "aux_" + "%05x" % random.randrange(16**5) if is_new: self.model_elements.append( ModelElement(out, ElementTypes.VAR, "auxiliary binary variable", domain=pyo.Binary) ) if not M: M = self.M def cbin1(scenario: ConstraintScenario): def cbin1_f(model, t): return self._parse_var(scenario, a, model, t) - self.eps >= self._parse_var( scenario, b, model, t ) - M * (1 - self._parse_out_var(scenario, out, model, t)) return cbin1_f def cbin2(scenario: ConstraintScenario): def cbin2_f(model, t): return self._parse_var(scenario, a, model, t) <= self._parse_var( scenario, b, model, t ) + M * self._parse_out_var(scenario, out, model, t) return cbin2_f self.model_elements.append( ModelElement( f"cmilp_{out}_1", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin1, ) ) self.model_elements.append( ModelElement( f"cmilp_{out}_2", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin2, ) ) return out
[docs] def from_and(self, a: str, b: str, out: str = None, is_new: bool = True) -> str: """ Generates constraints based on: \\ out = 1 if (a and b), out = 0 otherwise. The MILP formulation is: \\ out >= a + b - 1 \\ out <= a \\ out <= b Args: a (str): Variable 1 (must be binary). b (str): Variable 2 (must be binary). out (str, optional): Name of the output variable. If not given, a name is autogenerated. is_new (bool, optional): Indicates if the variable is new. If so, a corresponding ModelElement added. Defaults to True. If not given, a name is autogenerated and a corresponding ModelElement added. Returns: str: Name of the output variable. """ if not out: is_new = True # just in case... out = "aux_" + "%05x" % random.randrange(16**5) if is_new: self.model_elements.append( ModelElement(out, ElementTypes.VAR, "auxiliary binary variable", domain=pyo.Binary) ) def cbin1(scenario: ConstraintScenario): def cbin1_f(model, t): return ( self._parse_out_var(scenario, out, model, t) >= self._parse_var(scenario, a, model, t) + self._parse_var(scenario, b, model, t) - 1 ) return cbin1_f def cbin2(scenario: ConstraintScenario): def cbin2_f(model, t): return self._parse_out_var(scenario, out, model, t) <= self._parse_var(scenario, a, model, t) return cbin2_f def cbin3(scenario: ConstraintScenario): def cbin3_f(model, t): return self._parse_out_var(scenario, out, model, t) <= self._parse_var(scenario, b, model, t) return cbin3_f self.model_elements.append( ModelElement( f"cmilp_{out}_1", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin1, ) ) self.model_elements.append( ModelElement( f"cmilp_{out}_2", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin2, ) ) self.model_elements.append( ModelElement( f"cmilp_{out}_3", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin3, ) ) return out
[docs] def from_or(self, a: str, b: str, out: str = None, is_new: bool = True) -> str: """ Generates constraints based on: \\ out = 1 if (a or b), out = 0 otherwise. The MILP formulation is: \\ out <= a + b \\ out >= a \\ out >= b Args: a (str): Variable 1 (must be binary). b (str): Variable 2 (must be binary). out (str, optional): Name of the output variable. If not given, a name is autogenerated. is_new (bool, optional): Indicates if the variable is new. If so, a corresponding ModelElement added. Defaults to True. If not given, a name is autogenerated and a corresponding ModelElement added. Returns: str: Name of the output variable. """ if not out: is_new = True # just in case... out = "aux_" + "%05x" % random.randrange(16**5) if is_new: self.model_elements.append( ModelElement(out, ElementTypes.VAR, "auxiliary binary variable", domain=pyo.Binary) ) def cbin1(scenario: ConstraintScenario): def cbin1_f(model, t): return self._parse_out_var(scenario, out, model, t) <= self._parse_var( scenario, a, model, t ) + self._parse_var(scenario, b, model, t) return cbin1_f def cbin2(scenario: ConstraintScenario): def cbin2_f(model, t): return self._parse_out_var(scenario, out, model, t) >= self._parse_var(scenario, a, model, t) return cbin2_f def cbin3(scenario: ConstraintScenario): def cbin3_f(model, t): return self._parse_out_var(scenario, out, model, t) >= self._parse_var(scenario, b, model, t) return cbin3_f self.model_elements.append( ModelElement( f"cmilp_{out}_1", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin1, ) ) self.model_elements.append( ModelElement( f"cmilp_{out}_2", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin2, ) ) self.model_elements.append( ModelElement( f"cmilp_{out}_3", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin3, ) ) return out
[docs] def from_not(self, a: str, out: str = None, is_new: bool = True) -> str: """ Generates a constraint based on: \\ out = 1 - a The MILP formulation is: \\ out == 1 - a Args: a (str): Variable 1 (must be binary) out (str, optional): Name of the output variable. If not given, a name is autogenerated. is_new (bool, optional): Indicates if the variable is new. If so, a corresponding ModelElement added. Defaults to True. If not given, a name is autogenerated and a corresponding ModelElement added. Returns: str: Name of the output variable. """ if not out: is_new = True # just in case... out = "aux_" + "%05x" % random.randrange(16**5) if is_new: self.model_elements.append( ModelElement(out, ElementTypes.VAR, "auxiliary binary variable", domain=pyo.Binary) ) def cbin1(scenario: ConstraintScenario): def cbin1_f(model, t): return self._parse_out_var(scenario, out, model, t) == 1 - self._parse_var(scenario, a, model, t) return cbin1_f self.model_elements.append( ModelElement( f"cmilp_{out}_1", ElementTypes.ROBUST_CONSTRAINT, f"constrain auxiliary binary variable {out}", expr=cbin1, ) ) return out
[docs] def enforce_value(self, a: str, val: Union[int, float]) -> None: """ Generates an always-true constraint for a: \\ val == a Args: a (str): Variable 1. val (Union[int, float]): value the variable should be set to. """ def cbin1(scenario: ConstraintScenario): def cbin1_f(model, t): return self._parse_var(scenario, a, model, t) == val return cbin1_f self.model_elements.append( ModelElement( f"cenf_{a}", ElementTypes.ROBUST_CONSTRAINT, f"constrain binary variable {a} to {val}", expr=cbin1 ) )
[docs] def _parse_var( self, scenario: ConstraintScenario, var: Union[str, int, float, list[int], list[float]], model: ConcreteModel, t: int, expand_variable: bool = False, ) -> Union[Union[Var, Param], int, float]: """ Helper method to access elements from self.entity's pyomo model. Args: scenario (ConstraintScenario): Scenario to access. var (Union[str, int, float, list[int], list[float]]): Either variable name or constant value. model (ConcreteModel): Pyomo model to access. t (int): Time index. expand_variable (bool, optional): Tells the scenario that this variable should be expanded for robustness. Defaults to False. Returns: Union[Union[Var, Param], int, float]: Either pyomo element or constant value. """ if isinstance(var, str): el = scenario(var, model, expand_variable) if el.is_indexed(): return el[t] else: return el else: if isinstance(var, (list, tuple)): return var[t] else: return var
[docs] def _parse_out_var( self, scenario: ConstraintScenario, var: Union[str, int, float, list[int], list[float]], model: ConcreteModel, t: int, ) -> Union[Union[Var, Param], int, float]: """ Helper method to access elements from self.entity's pyomo model. Enforces that the variable is expanded for robustness. Args: scenario (ConstraintScenario): Scenario to access. var (Union[str, int, float, list[int], list[float]]): Either variable name or constant value. model (ConcreteModel): Pyomo model to access. t (int): Time index. Returns: Union[Union[Var, Param], int, float]: Either pyomo element or constant value. """ return self._parse_var(scenario, var, model, t, expand_variable=True)