diff --git a/pyomo/core/plugins/transform/add_slack_vars.py b/pyomo/core/plugins/transform/add_slack_vars.py index 7a3c4939671..5ef42f4dde0 100644 --- a/pyomo/core/plugins/transform/add_slack_vars.py +++ b/pyomo/core/plugins/transform/add_slack_vars.py @@ -7,6 +7,9 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +from collections import defaultdict +from operator import attrgetter + from pyomo.core import ( TransformationFactory, Var, @@ -15,14 +18,30 @@ Objective, Block, value, + Expression, + Param, + Suffix, ) +from pyomo.common.autoslots import AutoSlots +from pyomo.common.collections import ComponentMap from pyomo.common.modeling import unique_component_name from pyomo.core.plugins.transform.hierarchy import NonIsomorphicTransformation from pyomo.common.config import ConfigBlock, ConfigValue -from pyomo.core.base import ComponentUID +from pyomo.core.base import ComponentUID, SortComponents from pyomo.common.deprecation import deprecation_warning +from pyomo.repn.util import categorize_valid_components + +### FIXME: Remove the following as soon as non-active components no +### longer report active==True +from pyomo.network import Port +from pyomo.core.base import RangeSet, Set + +import logging + +logger = logging.getLogger('pyomo.core') + def target_list(x): deprecation_msg = ( @@ -64,9 +83,16 @@ def target_list(x): ) -import logging +class _AddSlackVariablesData(AutoSlots.Mixin): + __slots__ = ('slack_variables', 'relaxed_constraint', 'summed_slacks_expr') + + def __init__(self): + self.slack_variables = defaultdict(list) + self.relaxed_constraint = ComponentMap() + self.summed_slacks_expr = None -logger = logging.getLogger('pyomo.core') + +Block.register_private_data_initializer(_AddSlackVariablesData) @TransformationFactory.register( @@ -90,6 +116,22 @@ class AddSlackVariables(NonIsomorphicTransformation): doc="This specifies the list of Constraints to add slack variables to.", ), ) + CONFIG.declare( + 'add_slack_objective', + ConfigValue( + default=True, + domain=bool, + description="Whether or not to change the model objective to minimizing " + "the added slack variables.", + doc=""" + Whether or not to change the problem objective to minimize the added slack + variables. If True (the default), the original objective is deactivated + and the transformation adds an objective to minimize the sum of the added + (non-negative) slack variables. If False, the transformation does not + change the model objective. + """, + ), + ) def __init__(self, **kwds): kwds['name'] = "add_slack_vars" @@ -103,10 +145,11 @@ def _apply_to_impl(self, instance, **kwds): config.set_value(kwds) targets = config.targets + trans_info = instance.private_data() + if targets is None: - constraintDatas = instance.component_data_objects( - Constraint, descend_into=True - ) + constraintDatas = self._get_all_constraint_datas(instance) + else: constraintDatas = [] for t in targets: @@ -126,10 +169,6 @@ def _apply_to_impl(self, instance, **kwds): else: constraintDatas.append(t) - # deactivate the objective - for o in instance.component_data_objects(Objective): - o.deactivate() - # create block where we can add slack variables safely xblockname = unique_component_name(instance, "_core_add_slack_variables") instance.add_component(xblockname, Block()) @@ -161,6 +200,8 @@ def _apply_to_impl(self, instance, **kwds): body += posSlack # penalize slack in objective obj_expr += posSlack + trans_info.slack_variables[cons].append(posSlack) + trans_info.relaxed_constraint[posSlack] = cons if upper is not None: # we subtract a positive slack variable from the body: # declare slack @@ -171,6 +212,141 @@ def _apply_to_impl(self, instance, **kwds): body -= negSlack # add slack to objective obj_expr += negSlack + trans_info.slack_variables[cons].append(negSlack) + trans_info.relaxed_constraint[negSlack] = cons + cons.set_value((lower, body, upper)) - # make a new objective that minimizes sum of slack variables - xblock._slack_objective = Objective(expr=obj_expr) + + trans_info.summed_slacks_expr = obj_expr + if config.add_slack_objective: + # deactivate the objective + for o in instance.component_data_objects(Objective): + o.deactivate() + + # make a new objective that minimizes sum of slack variables + xblock._slack_objective = Objective(expr=obj_expr) + + def _get_all_constraint_datas(self, model): + components, unknown = categorize_valid_components( + model, + active=True, + sort=SortComponents.deterministic, + valid={ + Block, + Expression, + Var, + Param, + Suffix, + Objective, + # FIXME: Non-active components should not report as Active + Set, + RangeSet, + Port, + }, + targets={Constraint}, + ) + if unknown: + raise ValueError( + "The model ('%s') contains the following active components " + "that the 'core.add_slack_variables' transformation does not " + "know how to process:\n\t%s\nIf these components are Block-like " + "(e.g., Disjuncts) and the intent is to add slacks on them, call " + "the transformation on them directly." + % ( + model.name, + "\n\t".join( + sorted( + "%s:\n\t\t%s" + % (k, "\n\t\t".join(sorted(map(attrgetter('name'), v)))) + for k, v in unknown.items() + ) + ), + ) + ) + if components[Constraint]: + for block in components[Constraint]: + for cons in block.component_data_objects( + Constraint, + active=True, + descend_into=False, + sort=SortComponents.deterministic, + ): + yield cons + + def get_slack_variables(self, model, constraint): + """Return the list of slack variables used to relax 'constraint.' Note + that if 'constraint' is one-sided, there will be a single variable in + the list, but if it is a ranged constraint (l <= expr <= u) or an + equality, there will be two variables. + + Returns + ------- + List of slack variables + + Parameters + ---------- + model: ConcreteModel + A model, having had the 'core.add_slack_variables' transformation + applied to it + constraint: Constraint + A constraint that was relaxed by the transformation (either + because no targets were specified or because it was a target) + """ + slack_variables = model.private_data().slack_variables + if constraint in slack_variables: + return slack_variables[constraint] + else: + raise ValueError( + f"It does not appear that {constraint.name} is a constraint " + f"on model {model.name} that was relaxed by the " + f"'core.add_slack_variables' transformation." + ) + + def get_relaxed_constraint(self, model, slack_var): + """Return the constraint that 'slack_var' is used to relax. + + Returns + ------- + Constraint + + Parameters + ----------- + model: ConcreteModel + A model, having had the 'core.add_slack_variables' transformation + applied to it + slack_var: Var + A variable created by the 'core.add_slack_variables' transformation to + relax a constraint. + """ + relaxed_constraints = model.private_data().relaxed_constraint + if slack_var in relaxed_constraints: + return relaxed_constraints[slack_var] + else: + raise ValueError( + f"It does not appear that {slack_var.name} is a slack variable " + f"created by applying the 'core.add_slack_variables' transformation " + f"to model {model.name}." + ) + + def get_summed_slacks_expr(self, model): + """Return an expression summing all the slacks added to the model during the + transformation. This would most commonly be used to add a penalty on non-zero + slacks to an existing objective. + + Returns + ------- + Expression + + Parameters + ---------- + model: ConcreteModel + A model, having had the 'core.add_slack_variables' transformation + applied to it + """ + expr = model.private_data().summed_slacks_expr + if expr is None: + raise ValueError( + f"It does not appear that {model.name} is a model that was transformed " + f"by the 'core.add_slack_variables' transformation." + ) + return expr diff --git a/pyomo/core/tests/transform/test_add_slacks.py b/pyomo/core/tests/transform/test_add_slacks.py index bd089dd3735..1330fb7e1b0 100644 --- a/pyomo/core/tests/transform/test_add_slacks.py +++ b/pyomo/core/tests/transform/test_add_slacks.py @@ -18,6 +18,8 @@ import pyomo.common.unittest as unittest import random +from pyomo.gdp import Disjunct + from pyomo.opt import check_available_solvers from pyomo.environ import ( ConcreteModel, @@ -87,6 +89,26 @@ def test_slack_vars_added(self): self.assertEqual(xblock._slack_plus_rule2.bounds, (0, None)) self.assertEqual(xblock._slack_plus_rule3.bounds, (0, None)) + def test_mapping_api(self): + m = self.makeModel() + trans = TransformationFactory('core.add_slack_variables') + trans.apply_to(m) + + slacks = trans.get_slack_variables(m, m.rule1) + self.assertEqual(len(slacks), 1) + slack = slacks[0] + self.assertIs(m.rule1, trans.get_relaxed_constraint(m, slack)) + + slacks = trans.get_slack_variables(m, m.rule2) + self.assertEqual(len(slacks), 2) + self.assertIs(m.rule2, trans.get_relaxed_constraint(m, slacks[0])) + self.assertIs(m.rule2, trans.get_relaxed_constraint(m, slacks[1])) + + slacks = trans.get_slack_variables(m, m.rule3) + self.assertEqual(len(slacks), 1) + slack = slacks[0] + self.assertIs(m.rule3, trans.get_relaxed_constraint(m, slack)) + # wrapping this as a method because I use it again later when I test # targets def checkRule1(self, m): @@ -180,6 +202,17 @@ def test_new_obj_created(self): ), ) + def test_no_objective_change(self): + m = self.makeModel() + TransformationFactory('core.add_slack_variables').apply_to( + m, add_slack_objective=False + ) + + self.assertTrue(m.obj.active) + transBlock = m.component("_core_add_slack_variables") + obj = transBlock.component("_slack_objective") + self.assertIsNone(obj) + def test_badModel_err(self): model = ConcreteModel() model.x = Var(within=NonNegativeReals) @@ -345,6 +378,30 @@ def test_error_for_non_constraint_target_in_list(self): targets=[m.rule1, m.x], ) + def test_error_for_mapping_untransformed_constraint(self): + m = self.makeModel() + trans = TransformationFactory('core.add_slack_variables') + trans.apply_to(m, targets=[m.rule1]) + with self.assertRaisesRegex( + ValueError, + f"It does not appear that {m.rule2.name} is a constraint " + f"on model {m.name} that was relaxed by the " + f"'core.add_slack_variables' transformation.", + ): + cons = trans.get_slack_variables(m, m.rule2) + + def test_error_for_mapping_non_slack_variables(self): + m = self.makeModel() + trans = TransformationFactory('core.add_slack_variables') + trans.apply_to(m) + with self.assertRaisesRegex( + ValueError, + f"It does not appear that {m.x} is a slack variable " + f"created by applying the 'core.add_slack_variables' transformation " + f"to model {m.name}.", + ): + cons = trans.get_relaxed_constraint(m, m.x) + def test_deprecation_warning_for_cuid_target(self): m = self.makeModel() out = StringIO() @@ -432,6 +489,25 @@ def test_transformed_constraint_scalar_body(self): self.assertEqual(c.body.arg(1).arg(0), -1) self.assertIs(c.body.arg(1).arg(1), transBlock._slack_minus_rule4) + def test_gdp_error(self): + m = self.makeModel() + m.disj = Disjunct() + + with self.assertRaisesRegex( + ValueError, + r"The model \('unknown'\) contains the following active components that the " + r"'core.add_slack_variables' transformation does not know how to process:" + + "\n\t" + + r"\:" + + "\n\t\t" + + r"disj" + + "\n" + + r"If these components are Block-like " + r"\(e.g., Disjuncts\) and the intent is to add slacks on them, call " + r"the transformation on them directly.", + ): + TransformationFactory('core.add_slack_variables').apply_to(m) + class TestAddSlacks_IndexedConstraints(unittest.TestCase): @staticmethod @@ -449,6 +525,43 @@ def rule1_rule(m, s): m.obj = Objective(expr=sum(m.x[s] for s in m.S) - m.y) return m + def test_mapping_api(self): + m = self.makeModel() + trans = TransformationFactory('core.add_slack_variables') + trans.apply_to(m) + + for i in m.S: + slacks = trans.get_slack_variables(m, m.rule1[i]) + self.assertEqual(len(slacks), 1) + slack = slacks[0] + self.assertIs(m.rule1[i], trans.get_relaxed_constraint(m, slack)) + + slacks = trans.get_slack_variables(m, m.rule2) + self.assertEqual(len(slacks), 1) + self.assertIs(m.rule2, trans.get_relaxed_constraint(m, slacks[0])) + + def test_summation_of_slacks_api(self): + m = self.makeModel() + m.rule2.deactivate() + trans = TransformationFactory('core.add_slack_variables') + trans.apply_to(m) + + assertExpressionsEqual( + self, + trans.get_summed_slacks_expr(m), + sum(trans.get_slack_variables(m, m.rule1[i])[0] for i in m.S), + ) + + def test_summation_of_slacks_error(self): + m = self.makeModel() + trans = TransformationFactory('core.add_slack_variables') + with self.assertRaisesRegex( + ValueError, + "It does not appear that unknown is a model that was transformed " + "by the 'core.add_slack_variables' transformation.", + ): + trans.get_summed_slacks_expr(m) + def checkSlackVars_indexedtarget(self, transBlock): self.assertIsInstance(transBlock.component("_slack_plus_rule1[1]"), Var) self.assertIsInstance(transBlock.component("_slack_plus_rule1[2]"), Var)