"""
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)