Source code for pyomo.gdp.disjunct

#  ___________________________________________________________________________
#
#  Pyomo: Python Optimization Modeling Objects
#  Copyright (c) 2008-2024
#  National Technology and Engineering Solutions of Sandia, LLC
#  Under the terms of Contract DE-NA0003525 with National Technology and
#  Engineering Solutions of Sandia, LLC, the U.S. Government retains certain
#  rights in this software.
#  This software is distributed under the 3-clause BSD License.
#  ___________________________________________________________________________

import logging
import sys
import types

from math import fabs
from weakref import ref as weakref_ref

from pyomo.common.autoslots import AutoSlots
from pyomo.common.deprecation import deprecation_warning, RenamedClass
from pyomo.common.errors import PyomoException
from pyomo.common.log import is_debug_set
from pyomo.common.numeric_types import native_logical_types, native_types
from pyomo.common.modeling import unique_component_name, NOTSET
from pyomo.common.timing import ConstructionTimer
from pyomo.core import (
    ModelComponentFactory,
    Binary,
    Block,
    ConstraintList,
    Any,
    LogicalConstraintList,
    BooleanValue,
    ScalarBooleanVar,
    ScalarVar,
    value,
)
from pyomo.core.base.component import (
    ActiveComponent,
    ActiveComponentData,
    ComponentData,
)
from pyomo.core.base.global_set import UnindexedComponent_index
from pyomo.core.base.block import BlockData
from pyomo.core.base.misc import apply_indexed_rule
from pyomo.core.base.indexed_component import ActiveIndexedComponent
from pyomo.core.expr.expr_common import ExpressionType

logger = logging.getLogger('pyomo.gdp')

_rule_returned_none_error = """Disjunction '%s': rule returned None.

Disjunction rules must return an iterable containing Disjuncts or
individual expressions, or Disjunction.Skip.  The most common cause of
this error is forgetting to include the "return" statement at the end of
your rule.
"""


