Repository: Pyomo/pyomo

Issue:
contrib.deactivate_trivial_constraints can miss trivial constraints
## Summary

The `contrib.deactivate_trivial_constraints` transformation uses the polynomial degree of the constraint expression to decide if the expression is constant. That's not actually a reliable method of doing so, meaning that it can miss trivial constraints like in the example below, where the polynomial degree of the constraint body evaluates to 1.

### Steps to reproduce the issue

```
from pyomo.environ import *

m = ConcreteModel()
m.x = Var()
m.y = Var()
m.y.fix(0)
m.c = Constraint(expr=m.x*m.y >= 0)

TransformationFactory('contrib.deactivate_trivial_constraints').apply_to(m)

print(m.c.active) # True
```

### Information on your system

Pyomo version: main
Python version: 3.8
Operating system: linux
How Pyomo was installed (PyPI, conda, source): source
Solver (if applicable):


### Additional information

I think you could use `generate_standard_repn` to actually detect if a constraint body is constant. This isn't a bug in the sense that it does the wrong thing, but it does fall short on its promises.



Discussion:
I think using `generate_standard_repn` within `contrib.deactivate_trivial_constraints` is a good idea. Using `compute_bounds_on_expr` may work as well (in the GDPopt context, we would have to make sure we restored variable bounds before deactivating trivial constraints rather than after).
@emma58: question: is this a bug in `deactivate_trivial_constraints` or in `polynomial_degree`?
@jsiirola, it kind of does seem like a bug in `polynomial_degree` because it's going halfway between evaluating the values of constants and not... It feels like if it's going to go as far as saying that a fixed Var multiplied by a not-fixed Var no longer has polynomial degree 2, it might as well catch that the Var was fixed to 0 and answer 0. Perhaps whether to treat fixed variables as fixed forever should be an option when you ask for the polynomial degree? Because one could imagine a case where 2 would be the answer you wanted, if you're not sure that variable is going to stay fixed.

Resolving patch:
diff --git a/pyomo/contrib/preprocessing/plugins/deactivate_trivial_constraints.py b/pyomo/contrib/preprocessing/plugins/deactivate_trivial_constraints.py
--- a/pyomo/contrib/preprocessing/plugins/deactivate_trivial_constraints.py
+++ b/pyomo/contrib/preprocessing/plugins/deactivate_trivial_constraints.py
@@ -21,6 +21,7 @@
 from pyomo.core.base.transformation import TransformationFactory
 from pyomo.core.expr.numvalue import value
 from pyomo.core.plugins.transform.hierarchy import IsomorphicTransformation
+from pyomo.repn import generate_standard_repn
 
 logger = logging.getLogger('pyomo.contrib.preprocessing')
 
@@ -65,7 +66,8 @@ class TrivialConstraintDeactivator(IsomorphicTransformation):
 
     def _apply_to(self, instance, **kwargs):
         config = self.CONFIG(kwargs)
