Repository: Pyomo/pyomo

Issue:
calculate_variable_from_constraint can fail for simple constraints due to domain violation
## Summary

Using the `calculate_variable_from_constraint` method can result in unexpected failures due to trying to set the value of a `Var` outside of its domain, as shown in the example below. In this case, a simple constraint of `v == 0` can be successfully solved, but fails if the constraint is scaled by a factor of 10 (ie. `10*v == 0`).

```
# example.py
import pyomo.environ as pyo
from pyomo.util.calc_var_value import calculate_variable_from_constraint

# Create basic model
m = pyo.ConcreteModel()
m.v1 = pyo.Var(initialize=1, domain=pyo.NonNegativeReals)

m.c1 = pyo.Constraint(expr=m.v1 == 0)

# Calculate value of v1 using constraint c1
calculate_variable_from_constraint(m.v1, m.c1)

# This works as expected
assert pyo.value(m.v1) == 0

# However, if we multiply v1 by 10:
m.c2 = pyo.Constraint(expr=m.v1*10 == 0)
m.v1.set_value(1)

calculate_variable_from_constraint(m.v1, m.c2)

>>> ValueError: Numeric value `-9` (<class 'int'>) is not in domain NonNegativeReals for Var v1
```

This issue arises due to line 113 in `calculate_variable_from_constraint` which assumes that the constraint is linear with a coefficient of 1 w.r.t. x to quickly solve for trivial cases (such as the initial problem). However, in the case of the scaled problem this violates the domain on the `Var` causing `calculate_variable_from_constraint` to fail when ideally it would continue.

I think the first part of the solution is to wrap line 113 in a `try/except` to handle this case, but this leads to the question of what to do in the except. However, the next step in the method assumes that line 113 set the value of the variable to a new value such that there is also a new residual for the constraint, which is used to estimate the derivative w.r.t. the variable. I the except is just `pass`, then this next step fails due to a division by zero error (as the value of the variable is the same).


Discussion:

