diff --git a/pyomo/core/base/__init__.py b/pyomo/core/base/__init__.py index d92999aa931..6d8d22c85a9 100644 --- a/pyomo/core/base/__init__.py +++ b/pyomo/core/base/__init__.py @@ -38,7 +38,7 @@ from pyomo.core.base.component import name, Component, ModelComponentFactory from pyomo.core.base.componentuid import ComponentUID from pyomo.core.base.config import PyomoOptions -from pyomo.core.base.enums import SortComponents, TraversalStrategy +from pyomo.core.base.enums import SortComponents, TraversalStrategy, VarCollector from pyomo.core.base.label import ( CuidLabeler, CounterLabeler, diff --git a/pyomo/core/base/enums.py b/pyomo/core/base/enums.py index 9fe6e4f9f36..5eb90c9f69c 100644 --- a/pyomo/core/base/enums.py +++ b/pyomo/core/base/enums.py @@ -11,6 +11,7 @@ import enum import sys +from pyomo.common import enums if sys.version_info[:2] >= (3, 11): strictEnum = {'boundary': enum.STRICT} @@ -93,3 +94,8 @@ def sort_names(flag): @staticmethod def sort_indices(flag): return SortComponents.SORTED_INDICES in SortComponents(flag) + + +class VarCollector(enums.IntEnum): + FromVarComponents = 1 + FromExpressions = 2 diff --git a/pyomo/core/plugins/transform/discrete_vars.py b/pyomo/core/plugins/transform/discrete_vars.py index 89cd1804266..4c1d331f758 100644 --- a/pyomo/core/plugins/transform/discrete_vars.py +++ b/pyomo/core/plugins/transform/discrete_vars.py @@ -14,7 +14,23 @@ logger = logging.getLogger('pyomo.core') from pyomo.common import deprecated -from pyomo.core.base import Transformation, TransformationFactory, Var, Suffix, Reals +from pyomo.common.config import ConfigDict, ConfigValue, In, IsInstance +from pyomo.common.deprecation import deprecation_warning +from pyomo.core.base import ( + Transformation, + TransformationFactory, + Var, + Suffix, + Reals, + Block, + ReverseTransformationToken, + VarCollector, + Constraint, + Objective, +) +from pyomo.core.util import target_list +from pyomo.gdp import Disjunct +from pyomo.util.vars_from_expressions import get_vars_from_components # @@ -25,12 +41,95 @@ 'core.relax_integer_vars', doc="Relax integer variables to continuous counterparts" ) class RelaxIntegerVars(Transformation): + CONFIG = ConfigDict('core.relax_integer_vars') + CONFIG.declare( + 'targets', + ConfigValue( + default=None, + domain=target_list, + description="target or list of targets that will be relaxed", + doc=""" + This specifies the list of components to relax. If None (default), the + entire model is transformed. 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( + 'reverse', + ConfigValue( + default=None, + domain=IsInstance(ReverseTransformationToken), + description="The token returned by a (forward) call to this " + "transformation, if you wish to reverse the transformation.", + doc=""" + This argument should be the reverse transformation token + returned by a previous call to this transformation to transform + fixed disjunctive state in the given model. + If this argument is specified, this call to the transformation + will reverse what the transformation did in the call that returned + the token. Note that if there are intermediate changes to the model + in between the forward and the backward calls to the transformation, + the behavior could be unexpected. + """, + ), + ) + CONFIG.declare( + 'var_collector', + ConfigValue( + default=VarCollector.FromVarComponents, + domain=In(VarCollector), + description="The method for collection the Vars to relax. If " + "VarCollector.FromVarComponents (default), any Var component on " + "the active tree will be relaxed.", + doc=""" + This specifies the method for collecting the Var components to relax. + The default, VarCollector.FromVarComponents, assumes that all relevant + Vars are on the active tree. If this is true, then this is the most + performant option. However, in more complex cases where some Vars may not + be in the active tree (e.g. some are on deactivated Blocks or come from + other models), specify VarCollector.FromExpressions to relax all Vars that + appear in expressions in the active tree. + """, + ), + ) + CONFIG.declare( + 'transform_deactivated_blocks', + ConfigValue( + default=True, + description="[DEPRECATED]: Whether or not to search for Var components to " + "relax on deactivated Blocks. True by default", + ), + ) + CONFIG.declare( + 'undo', + ConfigValue( + default=False, + domain=bool, + description="[DEPRECATED]: Please use the 'reverse' argument to undo " + "the transformation.", + ), + ) + def __init__(self): - super(RelaxIntegerVars, self).__init__() + super().__init__() def _apply_to(self, model, **kwds): - options = kwds.pop('options', {}) - if kwds.get('undo', options.get('undo', False)): + if not model.ctype in (Block, Disjunct): + raise ValueError( + "Transformation called on %s of type %s. 'model' " + "must be a ConcreteModel or Block." % (model.name, model.ctype) + ) + config = self.CONFIG(kwds.pop('options', {})) + config.set_value(kwds) + + if config.undo: + deprecation_warning( + "The 'undo' argument is deprecated. Please use the 'reverse' " + "argument to undo the transformation.", + version='6.9.3.dev0', + ) for v, d in model._relaxed_integer_vars[None].values(): bounds = v.bounds v.domain = d @@ -38,43 +137,85 @@ def _apply_to(self, model, **kwds): v.setub(bounds[1]) model.del_component("_relaxed_integer_vars") return - # True by default, you can specify False if you want - descend = kwds.get( - 'transform_deactivated_blocks', - options.get('transform_deactivated_blocks', True), - ) - active = None if descend else True - # Relax the model - relaxed_vars = {} - _base_model_vars = model.component_data_objects( - Var, active=active, descend_into=True - ) - for var in _base_model_vars: + targets = (model,) if config.targets is None else config.targets + + if config.reverse is None: + reverse_dict = {} + # Relax the model + reverse_token = ReverseTransformationToken( + self.__class__, model, targets, reverse_dict + ) + else: + # reverse the transformation + reverse_token = config.reverse + reverse_token.check_token_valid(self.__class__, model, targets) + reverse_dict = reverse_token.reverse_dict + for v, d in reverse_dict.values(): + lb, ub = v.bounds + v.domain = d + v.setlb(lb) + v.setub(ub) + return + + ### [ESJ 4/29/25]: This can go away when we remove 'undo' + model._relaxed_integer_vars = Suffix(direction=Suffix.LOCAL) + model._relaxed_integer_vars[None] = reverse_dict + ### + + for t in targets: + if isinstance(t, Block): + blocks = t.values() if t.is_indexed() else (t,) + for block in blocks: + self._relax_block(block, config, reverse_dict) + elif t.ctype is Var: + self._relax_var(t, reverse_dict) + else: + raise ValueError( + "Target '%s' was not a Block or Var. It was of type " + "'%s' and cannot be transformed." % (t.name, type(t)) + ) + + return reverse_token + + def _relax_block(self, block, config, reverse_dict): + self._relax_vars_from_block(block, config, reverse_dict) + + for b in block.component_data_objects(Block, active=None, descend_into=True): + if not b.active: + if config.transform_deactivated_blocks: + deprecation_warning( + "The `transform_deactivated_blocks` arguments is deprecated. " + "Either specify deactivated Blocks as targets to activate them " + "if transforming them is the desired behavior.", + version='6.9.3.dev0', + ) + else: + continue + self._relax_vars_from_block(b, config, reverse_dict) + + def _relax_vars_from_block(self, block, config, reverse_dict): + if config.var_collector is VarCollector.FromVarComponents: + model_vars = block.component_data_objects(Var, descend_into=False) + else: + model_vars = get_vars_from_components( + block, ctype=(Constraint, Objective), descend_into=False + ) + for var in model_vars: + if id(var) not in reverse_dict: + self._relax_var(var, reverse_dict) + + def _relax_var(self, v, reverse_dict): + var_datas = v.values() if v.is_indexed() else (v,) + for var in var_datas: if not var.is_integer(): continue - # Note: some indexed components can only have their - # domain set on the parent component (the individual - # indices cannot be set independently) - _c = var.parent_component() - try: - lb, ub = var.bounds - _domain = var.domain - var.domain = Reals - var.setlb(lb) - var.setub(ub) - relaxed_vars[id(var)] = (var, _domain) - except: - if id(_c) in relaxed_vars: - continue - _domain = _c.domain - lb, ub = _c.bounds - _c.domain = Reals - _c.setlb(lb) - _c.setub(ub) - relaxed_vars[id(_c)] = (_c, _domain) - model._relaxed_integer_vars = Suffix(direction=Suffix.LOCAL) - model._relaxed_integer_vars[None] = relaxed_vars + lb, ub = var.bounds + _domain = var.domain + var.domain = Reals + var.setlb(lb) + var.setub(ub) + reverse_dict[id(var)] = (var, _domain) @TransformationFactory.register( diff --git a/pyomo/core/tests/unit/test_xfrm_discrete_vars.py b/pyomo/core/tests/unit/test_xfrm_discrete_vars.py index 07edce21771..95dc42001bc 100644 --- a/pyomo/core/tests/unit/test_xfrm_discrete_vars.py +++ b/pyomo/core/tests/unit/test_xfrm_discrete_vars.py @@ -13,6 +13,7 @@ import pyomo.common.unittest as unittest +from pyomo.core.base import VarCollector from pyomo.environ import ( ConcreteModel, Var, @@ -23,6 +24,9 @@ TransformationFactory, SolverFactory, Reals, + Block, + Integers, + value, ) from pyomo.opt import check_available_solvers @@ -40,6 +44,17 @@ def _generateModel(): return model +def _make_hierarchical_model(): + m = ConcreteModel() + m.y = Var(domain=Binary) + m.b = Block() + m.b.x = Var([1, 2], bounds=(2, 45), domain=Integers) + m.b.y = Var(domain=Binary) + m.b.c = Constraint(expr=m.b.x[1] * m.y <= 23) + + return m + + class Test(unittest.TestCase): @unittest.skipIf(len(solvers) == 0, "LP/MIP solver not available") def test_solve_relax_transform(self): @@ -51,7 +66,7 @@ def test_solve_relax_transform(self): s.solve(m) self.assertEqual(len(m.dual), 0) - TransformationFactory('core.relax_discrete').apply_to(m) + TransformationFactory('core.relax_integer_vars').apply_to(m) self.assertIs(m.x.domain, Reals) self.assertEqual(m.x.lb, 0) self.assertEqual(m.x.ub, 1) @@ -60,6 +75,185 @@ def test_solve_relax_transform(self): self.assertAlmostEqual(m.dual[m.c1], -0.5, 4) self.assertAlmostEqual(m.dual[m.c2], -0.5, 4) + def test_reverse_relax_integer_vars(self): + m = _generateModel() + lp_relax = TransformationFactory('core.relax_integer_vars') + reverse = lp_relax.apply_to(m) + self.assertIs(m.x.domain, Reals) + self.assertIs(m.y.domain, Reals) + self.assertEqual(m.x.lb, 0) + self.assertEqual(m.x.ub, 1) + self.assertIsNone(m.y.lb) + self.assertIsNone(m.y.ub) + + lp_relax.apply_to(m, reverse=reverse) + self.assertIs(m.x.domain, Binary) + self.assertIs(m.y.domain, Reals) + self.assertEqual(m.x.lb, 0) + self.assertEqual(m.x.ub, 1) + self.assertIsNone(m.y.lb) + self.assertIsNone(m.y.ub) + + def test_relax_integer_vars_block_targets(self): + m = _make_hierarchical_model() + TransformationFactory('core.relax_integer_vars').apply_to(m, targets=m.b) + for i in [1, 2]: + self.assertIs(m.b.x[i].domain, Reals) + self.assertEqual(m.b.x[i].lb, 2) + self.assertEqual(m.b.x[i].ub, 45) + self.assertIs(m.b.y.domain, Reals) + self.assertEqual(m.b.y.lb, 0) + self.assertEqual(m.b.y.ub, 1) + + self.assertIs(m.y.domain, Binary) + self.assertEqual(m.y.lb, 0) + self.assertEqual(m.y.ub, 1) + + def test_relax_integer_vars_var_data_targets(self): + m = _make_hierarchical_model() + TransformationFactory('core.relax_integer_vars').apply_to( + m, targets=[m.b.x[1], m.y] + ) + # transformed + self.assertIs(m.b.x[1].domain, Reals) + self.assertEqual(m.b.x[1].lb, 2) + self.assertEqual(m.b.x[1].ub, 45) + # not transformed + self.assertIs(m.b.x[2].domain, Integers) + self.assertEqual(m.b.x[2].lb, 2) + self.assertEqual(m.b.x[2].ub, 45) + # not transformed + self.assertIs(m.b.y.domain, Binary) + self.assertEqual(m.b.y.lb, 0) + self.assertEqual(m.b.y.ub, 1) + # transformed + self.assertIs(m.y.domain, Reals) + self.assertEqual(m.y.lb, 0) + self.assertEqual(m.y.ub, 1) + + def test_relax_integer_vars_indexed_var_targets(self): + m = _make_hierarchical_model() + TransformationFactory('core.relax_integer_vars').apply_to(m, targets=m.b.x) + # transformed + for i in [1, 2]: + self.assertIs(m.b.x[i].domain, Reals) + self.assertEqual(m.b.x[i].lb, 2) + self.assertEqual(m.b.x[i].ub, 45) + # not transformed + self.assertIs(m.b.y.domain, Binary) + self.assertEqual(m.b.y.lb, 0) + self.assertEqual(m.b.y.ub, 1) + # not transformed + self.assertIs(m.y.domain, Binary) + self.assertEqual(m.y.lb, 0) + self.assertEqual(m.y.ub, 1) + + def test_relax_integer_vars_vars_from_expressions(self): + m = _make_hierarchical_model() + TransformationFactory('core.relax_integer_vars').apply_to( + m.b, var_collector=VarCollector.FromExpressions + ) + # transformed + self.assertIs(m.b.x[1].domain, Reals) + self.assertEqual(m.b.x[1].lb, 2) + self.assertEqual(m.b.x[1].ub, 45) + # not transformed + self.assertIs(m.b.x[2].domain, Integers) + self.assertEqual(m.b.x[2].lb, 2) + self.assertEqual(m.b.x[2].ub, 45) + # not transformed + self.assertIs(m.b.y.domain, Binary) + self.assertEqual(m.b.y.lb, 0) + self.assertEqual(m.b.y.ub, 1) + # transformed + self.assertIs(m.y.domain, Reals) + self.assertEqual(m.y.lb, 0) + self.assertEqual(m.y.ub, 1) + + def test_relax_integer_vars_ignore_deactivated_blocks(self): + m = _make_hierarchical_model() + m.b.deactivate() + TransformationFactory('core.relax_integer_vars').apply_to( + m, transform_deactivated_blocks=False + ) + # not transformed + for i in [1, 2]: + self.assertIs(m.b.x[i].domain, Integers) + self.assertEqual(m.b.x[i].lb, 2) + self.assertEqual(m.b.x[i].ub, 45) + # not transformed + self.assertIs(m.b.y.domain, Binary) + self.assertEqual(m.b.y.lb, 0) + self.assertEqual(m.b.y.ub, 1) + # transformed + self.assertIs(m.y.domain, Reals) + self.assertEqual(m.y.lb, 0) + self.assertEqual(m.y.ub, 1) + + m = _make_hierarchical_model() + m.obj = Objective(expr=m.b.x[2]) + m.b.deactivate() + TransformationFactory('core.relax_integer_vars').apply_to( + m, + transform_deactivated_blocks=False, + var_collector=VarCollector.FromExpressions, + ) + # not transformed + self.assertIs(m.b.x[1].domain, Integers) + self.assertEqual(m.b.x[1].lb, 2) + self.assertEqual(m.b.x[1].ub, 45) + # transformed + self.assertIs(m.b.x[2].domain, Reals) + self.assertEqual(m.b.x[2].lb, 2) + self.assertEqual(m.b.x[2].ub, 45) + # not transformed + self.assertIs(m.b.y.domain, Binary) + self.assertEqual(m.b.y.lb, 0) + self.assertEqual(m.b.y.ub, 1) + # not transformed + self.assertIs(m.y.domain, Binary) + self.assertEqual(m.y.lb, 0) + self.assertEqual(m.y.ub, 1) + + def test_relax_integer_vars_fixed_vars(self): + m = _make_hierarchical_model() + m.y.fix(0) + m.b.y.fix(1) + reverse = TransformationFactory('core.relax_integer_vars').apply_to(m) + + # change the domain, but don't unfix + self.assertIs(m.y.domain, Reals) + self.assertEqual(m.y.lb, 0) + self.assertEqual(m.y.ub, 1) + self.assertEqual(value(m.y), 0) + self.assertTrue(m.y.fixed) + + self.assertIs(m.b.y.domain, Reals) + self.assertEqual(m.b.y.lb, 0) + self.assertEqual(m.b.y.ub, 1) + self.assertEqual(value(m.b.y), 1) + self.assertTrue(m.b.y.fixed) + + # transformed + for i in [1, 2]: + self.assertIs(m.b.x[i].domain, Reals) + self.assertEqual(m.b.x[i].lb, 2) + self.assertEqual(m.b.x[i].ub, 45) + + # reverse and make sure fixed guys are still fixed + TransformationFactory('core.relax_integer_vars').apply_to(m, reverse=reverse) + self.assertIs(m.y.domain, Binary) + self.assertEqual(m.y.lb, 0) + self.assertEqual(m.y.ub, 1) + self.assertEqual(value(m.y), 0) + self.assertTrue(m.y.fixed) + + self.assertIs(m.b.y.domain, Binary) + self.assertEqual(m.b.y.lb, 0) + self.assertEqual(m.b.y.ub, 1) + self.assertEqual(value(m.b.y), 1) + self.assertTrue(m.b.y.fixed) + @unittest.skipIf(len(solvers) == 0, "LP/MIP solver not available") def test_solve_fix_transform(self): s = SolverFactory(solvers[0])