-        if config.tmp and not hasattr(instance, '_tmp_trivial_deactivated_constrs'):
+        if config.tmp and not hasattr(instance,
+                                      '_tmp_trivial_deactivated_constrs'):
             instance._tmp_trivial_deactivated_constrs = ComponentSet()
         elif config.tmp:
             logger.warning(
@@ -76,19 +78,20 @@ def _apply_to(self, instance, **kwargs):
 
         # Trivial constraints are those that do not contain any variables, ie.
         # the polynomial degree is 0
-        trivial_constraints = (
-            constr
-            for constr in instance.component_data_objects(
-                ctype=Constraint, active=True, descend_into=True)
-            if constr.body.polynomial_degree() == 0)
+        for constr in instance.component_data_objects(ctype=Constraint,
+                                                      active=True,
+                                                      descend_into=True):
+            repn = generate_standard_repn(constr.body)
+            if not repn.is_constant():
+                # This constraint is not trivial
+                continue
 
-        for constr in trivial_constraints:
             # We need to check each constraint to sure that it is not violated.
             constr_lb = value(
                 constr.lower) if constr.has_lb() else float('-inf')
             constr_ub = value(
                 constr.upper) if constr.has_ub() else float('inf')
-            constr_value = value(constr.body)
+            constr_value = repn.constant
 
             # Check if the lower bound is violated outside a given tolerance
             if (constr_value + config.tolerance <= constr_lb):
diff --git a/pyomo/contrib/preprocessing/plugins/remove_zero_terms.py b/pyomo/contrib/preprocessing/plugins/remove_zero_terms.py
--- a/pyomo/contrib/preprocessing/plugins/remove_zero_terms.py
+++ b/pyomo/contrib/preprocessing/plugins/remove_zero_terms.py
@@ -39,9 +39,12 @@ def _apply_to(self, model):
 
         for constr in m.component_data_objects(
                 ctype=Constraint, active=True, descend_into=True):
-            if not constr.body.polynomial_degree() == 1:
-                continue  # we currently only process linear constraints
             repn = generate_standard_repn(constr.body)
+            if not repn.is_linear() or repn.is_constant():
+                continue  # we currently only process linear constraints, and we
+                          # assume that trivial constraints have already been
+                          # deactivated or will be deactivated in a different
+                          # step
 
             # get the index of all nonzero coefficient variables
             nonzero_vars_indx = [


Test patch:
diff --git a/pyomo/contrib/gdpopt/tests/test_gdpopt.py b/pyomo/contrib/gdpopt/tests/test_gdpopt.py
--- a/pyomo/contrib/gdpopt/tests/test_gdpopt.py
+++ b/pyomo/contrib/gdpopt/tests/test_gdpopt.py
@@ -279,6 +279,28 @@ def test_bound_infeasibility_in_subproblems(self):
         self.assertFalse(value(m.disj.disjuncts[0].indicator_var))
         self.assertTrue(value(m.disj.disjuncts[1].indicator_var))
 
+    def test_subproblem_preprocessing_encounters_trivial_constraints(self):
+        m = ConcreteModel()
+        m.x = Var(bounds=(0, 10))
+        m.z = Var(bounds=(-10, 10))
+        m.disjunction = Disjunction(expr=[[m.x == 0, m.z >= 4], 
+                                          [m.x + m.z <= 0]])
+        m.cons = Constraint(expr=m.x*m.z <= 0)
+        m.obj = Objective(expr=-m.z)
+        m.disjunction.disjuncts[0].indicator_var.fix(True)
+        m.disjunction.disjuncts[1].indicator_var.fix(False)
+        SolverFactory('gdpopt').solve(m, strategy='RIC', mip_solver=mip_solver,
+                                      nlp_solver=nlp_solver,
+                                      init_strategy='fix_disjuncts')
+        # The real test is that this doesn't throw an error when we preprocess
+        # to solve the first subproblem (in the initialization). The nonlinear
+        # constraint becomes trivial, which we need to make sure is handled
+        # correctly.
+        self.assertEqual(value(m.x), 0)
+        self.assertEqual(value(m.z), 10)
+        self.assertTrue(value(m.disjunction.disjuncts[0].indicator_var))
+        self.assertFalse(value(m.disjunction.disjuncts[1].indicator_var))
+
     @unittest.skipUnless(sympy_available, "Sympy not available")
     def test_logical_constraints_on_disjuncts(self):
         m = models.makeLogicalConstraintsOnDisjuncts()
diff --git a/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py b/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py
--- a/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py
+++ b/pyomo/contrib/preprocessing/tests/test_deactivate_trivial_constraints.py
@@ -98,6 +98,39 @@ def _trivial_constraints_ub_conflict(self):
         TransformationFactory(
             'contrib.deactivate_trivial_constraints').apply_to(m)
 
+    def test_trivial_constraint_due_to_0_coefficient(self):
+        m = ConcreteModel()
+        m.x = Var()
+        m.y = Var()
+        m.y.fix(0)
+        m.c = Constraint(expr=m.x*m.y >= 0)
+
+        TransformationFactory(
+            'contrib.deactivate_trivial_constraints').apply_to(m)
+
+        self.assertFalse(m.c.active)
+
+    def test_higher_degree_trivial_constraint(self):
+        m = ConcreteModel()
+        m.x = Var()
+        m.y = Var()
+        m.z = Var()
+        m.c = Constraint(expr=(m.x**2 + m.y)*m.z >= -8)
+        m.z.fix(0)
+        TransformationFactory(
+            'contrib.deactivate_trivial_constraints').apply_to(m)
+        self.assertFalse(m.c.active)
+
+    def test_trivial_linear_constraint_due_to_cancellation(self):
+        m = ConcreteModel()
+        m.x = Var()
+        m.c = Constraint(expr=m.x - m.x <= 0)
+
+        TransformationFactory(
+            'contrib.deactivate_trivial_constraints').apply_to(m)
+
+        self.assertFalse(m.c.active)
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py b/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py
--- a/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py
+++ b/pyomo/contrib/preprocessing/tests/test_zero_term_removal.py
@@ -3,6 +3,7 @@
 from pyomo.environ import (ConcreteModel, Constraint, TransformationFactory,
                            Var)
 from pyomo.core.expr import current as EXPR
+from pyomo.repn import generate_standard_repn
 
 
 class TestRemoveZeroTerms(unittest.TestCase):
@@ -33,6 +34,27 @@ def test_zero_term_removal(self):
         self.assertFalse(any(id(m.v1) == id(v)
                              for v in EXPR.identify_variables(m.c4.body)))
 
+    def test_trivial_constraints_skipped(self):
+        m = ConcreteModel()
+        m.x = Var()
+        m.y = Var()
+        m.z = Var()
+        m.c = Constraint(expr=(m.x + m.y)*m.z >= 8)
+        m.z.fix(0)
+        TransformationFactory('contrib.remove_zero_terms').apply_to(m)
+        m.z.unfix()
+        # check constraint is unchanged
+        self.assertEqual(m.c.lower, 8)
+        self.assertIsNone(m.c.upper)
+        repn = generate_standard_repn(m.c.body)
+        self.assertTrue(repn.is_quadratic())
+        self.assertEqual(repn.quadratic_coefs[0], 1)
+        self.assertEqual(repn.quadratic_coefs[1], 1)
+        self.assertIs(repn.quadratic_vars[0][0], m.x)
+        self.assertIs(repn.quadratic_vars[0][1], m.z)
+        self.assertIs(repn.quadratic_vars[1][0], m.y)
+        self.assertIs(repn.quadratic_vars[1][1], m.z)
+        self.assertEqual(repn.constant, 0)
 
 if __name__ == '__main__':
     unittest.main()