Resolving patch:
diff --git a/pyomo/util/calc_var_value.py b/pyomo/util/calc_var_value.py
--- a/pyomo/util/calc_var_value.py
+++ b/pyomo/util/calc_var_value.py
@@ -28,6 +28,12 @@ def calculate_variable_from_constraint(variable, constraint,
     residual, it falls back on Newton's method using exact (symbolic)
     derivatives.
 
+    Notes
+    -----
+    This is an unconstrained solver and is NOT guaranteed to respect the
+    variable bounds or domain.  The solver may leave the variable value
+    in an infeasible state (outside the declared bounds or domain bounds).
+
     Parameters:
     -----------
     variable: :py:class:`_VarData`
@@ -53,9 +59,6 @@ def calculate_variable_from_constraint(variable, constraint,
     --------
     None
 
-    Note: this is an unconstrained solver and is NOT guaranteed to
-    respect the variable bounds.
-
     """
     # Leverage all the Constraint logic to process the incoming tuple/expression
     if not isinstance(constraint, _ConstraintData):
@@ -70,27 +73,30 @@ def calculate_variable_from_constraint(variable, constraint,
         raise ValueError("Constraint must be an equality constraint")
 
     if variable.value is None:
+        # Note that we use "valid=True" here as well, as the variable
+        # domain may not admit the calculated initial guesses, and we
+        # want to bypass that check.
         if variable.lb is None:
             if variable.ub is None:
                 # no variable values, and no lower or upper bound - set
                 # initial value to 0.0
-                variable.set_value(0)
+                variable.set_value(0, valid=True)
             else:
                 # no variable value or lower bound - set to 0 or upper
                 # bound whichever is lower
-                variable.set_value(min(0, variable.ub))
+                variable.set_value(min(0, variable.ub), valid=True)
         elif variable.ub is None:
             # no variable value or upper bound - set to 0 or lower
             # bound, whichever is higher
-            variable.set_value(max(0, variable.lb))
+            variable.set_value(max(0, variable.lb), valid=True)
         else:
             # we have upper and lower bounds
             if variable.lb <= 0 and variable.ub >= 0:
                 # set the initial value to 0 if bounds bracket 0
-                variable.set_value(0)
+                variable.set_value(0, valid=True)
             else:
                 # set the initial value to the midpoint of the bounds
-                variable.set_value((variable.lb+variable.ub)/2.0)
+                variable.set_value((variable.lb+variable.ub)/2.0, valid=True)
 
     # store the initial value to use later if necessary
     orig_initial_value = variable.value
@@ -110,7 +116,7 @@ def calculate_variable_from_constraint(variable, constraint,
             "initial guess.\n\tPlease provide a different initial guess.")
         raise
 
-    variable.set_value(x1 - (residual_1-upper))
+    variable.set_value(x1 - (residual_1-upper), valid=True)
     residual_2 = value(body, exception=False)
 
     # If we encounter an error while evaluating the expression at the
@@ -131,13 +137,13 @@ def calculate_variable_from_constraint(variable, constraint,
         slope = float(residual_1 - residual_2) / (x1 - x2)
         intercept = (residual_1-upper) - slope*x1
         if slope:
-            variable.set_value(-intercept/slope)
+            variable.set_value(-intercept/slope, valid=True)
             body_val = value(body, exception=False)
             if body_val is not None and abs(body_val-upper) < eps:
                 return
 
     # Variable appears nonlinearly; solve using Newton's method
-    variable.set_value(orig_initial_value) # restore initial value
+    variable.set_value(orig_initial_value, valid=True) # restore initial value
     expr = body - upper
     expr_deriv = differentiate(expr, wrt=variable,
                                mode=differentiate.Modes.sympy)
@@ -183,7 +189,7 @@ def calculate_variable_from_constraint(variable, constraint,
         pk = -fk/fpk
         alpha = 1.0
         xkp1 = xk + alpha * pk
-        variable.set_value(xkp1)
+        variable.set_value(xkp1, valid=True)
 
         # perform line search
         if linesearch:
@@ -205,7 +211,7 @@ def calculate_variable_from_constraint(variable, constraint,
                     break
                 alpha /= 2.0
                 xkp1 = xk + alpha * pk
-                variable.set_value(xkp1)
+                variable.set_value(xkp1, valid=True)
 
             if alpha <= alpha_min:
                 residual = value(expr, exception=False)


Test patch:
diff --git a/pyomo/util/tests/test_calc_var_value.py b/pyomo/util/tests/test_calc_var_value.py
--- a/pyomo/util/tests/test_calc_var_value.py
+++ b/pyomo/util/tests/test_calc_var_value.py
@@ -14,7 +14,9 @@
 import pyomo.common.unittest as unittest
 
 from pyomo.common.log import LoggingIntercept
-from pyomo.environ import ConcreteModel, Var, Constraint, Param, value, exp
+from pyomo.environ import (
+    ConcreteModel, Var, Constraint, Param, value, exp, NonNegativeReals
+)
 from pyomo.util.calc_var_value import calculate_variable_from_constraint
 from pyomo.core.expr.calculus.diff_with_sympy import differentiate_available
 
@@ -227,3 +229,38 @@ def f(m):
                 "remaining residual = {function evaluation error}"):
             calculate_variable_from_constraint(m.x, m.c, linesearch=True,
                                                alpha_min=.5)
+
+    def test_bound_violation(self):
+        # Test Issue #2176: solving a constraint where the intermediate
+        # value can step outside the bounds
+        m = ConcreteModel()
+        m.v1 = Var(initialize=1, domain=NonNegativeReals)
+        m.c1 = Constraint(expr=m.v1 == 0)
+
+        # Calculate value of v1 using constraint c1
+        calculate_variable_from_constraint(m.v1, m.c1)
+        self.assertEqual(value(m.v1), 0)
+
+        # Calculate value of v1 using a scaled constraint c2
+        m.c2 = Constraint(expr=m.v1*10 == 0)
+        m.v1.set_value(1)
+        calculate_variable_from_constraint(m.v1, m.c2)
+        self.assertEqual(value(m.v1), 0)
+
+        # Test linear solution falling outside bounds
+        m.c3 = Constraint(expr=m.v1*10 == -1)
+        m.v1.set_value(1)
+        calculate_variable_from_constraint(m.v1, m.c3)
+        self.assertEqual(value(m.v1), -0.1)
+
+    @unittest.skipUnless(differentiate_available, "this test requires sympy")
+    def test_nonlinear_bound_violation(self):
+        m = ConcreteModel()
+        m.v1 = Var(initialize=1, domain=NonNegativeReals)
+        m.c1 = Constraint(expr=m.v1 == 0)
+
+        # Test nonlinear solution falling outside bounds
+        m.c4 = Constraint(expr=m.v1**3 == -8)
+        m.v1.set_value(1)
+        calculate_variable_from_constraint(m.v1, m.c4)
+        self.assertEqual(value(m.v1), -2)