[docs] class GDP_Error(PyomoException): """Exception raised while processing GDP Models"""
[docs] class AutoLinkedBinaryVar(ScalarVar): """A binary variable implicitly linked to its equivalent Boolean variable. Basic operations like setting values and fixing/unfixing this variable are also automatically applied to the associated Boolean variable. As this class is only intended to provide a deprecation path for Disjunct.indicator_var, it only supports Scalar instances and does not support indexing. """ INTEGER_TOLERANCE = 0.001 __autoslot_mappers__ = {'_associated_boolean': AutoSlots.weakref_mapper}
[docs] def __init__(self, boolean_var=None): super().__init__(domain=Binary) self._associated_boolean = weakref_ref(boolean_var)
def get_associated_boolean(self): return self._associated_boolean()
[docs] def set_value(self, val, skip_validation=False, _propagate_value=True): super().set_value(val, skip_validation) if not _propagate_value: return # Map the incoming (numeric) value to bool/None if val is None: bool_val = None elif fabs(val - 0.5) < 0.5 - AutoLinkedBinaryVar.INTEGER_TOLERANCE: bool_val = None else: bool_val = bool(int(val + 0.5)) # (Setting _propagate_value prevents infinite recursion.) self.get_associated_boolean().set_value( bool_val, skip_validation, _propagate_value=False )
[docs] def fix(self, value=NOTSET, skip_validation=False): super().fix(value, skip_validation) bool_var = self.get_associated_boolean() if not bool_var.is_fixed(): bool_var.fix()
[docs] def unfix(self): super().unfix() bool_var = self.get_associated_boolean() if bool_var.is_fixed(): bool_var.unfix()
[docs] class AutoLinkedBooleanVar(ScalarBooleanVar): """A Boolean variable implicitly linked to its equivalent binary variable. This class provides a deprecation path for GDP. Originally, Disjunct indicator_var was a binary variable. This simplified early transformations. However, with the introduction of a proper logical expression system, the mathematically correct approach is for the Disjunct's indicator_var attribute to be a proper BooleanVar. As part of the transition, indicator_var attributes are instances of AutoLinkedBooleanVar, which allow the indicator_var to be used in logical expressions, but also implicitly converted (with deprecation warning) into their equivalent binary variable. Basic operations like setting values and fixing/unfixing this variable are also automatically applied to the associated binary variable. As this class is only intended to provide a deprecation path for Disjunct.indicator_var, it only supports Scalar instances and does not support indexing. """
[docs] def as_numeric(self): """Return the binary variable associated with this Boolean variable. This method returns the associated binary variable along with a deprecation warning about using the Boolean variable in a numeric context. """ deprecation_warning( "Implicit conversion of the Boolean indicator_var '%s' to a " "binary variable is deprecated and will be removed. " "Either express constraints on indicator_var using " "LogicalConstraints or work with the associated binary " "variable from indicator_var.get_associated_binary()" % (self.name,), version='6.0', ) return self.get_associated_binary()
def as_binary(self): return self.as_numeric()
[docs] def set_value(self, val, skip_validation=False, _propagate_value=True): # super() does not work as expected for properties; we will call # the property setter explicitly. super().set_value(val, skip_validation) if not _propagate_value: return # Fetch the current value (so we know it has already been cast # to None/bool) val = self.value if val is not None: val = int(val) # (Setting _propagate_value prevents infinite recursion.) self.get_associated_binary().set_value( val, skip_validation, _propagate_value=False )
[docs] def fix(self, value=NOTSET, skip_validation=False): super().fix(value, skip_validation) bin_var = self.get_associated_binary() if not bin_var.is_fixed(): bin_var.fix()
[docs] def unfix(self): super().unfix() bin_var = self.get_associated_binary() if bin_var.is_fixed(): bin_var.unfix()
# # Duck-type the numeric expression API, but route the conversion to # Binary through as_numeric to generate the deprecation warning # @property def bounds(self): return self.as_numeric().bounds @bounds.setter def bounds(self, value): self.as_numeric().bounds = value @property def lb(self): return self.as_numeric().lb @lb.setter def lb(self, value): self.as_numeric().lb = value @property def ub(self): return self.as_numeric().ub @ub.setter def ub(self, value): self.as_numeric().ub = value def __abs__(self): return self.as_numeric().__abs__() def __float__(self): return self.as_numeric().__float__() def __int__(self): return self.as_numeric().__int__() def __neg__(self): return self.as_numeric().__neg__() def __bool__(self): return self.as_numeric().__bool__() def __pos__(self): return self.as_numeric().__pos__() def get_units(self): return self.as_numeric().get_units() def has_lb(self): return self.as_numeric().has_lb() def has_ub(self): return self.as_numeric().has_ub() def is_binary(self): return self.as_numeric().is_binary() def is_continuous(self): return self.as_numeric().is_continuous() def is_integer(self): return self.as_numeric().is_integer() def polynomial_degree(self): return self.as_numeric().polynomial_degree() def __le__(self, arg): return self.as_numeric().__le__(arg) def __lt__(self, arg): return self.as_numeric().__lt__(arg) def __ge__(self, arg): return self.as_numeric().__ge__(arg) def __gt__(self, arg): return self.as_numeric().__gt__(arg) def __eq__(self, arg): # If the other operand is a Boolean, then we want to fall back # on the "normal" implementation of __eq__ for Boolean values if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return super().__eq__(arg) # Otherwise, we will treat this as a binary operand and use the # (numeric) relational expression system return self.as_numeric().__eq__(arg) def __ne__(self, arg): # If the other operand is a Boolean, then we want to fall back # on the "normal" implementation of __ne__ for Boolean values if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return super().__ne__(arg) # Otherwise, we will treat this as a binary operand and use the # (numeric) relational expression system return self.as_numeric().__ne__(arg) def __add__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__add__(arg) def __div__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__div__(arg) def __mul__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__mul__(arg) def __pow__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__pow__(arg) def __sub__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__sub__(arg) def __truediv__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__truediv__(arg) def __iadd__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__iadd__(arg) def __idiv__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__idiv__(arg) def __imul__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__imul__(arg) def __ipow__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__ipow__(arg) def __isub__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__isub__(arg) def __itruediv__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__itruediv__(arg) def __radd__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__radd__(arg) def __rdiv__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__rdiv__(arg) def __rmul__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__rmul__(arg) def __rpow__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__rpow__(arg) def __rsub__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__rsub__(arg) def __rtruediv__(self, arg): if isinstance(arg, BooleanValue) or arg.__class__ in native_logical_types: return NotImplemented return self.as_numeric().__rtruediv__(arg) def setlb(self, arg): return self.as_numeric().setlb(arg) def setub(self, arg): return self.as_numeric().setub(arg)
# The following should eventually be promoted so that all # IndexedComponents can use it class _Initializer(object): """A simple function to process an argument to a Component constructor. This checks the incoming initializer type and maps it to a static identifier so that when constructing indexed Components we can avoid a series of isinstance calls. Eventually this concept should be promoted to pyomo.core so that all Components can leverage a standardized approach to processing "flexible" arguments (POD data, rules, dicts, generators, etc).""" value = 0 deferred_value = 1 function = 2 dict_like = 3 @staticmethod def process(arg): if type(arg) in native_types: return (_Initializer.value, bool(arg)) elif type(arg) is types.FunctionType: return (_Initializer.function, arg) elif isinstance(arg, ComponentData): return (_Initializer.deferred_value, arg) elif hasattr(arg, '__getitem__'): return (_Initializer.dict_like, arg) else: # Hopefully this thing is castable to the type that is desired return (_Initializer.deferred_value, arg)
[docs] class DisjunctData(BlockData): __autoslot_mappers__ = {'_transformation_block': AutoSlots.weakref_mapper} _Block_reserved_words = set() @property def transformation_block(self): return ( None if self._transformation_block is None else self._transformation_block() )
[docs] def __init__(self, component): BlockData.__init__(self, component) with self._declare_reserved_components(): self.indicator_var = AutoLinkedBooleanVar() self.binary_indicator_var = AutoLinkedBinaryVar(self.indicator_var) self.indicator_var.associate_binary_var(self.binary_indicator_var) # pointer to transformation block if this disjunct has been # transformed. None indicates it hasn't been transformed. self._transformation_block = None
[docs] def activate(self): super(DisjunctData, self).activate() self.indicator_var.unfix()
[docs] def deactivate(self): super(DisjunctData, self).deactivate() self.indicator_var.fix(False)
def _deactivate_without_fixing_indicator(self): super(DisjunctData, self).deactivate() def _activate_without_unfixing_indicator(self): super(DisjunctData, self).activate()
class _DisjunctData(metaclass=RenamedClass): __renamed__new_class__ = DisjunctData __renamed__version__ = '6.7.2'
[docs] @ModelComponentFactory.register("Disjunctive blocks.") class Disjunct(Block): _ComponentDataClass = DisjunctData def __new__(cls, *args, **kwds): if cls != Disjunct: return super(Disjunct, cls).__new__(cls) if args == (): return ScalarDisjunct.__new__(ScalarDisjunct) else: return IndexedDisjunct.__new__(IndexedDisjunct)
[docs] def __init__(self, *args, **kwargs): if kwargs.pop('_deep_copying', None): # Hack for Python 2.4 compatibility # Deep copy will copy all items as necessary, so no need to # complete parsing return kwargs.setdefault('ctype', Disjunct) Block.__init__(self, *args, **kwargs)
# For the time being, this method is not needed. # # def _deactivate_without_fixing_indicator(self): # # Ideally, this would be a super call from this class. However, # # doing that would trigger a call to deactivate() on all the # # DisjunctData objects (exactly what we want to avoid!) # # # # For the time being, we will do something bad and directly call # # the base class method from where we would otherwise want to # # call this method. def _activate_without_unfixing_indicator(self): # Ideally, this would be a super call from this class. However, # doing that would trigger a call to deactivate() on all the # DisjunctData objects (exactly what we want to avoid!) # # For the time being, we will do something bad and directly call # the base class method from where we would otherwise want to # call this method. ActiveComponent.activate(self) if self.is_indexed(): for component_data in self.values(): component_data._activate_without_unfixing_indicator()
[docs] class ScalarDisjunct(DisjunctData, Disjunct):
[docs] def __init__(self, *args, **kwds): DisjunctData.__init__(self, self) Disjunct.__init__(self, *args, **kwds) self._data[None] = self self._index = UnindexedComponent_index
[docs] class SimpleDisjunct(metaclass=RenamedClass): __renamed__new_class__ = ScalarDisjunct __renamed__version__ = '6.0'
[docs] class IndexedDisjunct(Disjunct): # # HACK: this should be implemented on ActiveIndexedComponent, but # that will take time and a PEP # @property def active(self): return any(d.active for d in self._data.values())
DisjunctData._Block_reserved_words = set(dir(Disjunct()))
[docs] class DisjunctionData(ActiveComponentData): __slots__ = ('disjuncts', 'xor', '_algebraic_constraint', '_transformation_map') __autoslot_mappers__ = {'_algebraic_constraint': AutoSlots.weakref_mapper} _NoArgument = (0,) @property def algebraic_constraint(self): return ( None if self._algebraic_constraint is None else self._algebraic_constraint() )
[docs] def __init__(self, component=None): # # These lines represent in-lining of the # following constructors: # - ConstraintData, # - ActiveComponentData # - ComponentData self._component = weakref_ref(component) if (component is not None) else None self._index = NOTSET self._active = True self.disjuncts = [] self.xor = True # pointer to XOR (or OR) constraint if this disjunction has been # transformed. None if it has not been transformed self._algebraic_constraint = None # Dictionary to notate information from partial transformations self._transformation_map = {}
def set_value(self, expr): for e in expr: # The user gave us a proper Disjunct block # [ESJ 06/21/2019] This is really an issue with the reclassifier, # but in the case where you are iteratively adding to an # IndexedDisjunct indexed by Any which has already been transformed, # the new Disjuncts are Blocks already. This catches them for who # they are anyway. if hasattr(e, 'is_component_type') and e.is_component_type(): if e.ctype == Disjunct and not e.is_indexed(): self.disjuncts.append(e) continue e_iter = [e] elif hasattr(e, '__iter__'): e_iter = e else: e_iter = [e] # The user was lazy and gave us a single constraint # expression or an iterable of expressions expressions = [] for _tmpe in e_iter: try: if _tmpe.is_expression_type(): expressions.append(_tmpe) continue except AttributeError: pass msg = " in '%s'" % (type(e).__name__,) if e_iter is e else "" raise ValueError( "Unexpected term for Disjunction '%s'.\n" " Expected a Disjunct object, relational expression, " "or iterable of\n relational expressions but got '%s'%s" % (self.name, type(_tmpe).__name__, msg) ) comp = self.parent_component() if comp._autodisjuncts is None: b = self.parent_block() comp._autodisjuncts = Disjunct(Any) b.add_component( unique_component_name(b, comp.local_name + "_disjuncts"), comp._autodisjuncts, ) # TODO: I am not at all sure why we need to # explicitly construct this block - that should # happen automatically. comp._autodisjuncts.construct() disjunct = comp._autodisjuncts[len(comp._autodisjuncts)] disjunct.constraint = c = ConstraintList() disjunct.propositions = p = LogicalConstraintList() for e in expressions: if e.is_expression_type(ExpressionType.RELATIONAL): c.add(e) elif e.is_expression_type(ExpressionType.LOGICAL): p.add(e) else: raise RuntimeError( "Unsupported expression type on Disjunct " f"{disjunct.name}: expected either relational or " f"logical expression, found {e.__class__.__name__}" ) self.disjuncts.append(disjunct)
class _DisjunctionData(metaclass=RenamedClass): __renamed__new_class__ = DisjunctionData __renamed__version__ = '6.7.2'
[docs] @ModelComponentFactory.register("Disjunction expressions.") class Disjunction(ActiveIndexedComponent): _ComponentDataClass = DisjunctionData def __new__(cls, *args, **kwds): if cls != Disjunction: return super(Disjunction, cls).__new__(cls) if args == (): return ScalarDisjunction.__new__(ScalarDisjunction) else: return IndexedDisjunction.__new__(IndexedDisjunction)
[docs] def __init__(self, *args, **kwargs): self._init_rule = kwargs.pop('rule', None) self._init_expr = kwargs.pop('expr', None) self._init_xor = _Initializer.process(kwargs.pop('xor', True)) self._autodisjuncts = None kwargs.setdefault('ctype', Disjunction) super(Disjunction, self).__init__(*args, **kwargs) if self._init_expr is not None and self._init_rule is not None: raise ValueError( "Cannot specify both rule= and expr= for Disjunction %s" % (self.name,) )
# # TODO: Ideally we would not override these methods and instead add # the contents of _check_skip_add to the set_value() method. # Unfortunately, until IndexedComponentData objects know their own # index, determining the index is a *very* expensive operation. If # we refactor things so that the Data objects have their own index, # then we can remove these overloads. # def _setitem_impl(self, index, obj, value): if value is Disjunction.Skip: del self[index] return None else: obj.set_value(value) return obj def _setitem_when_not_present(self, index, value): if value is Disjunction.Skip: return None else: ans = super(Disjunction, self)._setitem_when_not_present( index=index, value=value ) self._initialize_members((index,)) return ans def _initialize_members(self, init_set): if self._init_xor[0] == _Initializer.value: # POD data val = self._init_xor[1] for key in init_set: self._data[key].xor = val elif self._init_xor[0] == _Initializer.deferred_value: # Param data val = bool(value(self._init_xor[1])) for key in init_set: self._data[key].xor = val elif self._init_xor[0] == _Initializer.function: # rule fcn = self._init_xor[1] for key in init_set: self._data[key].xor = bool( value(apply_indexed_rule(self, fcn, self._parent(), key)) ) elif self._init_xor[0] == _Initializer.dict_like: # dict-like thing val = self._init_xor[1] for key in init_set: self._data[key].xor = bool(value(val[key]))
[docs] def construct(self, data=None): if is_debug_set(logger): logger.debug("Constructing disjunction %s" % (self.name)) if self._constructed: return timer = ConstructionTimer(self) self._constructed = True if self._anonymous_sets is not None: for _set in self._anonymous_sets: _set.construct() _self_parent = self.parent_block() if not self.is_indexed(): if self._init_rule is not None: expr = self._init_rule(_self_parent) elif self._init_expr is not None: expr = self._init_expr else: timer.report() return if expr is None: raise ValueError(_rule_returned_none_error % (self.name,)) if expr is Disjunction.Skip: timer.report() return self._data[None] = self self._setitem_when_not_present(None, expr) elif self._init_expr is not None: raise IndexError( "Disjunction '%s': Cannot initialize multiple indices " "of a disjunction with a single disjunction list" % (self.name,) ) elif self._init_rule is not None: _init_rule = self._init_rule for ndx in self._index_set: try: expr = apply_indexed_rule(self, _init_rule, _self_parent, ndx) except Exception: err = sys.exc_info()[1] logger.error( "Rule failed when generating expression for " "disjunction %s with index %s:\n%s: %s" % (self.name, str(ndx), type(err).__name__, err) ) raise if expr is None: _name = "%s[%s]" % (self.name, str(ndx)) raise ValueError(_rule_returned_none_error % (_name,)) if expr is Disjunction.Skip: continue self._setitem_when_not_present(ndx, expr) timer.report()
def _pprint(self): """ Return data that will be printed for this component. """ return ( [ ("Size", len(self)), ("Index", self._index_set if self.is_indexed() else None), ("Active", self.active), ], self.items(), ("Disjuncts", "Active", "XOR"), lambda k, v: [[x.name for x in v.disjuncts], v.active, v.xor], )
[docs] class ScalarDisjunction(DisjunctionData, Disjunction):
[docs] def __init__(self, *args, **kwds): DisjunctionData.__init__(self, component=self) Disjunction.__init__(self, *args, **kwds) self._index = UnindexedComponent_index
# # Singleton disjunctions are strange in that we want them to be # both be constructed but have len() == 0 when not initialized with # anything (at least according to the unit tests that are # currently in place). So during initialization only, we will # treat them as "indexed" objects where things like # Constraint.Skip are managed. But after that they will behave # like DisjunctionData objects where set_value does not handle # Disjunction.Skip but expects a valid expression or None. #
[docs] def set_value(self, expr): """Set the expression on this disjunction.""" if not self._constructed: raise ValueError( "Setting the value of disjunction '%s' " "before the Disjunction has been constructed (there " "is currently no object to set)." % (self.name) ) if len(self._data) == 0: self._data[None] = self if expr is Disjunction.Skip: del self[None] return None return super(ScalarDisjunction, self).set_value(expr)
[docs] class SimpleDisjunction(metaclass=RenamedClass): __renamed__new_class__ = ScalarDisjunction __renamed__version__ = '6.0'
[docs] class IndexedDisjunction(Disjunction): # # HACK: this should be implemented on ActiveIndexedComponent, but # that will take time and a PEP # @property def active(self): return any(d.active for d in self._data.values())