diff --git a/pyomo/gdp/plugins/simple_disjunction_transform.py b/pyomo/gdp/plugins/simple_disjunction_transform.py new file mode 100644 index 00000000000..78a8b0bd953 --- /dev/null +++ b/pyomo/gdp/plugins/simple_disjunction_transform.py @@ -0,0 +1,539 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 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 enum +import logging +from weakref import ref as weakref_ref + +from pyomo.common.collections import ComponentMap, ComponentSet +from pyomo.common.config import ConfigDict, ConfigValue, InEnum +from pyomo.common.modeling import unique_component_name +from pyomo.core import Any, Block, Constraint, SortComponents +from pyomo.core.base import Transformation, TransformationFactory +from pyomo.core.base.component import ComponentBase +from pyomo.core.base.constraint import ConstraintData +from pyomo.core.util import target_list +from pyomo.gdp import Disjunct, Disjunction, GDP_Error +from pyomo.gdp.util import _parent_disjunct, is_child_of + +logger = logging.getLogger(__name__) + + +class ConstraintSelectionMethod(str, enum.Enum): + """Strategy used to derive the single Constraint kept for each Disjunct. + + user_specified: + Use the Constraint that the user assigned to each Disjunct through the + ``selected_constraints`` mapping. + first: + Use the first active Constraint encountered on each Disjunct (in + deterministic component order). + + Each method reduces the (active) source Constraints of a Disjunct to the + single expression placed in the corresponding simple Disjunct. The two + methods above each keep one Constraint as-is; future methods may instead + aggregate several Constraints into one (see ``_combine_sources``). + """ + + user_specified = 'user_specified' + first = 'first' + + +def _as_constraint_list(value): + """Normalize a ``selected_constraints`` value into a list of Constraints. + + Accepts a single ConstraintData, an indexed Constraint container (expanded + into its members), or any iterable of Constraints. Anything else is wrapped + in a single-element list so that validation can reject it with a clear + message. Returning a list (rather than a single Constraint) keeps the data + structure ready for selection methods that aggregate several Constraints + into one. + """ + if isinstance(value, ConstraintData): + return [value] + if isinstance(value, ComponentBase) and value.ctype is Constraint: + return list(value.values()) + if isinstance(value, (list, tuple, set, ComponentSet)): + return list(value) + return [value] + + +def _selected_constraints_map(arg): + """ConfigValue domain coercing the user's selection into a ComponentMap. + + Accepts a dict or ComponentMap mapping Disjuncts (the keys) to the + Constraint(s) (the values) that should be retained for each of them. Each + value is normalized to a list of Constraints. + """ + if arg is None: + return ComponentMap() + if isinstance(arg, (ComponentMap, dict)): + items = arg.items() + else: + try: + items = dict(arg).items() + except (TypeError, ValueError): + raise ValueError( + "Expected a dict or ComponentMap mapping Disjuncts to " + "Constraints for 'selected_constraints', but received an object " + "of type %s" % (type(arg).__name__,) + ) + result = ComponentMap() + for disjunct, value in items: + result[disjunct] = _as_constraint_list(value) + return result + + +@TransformationFactory.register( + 'gdp.simple_disjunction', + doc="Relax selected Disjunctions by building, for each one, a 'simple' " + "Disjunction whose Disjuncts each retain a single Constraint derived from " + "the corresponding original Disjunct.", +) +class SimpleDisjunctionTransformation(Transformation): + """Create a relaxation of one or more Disjunctions as *simple* Disjunctions. + + A *simple* Disjunction is one in which every Disjunct holds exactly one + Constraint. For each Disjunction that is transformed, this transformation + derives a single Constraint for each of its Disjuncts and assembles those + Constraints into a brand new Disjunction. Because each new Disjunct keeps a + relaxed (single-Constraint) view of the Disjunct it was generated from, the + resulting Disjunction is a relaxation of the original in the space of the + model (problem) variables. + + The original Disjunction is never modified: the generated simple Disjunction + (together with its Disjuncts) is placed inside a new Block that is added to + the parent Block of the Disjunction it was generated from. The new Disjuncts + get their own indicator variables, so the simple Disjunction is an + independent component that the caller may transform or otherwise use however + they see fit. + + There is more than one reasonable way to reduce a Disjunct to a single + Constraint, so the strategy is selectable through the + ``constraint_selection_method`` option (see + :class:`ConstraintSelectionMethod`): + + * ``'first'`` (the default) keeps the first active Constraint encountered + on each Disjunct. + * ``'user_specified'`` keeps the Constraint that the user assigned to each + Disjunct through the ``selected_constraints`` mapping. + + Both currently-implemented methods keep a single existing Constraint. The + selection machinery is, however, organized around lists of source + Constraints and a separate combination step (``_combine_sources``) so that + future methods which aggregate several Constraints into a single one can be + added without restructuring. + + Only active Constraints are ever considered. If a Disjunct has no active + Constraints, it is skipped (no corresponding Disjunct is created in the + simple Disjunction). If *every* Disjunct of a Disjunction is skipped, so + that the simple Disjunction would be empty, a GDP_Error is raised and no + simple Disjunction is created for that Disjunction. + + Nested Disjunctions are not supported: a Disjunction is not transformed if + it is itself nested inside a Disjunct or if any of its Disjuncts contains a + Disjunction of its own. When no targets are given, such Disjunctions are + skipped automatically; when a nested Disjunction is supplied as an explicit + target, a GDP_Error is raised. Likewise, deactivated Disjunctions are + skipped automatically but raise a GDP_Error when supplied as an explicit + target. + + After transformation, ``get_simple_disjunction`` and ``get_src_disjunction`` + map between an original Disjunction and the simple Disjunction generated from + it. + """ + + CONFIG = ConfigDict('gdp.simple_disjunction') + CONFIG.declare( + 'targets', + ConfigValue( + default=None, + domain=target_list, + description="target or list of targets (Disjunctions or Blocks) to " + "relax", + doc=""" + This specifies the list of Disjunctions to relax, or the Blocks + whose (active, non-nested) Disjunctions should be relaxed. If None + (default), every active, non-nested Disjunction on the instance is + relaxed. Note that if the transformation is done out of place, the + list of targets should be attached to the model before it is cloned, + and the list will specify the targets on the cloned instance. + """, + ), + ) + CONFIG.declare( + 'constraint_selection_method', + ConfigValue( + default=ConstraintSelectionMethod.first, + domain=InEnum(ConstraintSelectionMethod), + description="Strategy used to derive the single Constraint kept for " + "each Disjunct", + doc=""" + How to reduce each Disjunct to a single Constraint. Options are the + elements of the enum ConstraintSelectionMethod, or equivalently the + strings 'first' or 'user_specified'. + + 'first' keeps the first active Constraint encountered on each + Disjunct (in deterministic component order). 'user_specified' keeps + the Constraint that the user assigned to each Disjunct through the + 'selected_constraints' option, which is required in that case. + """, + ), + ) + CONFIG.declare( + 'selected_constraints', + ConfigValue( + default=None, + domain=_selected_constraints_map, + description="Mapping from Disjuncts to the Constraint(s) to keep for " + "each of them", + doc=""" + A dict or ComponentMap whose keys are Disjuncts and whose values are + the (active) Constraint, or list of Constraints, to retain for each + of those Disjuncts. This is only used (and is required) when + 'constraint_selection_method' is 'user_specified'. A Disjunct that + owns active Constraints but is absent from this mapping is an error; + a Disjunct with no active Constraints may be omitted and is skipped. + + The currently-implemented selection methods keep a single Constraint + per Disjunct, so a single Constraint is expected for each entry. + Values are nevertheless stored as lists so that selection methods + which aggregate several Constraints into one can reuse this option. + """, + ), + ) + transformation_name = 'simple_disjunction' + + #: Dispatch from selection method to the builder that turns a Disjunct's + #: selected source Constraints into the single expression placed in the + #: simple Disjunct. New strategies only need a new entry and builder. + _EXPRESSION_BUILDERS = { + ConstraintSelectionMethod.first: '_first_constraint_expression', + ConstraintSelectionMethod.user_specified: '_user_specified_expression', + } + + def __init__(self): + super().__init__() + self.logger = logger + + def _apply_to(self, instance, **kwds): + if instance.ctype is not Block: + raise GDP_Error( + "Transformation called on %s of type %s. 'instance' must be a " + "ConcreteModel or Block." % (instance.name, instance.ctype) + ) + + config = self.CONFIG(kwds.pop('options', {})) + config.set_value(kwds) + + method = config.constraint_selection_method + selected_constraints = config.selected_constraints + if method is ConstraintSelectionMethod.user_specified: + if not selected_constraints: + raise GDP_Error( + "The 'user_specified' constraint selection method was " + "requested, but no 'selected_constraints' mapping was " + "provided." + ) + elif selected_constraints: + logger.warning( + "A 'selected_constraints' mapping was provided, but the " + "constraint selection method is '%s', so the mapping will be " + "ignored. Set constraint_selection_method='user_specified' to " + "use it." % (method.value,) + ) + + build_expression = getattr(self, self._EXPRESSION_BUILDERS[method]) + for disjunction in self._get_disjunctions_to_transform( + instance, config.targets + ): + self._transform_disjunction( + disjunction, build_expression, selected_constraints + ) + + def _get_disjunctions_to_transform(self, instance, targets): + """Return the ordered list of Disjunctions that should be relaxed.""" + if targets is None: + return list(self._gather_disjunctions(instance)) + + disjunctions = [] + knownBlocks = {} + for t in targets: + if not is_child_of(parent=instance, child=t, knownBlocks=knownBlocks): + raise GDP_Error( + "Target '%s' is not a component on instance '%s'!" + % (t.name, instance.name) + ) + if t.ctype is Disjunction: + # The user explicitly asked for this Disjunction, so we validate + # it (rather than silently skipping) and report why if we cannot + # build a simple Disjunction from it. + for disjunction in t.values() if t.is_indexed() else (t,): + self._validate_explicit_disjunction(disjunction) + disjunctions.append(disjunction) + elif t.ctype is Block: + for block in t.values() if t.is_indexed() else (t,): + if not block.active: + continue + disjunctions.extend(self._gather_disjunctions(block)) + else: + raise GDP_Error( + "Target '%s' was not a Block or Disjunction. It was of type " + "%s and can't be transformed." % (t.name, type(t)) + ) + return disjunctions + + def _gather_disjunctions(self, block): + """Yield the active, top-level Disjunctions reachable from block. + + Only Blocks are descended into, so nested Disjunctions (which live + inside Disjuncts) are never discovered. A top-level Disjunction that + *contains* a nested Disjunction is skipped with a debug message, since + this transformation does not build simple Disjunctions from nested + Disjunctions. + """ + for disjunction in block.component_data_objects( + Disjunction, + active=True, + descend_into=Block, + sort=SortComponents.deterministic, + ): + if self._contains_nested_disjunction(disjunction): + logger.debug( + "Skipping Disjunction '%s' because it contains a nested " + "Disjunction." % disjunction.name + ) + continue + yield disjunction + + def _validate_explicit_disjunction(self, disjunction): + """Raise a GDP_Error if an explicitly-targeted Disjunction is ineligible.""" + if not disjunction.active: + raise GDP_Error( + "Disjunction '%s' is deactivated, so a simple disjunction " + "cannot be created from it. (Deactivated Disjunctions are " + "skipped automatically when no targets are specified.)" + % disjunction.name + ) + if _parent_disjunct(disjunction) is not None: + raise GDP_Error( + "Disjunction '%s' is nested in another Disjunct. This " + "transformation does not create simple disjunctions from nested " + "Disjunctions." % disjunction.name + ) + nested = self._nested_disjunction_owner(disjunction) + if nested is not None: + raise GDP_Error( + "Disjunction '%s' contains a nested Disjunction (on Disjunct " + "'%s'). This transformation does not create simple disjunctions " + "from nested Disjunctions." % (disjunction.name, nested.name) + ) + + def _contains_nested_disjunction(self, disjunction): + """Return True if any Disjunct of the Disjunction owns a Disjunction.""" + return self._nested_disjunction_owner(disjunction) is not None + + @staticmethod + def _nested_disjunction_owner(disjunction): + # Return the first active Disjunct that declares a Disjunction of its + # own, or None. We do not descend into nested Disjuncts: we only look + # for a Disjunction declared directly on a Disjunct (or one of its + # Blocks), which is exactly what makes the parent Disjunction nested. + for disjunct in disjunction.disjuncts: + if not disjunct.active: + continue + if ( + next( + disjunct.component_data_objects( + Disjunction, active=True, descend_into=Block + ), + None, + ) + is not None + ): + return disjunct + return None + + def _transform_disjunction( + self, disjunction, build_expression, selected_constraints + ): + # Build the single Constraint expression for each (active) Disjunct, + # skipping Disjuncts that have nothing to contribute. + chosen = [] + for disjunct in disjunction.disjuncts: + if not disjunct.active: + continue + expression = build_expression(disjunction, disjunct, selected_constraints) + if expression is None: + logger.debug( + "Disjunct '%s' has no active constraints to select, so it " + "is skipped in the simple disjunction generated from " + "Disjunction '%s'." % (disjunct.name, disjunction.name) + ) + continue + chosen.append((disjunct, expression)) + + if not chosen: + raise GDP_Error( + "Cannot create a simple disjunction from Disjunction '%s': none " + "of its active Disjuncts produced a constraint for the simple " + "disjunction." % disjunction.name + ) + + parent_block = disjunction.parent_block() + trans_block = Block() + parent_block.add_component( + unique_component_name( + parent_block, '_pyomo_gdp_simple_disjunction_reformulation' + ), + trans_block, + ) + trans_block.simple_disjuncts = Disjunct(Any) + + new_disjuncts = [] + for i, (orig_disjunct, expression) in enumerate(chosen): + new_disjunct = trans_block.simple_disjuncts[i] + new_disjunct.constraint = Constraint(expr=expression) + new_disjuncts.append(new_disjunct) + + trans_block.simple_disjunction = Disjunction(expr=new_disjuncts) + + # Record the mapping in both directions so callers can move between an + # original Disjunction and the simple Disjunction generated from it. + disjunction._transformation_map[self.transformation_name] = ( + trans_block.simple_disjunction + ) + trans_block._src_disjunction = weakref_ref(disjunction) + + return trans_block.simple_disjunction + + # ---------------------------------------------------------------------- # + # Constraint selection methods # + # # + # Each builder gathers the source Constraints relevant to a Disjunct and # + # returns the single relational expression to place in the corresponding # + # simple Disjunct, or None to skip the Disjunct. The actual reduction of # + # source Constraints to one expression is factored into # + # _combine_sources so that aggregating methods can be added later. # + # ---------------------------------------------------------------------- # + def _first_constraint_expression(self, disjunction, disjunct, selected_constraints): + sources = self._own_active_constraints(disjunct) + if not sources: + return None + return self._combine_sources(disjunction, disjunct, sources[:1]) + + def _user_specified_expression(self, disjunction, disjunct, selected_constraints): + if disjunct not in selected_constraints: + # No selection for this Disjunct: skip it if it has nothing to keep, + # otherwise the user left out a Disjunct we cannot reduce on our own. + if not self._own_active_constraints(disjunct): + return None + raise GDP_Error( + "Disjunct '%s' (in Disjunction '%s') has active constraints but " + "was not assigned one in 'selected_constraints'. Assign a " + "constraint for it, deactivate it, or use the 'first' " + "constraint selection method." % (disjunct.name, disjunction.name) + ) + sources = selected_constraints[disjunct] + self._validate_selected_sources(disjunction, disjunct, sources) + return self._combine_sources(disjunction, disjunct, sources) + + def _combine_sources(self, disjunction, disjunct, sources): + """Reduce a Disjunct's selected source Constraints to one expression. + + The currently-implemented selection methods keep a single Constraint, so + this references that Constraint's expression directly. Selection methods + that aggregate several Constraints into one will perform that reduction + here (for example, by combining the Constraint bodies into a new + expression). + """ + if len(sources) != 1: + raise GDP_Error( + "Disjunct '%s' (in Disjunction '%s') was assigned %d constraints, " + "but the current selection methods keep exactly one constraint " + "per Disjunct. (Aggregating several constraints into one is not " + "yet implemented.)" % (disjunct.name, disjunction.name, len(sources)) + ) + # Reference the original Constraint's relational expression. This reuses + # the original model Vars, so the new Constraint constrains exactly what + # the original did, without touching the original. + return sources[0].expr + + def _validate_selected_sources(self, disjunction, disjunct, sources): + own = ComponentSet(self._own_active_constraints(disjunct)) + for constraint in sources: + if not isinstance(constraint, ConstraintData): + raise GDP_Error( + "An object selected for Disjunct '%s' in " + "'selected_constraints' is not a Constraint. Expected a " + "ConstraintData, but got an object of type %s." + % (disjunct.name, type(constraint).__name__) + ) + if not constraint.active: + raise GDP_Error( + "The constraint '%s' selected for Disjunct '%s' is not " + "active. Only active constraints may be selected." + % (constraint.name, disjunct.name) + ) + if constraint not in own: + raise GDP_Error( + "The constraint '%s' selected for Disjunct '%s' is not one " + "of that Disjunct's own active constraints. (Constraints " + "inside a nested Disjunct cannot be selected.)" + % (constraint.name, disjunct.name) + ) + + @staticmethod + def _own_active_constraints(disjunct): + # Deterministic order, and does not descend into nested Disjuncts. + return list( + disjunct.component_data_objects( + Constraint, + active=True, + descend_into=Block, + sort=SortComponents.deterministic, + ) + ) + + # ---------------------------------------------------------------------- # + # Mapping between original and simple Disjunctions # + # ---------------------------------------------------------------------- # + def get_simple_disjunction(self, src_disjunction): + """Return the simple Disjunction generated from ``src_disjunction``. + + Raises a GDP_Error if ``src_disjunction`` was not transformed by this + transformation. + """ + simple = src_disjunction._transformation_map.get(self.transformation_name) + if simple is None: + raise GDP_Error( + "Disjunction '%s' has not been transformed with the 'gdp.%s' " + "transformation, so it has no simple disjunction." + % (src_disjunction.name, self.transformation_name) + ) + return simple + + def get_src_disjunction(self, simple_disjunction): + """Return the original Disjunction that ``simple_disjunction`` relaxes. + + Parameters + ---------- + simple_disjunction: Disjunction generated by this transformation (i.e., + the ``simple_disjunction`` component on one of the reformulation + Blocks created by this transformation). + """ + trans_block = simple_disjunction.parent_block() + src = getattr(trans_block, '_src_disjunction', None) + if type(src) is not weakref_ref: + raise GDP_Error( + "It appears that '%s' is not a simple disjunction generated by " + "the 'gdp.%s' transformation. No source disjunction found." + % (simple_disjunction.name, self.transformation_name) + ) + return src() diff --git a/pyomo/gdp/tests/test_simple_disjunction_transform.py b/pyomo/gdp/tests/test_simple_disjunction_transform.py new file mode 100644 index 00000000000..78faf4e1717 --- /dev/null +++ b/pyomo/gdp/tests/test_simple_disjunction_transform.py @@ -0,0 +1,425 @@ +# ____________________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2026 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 pyomo.common.unittest as unittest +from pyomo.common.collections import ComponentMap, ComponentSet +from pyomo.common.log import LoggingIntercept +from pyomo.core.expr import identify_variables +from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.environ import Block, ConcreteModel, Constraint, TransformationFactory, Var +from pyomo.gdp import Disjunct, Disjunction, GDP_Error +from pyomo.gdp.plugins import simple_disjunction_transform + + +class CommonModels: + def make_two_term_model(self): + """A flat two-term Disjunction whose Disjuncts each have two Constraints.""" + m = ConcreteModel() + m.x = Var(bounds=(0, 10)) + m.y = Var(bounds=(0, 10)) + m.d1 = Disjunct() + m.d1.c1 = Constraint(expr=m.x >= 1) + m.d1.c2 = Constraint(expr=m.y <= 5) + m.d2 = Disjunct() + m.d2.c1 = Constraint(expr=m.x <= 2) + m.d2.c2 = Constraint(expr=m.y >= 3) + m.disjunction = Disjunction(expr=[m.d1, m.d2]) + return m + + def make_nested_model(self): + """A two-term Disjunction with a nested Disjunction on the first Disjunct.""" + m = ConcreteModel() + m.x = Var(bounds=(0, 10)) + m.y = Var(bounds=(0, 10)) + m.d1 = Disjunct() + m.d1.c = Constraint(expr=m.x >= 1) + m.d1.inner1 = Disjunct() + m.d1.inner1.c = Constraint(expr=m.y >= 2) + m.d1.inner2 = Disjunct() + m.d1.inner2.c = Constraint(expr=m.y <= 1) + m.d1.inner = Disjunction(expr=[m.d1.inner1, m.d1.inner2]) + m.d2 = Disjunct() + m.d2.c = Constraint(expr=m.x <= 2) + m.disjunction = Disjunction(expr=[m.d1, m.d2]) + return m + + +class TestSimpleDisjunctionTransformation(unittest.TestCase, CommonModels): + def get_transformation_block(self, parent): + return parent.component('_pyomo_gdp_simple_disjunction_reformulation') + + def constraints_of(self, disjunct): + return list( + disjunct.component_data_objects(Constraint, active=True, descend_into=Block) + ) + + # ------------------------------------------------------------------ # + # 'first' constraint selection (the default) # + # ------------------------------------------------------------------ # + def test_first_method_builds_simple_disjunction(self): + m = self.make_two_term_model() + TransformationFactory('gdp.simple_disjunction').apply_to(m) + + trans = self.get_transformation_block(m) + self.assertIsNotNone(trans) + simple = trans.simple_disjunction + self.assertIsInstance(simple, Disjunction.__mro__[0]) + self.assertEqual(len(simple.disjuncts), 2) + + first_disjunct, second_disjunct = simple.disjuncts + self.assertEqual(len(self.constraints_of(first_disjunct)), 1) + self.assertEqual(len(self.constraints_of(second_disjunct)), 1) + # 'first' keeps c1 of each original Disjunct + assertExpressionsEqual( + self, self.constraints_of(first_disjunct)[0].expr, m.d1.c1.expr + ) + assertExpressionsEqual( + self, self.constraints_of(second_disjunct)[0].expr, m.d2.c1.expr + ) + + def test_block_added_to_parent_of_disjunction(self): + m = ConcreteModel() + m.sub = Block() + m.sub.x = Var(bounds=(0, 10)) + m.sub.d1 = Disjunct() + m.sub.d1.c = Constraint(expr=m.sub.x >= 1) + m.sub.d2 = Disjunct() + m.sub.d2.c = Constraint(expr=m.sub.x <= 2) + m.sub.disjunction = Disjunction(expr=[m.sub.d1, m.sub.d2]) + + TransformationFactory('gdp.simple_disjunction').apply_to( + m, targets=m.sub.disjunction + ) + # The new Block lives on the parent Block of the Disjunction, not the model + self.assertIsNotNone(self.get_transformation_block(m.sub)) + self.assertIsNone(self.get_transformation_block(m)) + + def test_original_disjunction_untouched(self): + m = self.make_two_term_model() + TransformationFactory('gdp.simple_disjunction').apply_to(m) + + self.assertTrue(m.disjunction.active) + self.assertIsNone(m.disjunction.algebraic_constraint) + for d in (m.d1, m.d2): + self.assertTrue(d.active) + self.assertEqual(len(self.constraints_of(d)), 2) + + def test_new_constraints_reference_original_variables(self): + m = self.make_two_term_model() + TransformationFactory('gdp.simple_disjunction').apply_to(m) + simple = self.get_transformation_block(m).simple_disjunction + new_con = self.constraints_of(simple.disjuncts[0])[0] + # The reused expression must point at the original model Var + self.assertIn(m.x, ComponentSet(identify_variables(new_con.expr))) + + def test_multiple_disjunctions(self): + m = self.make_two_term_model() + m.z = Var(bounds=(0, 10)) + m.e1 = Disjunct() + m.e1.c = Constraint(expr=m.z >= 1) + m.e2 = Disjunct() + m.e2.c = Constraint(expr=m.z <= 2) + m.disjunction2 = Disjunction(expr=[m.e1, m.e2]) + + TransformationFactory('gdp.simple_disjunction').apply_to(m) + # Each Disjunction gets its own reformulation Block on the parent + blocks = [ + b + for b in m.component_objects(Block) + if b.local_name.startswith('_pyomo_gdp_simple_disjunction_reformulation') + ] + self.assertEqual(len(blocks), 2) + + # ------------------------------------------------------------------ # + # 'user_specified' constraint selection # + # ------------------------------------------------------------------ # + def test_user_specified_selection(self): + m = self.make_two_term_model() + TransformationFactory('gdp.simple_disjunction').apply_to( + m, + constraint_selection_method='user_specified', + selected_constraints={m.d1: m.d1.c2, m.d2: m.d2.c2}, + ) + simple = self.get_transformation_block(m).simple_disjunction + assertExpressionsEqual( + self, self.constraints_of(simple.disjuncts[0])[0].expr, m.d1.c2.expr + ) + assertExpressionsEqual( + self, self.constraints_of(simple.disjuncts[1])[0].expr, m.d2.c2.expr + ) + + def test_user_specified_accepts_component_map(self): + m = self.make_two_term_model() + selection = ComponentMap() + selection[m.d1] = m.d1.c1 + selection[m.d2] = m.d2.c2 + TransformationFactory('gdp.simple_disjunction').apply_to( + m, + constraint_selection_method='user_specified', + selected_constraints=selection, + ) + simple = self.get_transformation_block(m).simple_disjunction + self.assertEqual(len(simple.disjuncts), 2) + + def test_user_specified_accepts_single_constraint_in_list(self): + m = self.make_two_term_model() + TransformationFactory('gdp.simple_disjunction').apply_to( + m, + constraint_selection_method='user_specified', + selected_constraints={m.d1: [m.d1.c2], m.d2: [m.d2.c1]}, + ) + simple = self.get_transformation_block(m).simple_disjunction + assertExpressionsEqual( + self, self.constraints_of(simple.disjuncts[0])[0].expr, m.d1.c2.expr + ) + + def test_user_specified_multiple_constraints_not_yet_supported(self): + # The data structure accepts several constraints per Disjunct (so that + # future aggregating methods can use it), but the current methods keep + # exactly one, so this is a clear, signposted error rather than a crash. + m = self.make_two_term_model() + with self.assertRaisesRegex(GDP_Error, "not\\s+yet\\s+implemented"): + TransformationFactory('gdp.simple_disjunction').apply_to( + m, + constraint_selection_method='user_specified', + selected_constraints={m.d1: [m.d1.c1, m.d1.c2], m.d2: m.d2.c1}, + ) + + def test_user_specified_requires_mapping(self): + m = self.make_two_term_model() + with self.assertRaisesRegex( + GDP_Error, "no 'selected_constraints' mapping was provided" + ): + TransformationFactory('gdp.simple_disjunction').apply_to( + m, constraint_selection_method='user_specified' + ) + + def test_user_specified_inactive_constraint_error(self): + m = self.make_two_term_model() + m.d1.c2.deactivate() + with self.assertRaisesRegex(GDP_Error, "is not active"): + TransformationFactory('gdp.simple_disjunction').apply_to( + m, + constraint_selection_method='user_specified', + selected_constraints={m.d1: m.d1.c2, m.d2: m.d2.c1}, + ) + + def test_user_specified_foreign_constraint_error(self): + m = self.make_two_term_model() + with self.assertRaisesRegex(GDP_Error, "is not one of that Disjunct's own"): + TransformationFactory('gdp.simple_disjunction').apply_to( + m, + constraint_selection_method='user_specified', + selected_constraints={m.d1: m.d2.c1, m.d2: m.d2.c1}, + ) + + def test_user_specified_missing_disjunct_error(self): + m = self.make_two_term_model() + with self.assertRaisesRegex( + GDP_Error, "was not assigned one in 'selected_constraints'" + ): + TransformationFactory('gdp.simple_disjunction').apply_to( + m, + constraint_selection_method='user_specified', + selected_constraints={m.d1: m.d1.c1}, + ) + + def test_user_specified_skips_disjunct_without_constraints(self): + m = self.make_two_term_model() + m.d3 = Disjunct() # no constraints + m.disjunction.deactivate() + m.disjunction2 = Disjunction(expr=[m.d1, m.d2, m.d3]) + TransformationFactory('gdp.simple_disjunction').apply_to( + m, + targets=m.disjunction2, + constraint_selection_method='user_specified', + selected_constraints={m.d1: m.d1.c1, m.d2: m.d2.c1}, + ) + simple = self.get_transformation_block(m).simple_disjunction + self.assertEqual(len(simple.disjuncts), 2) + + def test_selected_constraints_ignored_warning(self): + m = self.make_two_term_model() + with LoggingIntercept(level=logging.WARNING) as log: + TransformationFactory('gdp.simple_disjunction').apply_to( + m, selected_constraints={m.d1: m.d1.c1} + ) + self.assertIn("the mapping will be ignored", log.getvalue()) + + # ------------------------------------------------------------------ # + # Skipping and error behavior # + # ------------------------------------------------------------------ # + def test_disjunct_without_constraints_skipped(self): + m = self.make_two_term_model() + m.d3 = Disjunct() # no constraints + m.disjunction.deactivate() + m.disjunction2 = Disjunction(expr=[m.d1, m.d2, m.d3]) + TransformationFactory('gdp.simple_disjunction').apply_to( + m, targets=m.disjunction2 + ) + simple = self.get_transformation_block(m).simple_disjunction + self.assertEqual(len(simple.disjuncts), 2) + + def test_empty_simple_disjunction_raises(self): + m = ConcreteModel() + m.d1 = Disjunct() + m.d2 = Disjunct() + m.disjunction = Disjunction(expr=[m.d1, m.d2]) + with self.assertRaisesRegex( + GDP_Error, "none of its active Disjuncts produced a constraint" + ): + TransformationFactory('gdp.simple_disjunction').apply_to(m) + # No reformulation Block should have been created + self.assertIsNone(self.get_transformation_block(m)) + + def test_inactive_disjunction_target_raises(self): + m = self.make_two_term_model() + m.disjunction.deactivate() + with self.assertRaisesRegex(GDP_Error, "is deactivated"): + TransformationFactory('gdp.simple_disjunction').apply_to( + m, targets=m.disjunction + ) + + def test_inactive_disjunction_skipped_when_no_targets(self): + m = self.make_two_term_model() + m.disjunction.deactivate() + TransformationFactory('gdp.simple_disjunction').apply_to(m) + self.assertIsNone(self.get_transformation_block(m)) + + def test_only_active_constraints_selected_by_first(self): + m = self.make_two_term_model() + m.d1.c1.deactivate() # 'first' should now skip c1 and pick c2 + TransformationFactory('gdp.simple_disjunction').apply_to(m) + simple = self.get_transformation_block(m).simple_disjunction + assertExpressionsEqual( + self, self.constraints_of(simple.disjuncts[0])[0].expr, m.d1.c2.expr + ) + + # ------------------------------------------------------------------ # + # Nested disjunctions # + # ------------------------------------------------------------------ # + def test_nested_disjunction_skipped_when_no_targets(self): + m = self.make_nested_model() + TransformationFactory('gdp.simple_disjunction').apply_to(m) + # Neither the outer (contains nesting) nor the inner (is nested) is built + self.assertIsNone(self.get_transformation_block(m)) + self.assertIsNone(self.get_transformation_block(m.d1)) + + def test_nested_outer_disjunction_target_raises(self): + m = self.make_nested_model() + with self.assertRaisesRegex(GDP_Error, "contains a nested Disjunction"): + TransformationFactory('gdp.simple_disjunction').apply_to( + m, targets=m.disjunction + ) + + def test_nested_inner_disjunction_target_raises(self): + m = self.make_nested_model() + with self.assertRaisesRegex(GDP_Error, "is nested in another Disjunct"): + TransformationFactory('gdp.simple_disjunction').apply_to( + m, targets=m.d1.inner + ) + + # ------------------------------------------------------------------ # + # Targets and retrieval # + # ------------------------------------------------------------------ # + def test_block_target_collects_disjunctions(self): + m = ConcreteModel() + m.b = Block() + m.b.x = Var(bounds=(0, 10)) + m.b.d1 = Disjunct() + m.b.d1.c = Constraint(expr=m.b.x >= 1) + m.b.d2 = Disjunct() + m.b.d2.c = Constraint(expr=m.b.x <= 2) + m.b.disjunction = Disjunction(expr=[m.b.d1, m.b.d2]) + + TransformationFactory('gdp.simple_disjunction').apply_to(m, targets=m.b) + self.assertIsNotNone(self.get_transformation_block(m.b)) + + def test_target_not_on_instance_raises(self): + m = self.make_two_term_model() + other = self.make_two_term_model() + with self.assertRaisesRegex(GDP_Error, "is not a component on instance"): + TransformationFactory('gdp.simple_disjunction').apply_to( + m, targets=other.disjunction + ) + + def test_get_simple_disjunction(self): + m = self.make_two_term_model() + xform = TransformationFactory('gdp.simple_disjunction') + xform.apply_to(m) + simple = xform.get_simple_disjunction(m.disjunction) + self.assertIs(simple, self.get_transformation_block(m).simple_disjunction) + + def test_get_simple_disjunction_untransformed_raises(self): + m = self.make_two_term_model() + xform = TransformationFactory('gdp.simple_disjunction') + with self.assertRaisesRegex(GDP_Error, "has not been transformed"): + xform.get_simple_disjunction(m.disjunction) + + def test_get_src_disjunction(self): + m = self.make_two_term_model() + xform = TransformationFactory('gdp.simple_disjunction') + xform.apply_to(m) + simple = xform.get_simple_disjunction(m.disjunction) + self.assertIs(xform.get_src_disjunction(simple), m.disjunction) + + def test_src_and_simple_are_inverse(self): + m = self.make_two_term_model() + m.z = Var(bounds=(0, 10)) + m.e1 = Disjunct() + m.e1.c = Constraint(expr=m.z >= 1) + m.e2 = Disjunct() + m.e2.c = Constraint(expr=m.z <= 2) + m.disjunction2 = Disjunction(expr=[m.e1, m.e2]) + + xform = TransformationFactory('gdp.simple_disjunction') + xform.apply_to(m) + for src in (m.disjunction, m.disjunction2): + simple = xform.get_simple_disjunction(src) + self.assertIs(xform.get_src_disjunction(simple), src) + + def test_get_src_disjunction_bad_input_raises(self): + m = self.make_two_term_model() + xform = TransformationFactory('gdp.simple_disjunction') + xform.apply_to(m) + # m.disjunction is an original Disjunction, not a generated simple one + with self.assertRaisesRegex(GDP_Error, "No source disjunction found"): + xform.get_src_disjunction(m.disjunction) + + def test_transformation_out_of_place(self): + m = self.make_two_term_model() + new = TransformationFactory('gdp.simple_disjunction').create_using(m) + self.assertIsNot(new, m) + self.assertIsNotNone(self.get_transformation_block(new)) + self.assertIsNone(self.get_transformation_block(m)) + + def test_result_is_transformable_by_bigm(self): + m = self.make_two_term_model() + xform = TransformationFactory('gdp.simple_disjunction') + xform.apply_to(m) + simple = xform.get_simple_disjunction(m.disjunction) + # The generated Disjunction is a normal GDP component + TransformationFactory('gdp.bigm').apply_to(m, targets=[simple]) + self.assertIsNotNone(simple.algebraic_constraint) + + def test_bad_instance_type_raises(self): + m = self.make_two_term_model() + with self.assertRaisesRegex(GDP_Error, "must be a ConcreteModel or Block"): + TransformationFactory('gdp.simple_disjunction').apply_to(m.x) + + def test_disjunct_instance_raises(self): + m = self.make_two_term_model() + with self.assertRaisesRegex(GDP_Error, "must be a ConcreteModel or Block"): + TransformationFactory('gdp.simple_disjunction').apply_to(m.d1) + + +if __name__ == '__main__': + unittest.main()