Repository: Pyomo/pyomo

Issue:
FBBT fails to compute bounds on NegationExpressions
## Summary

If an expression includes a `NegationExpression`, `compute_bounds_on_expr` returns `(None, None)` in spite of valid bounds existing. (I think this might be related to #846, but I'm not sure, as `NegationExpression` isn't in the list of unsupported expression types?)

### Steps to reproduce the issue

```
from pyomo.environ import *
from pyomo.contrib.fbbt.fbbt import compute_bounds_on_expr
from pyomo.core import summation

m = ConcreteModel()
m.x = Var([0], bounds=(-2,7))

expr_lb, expr_ub = compute_bounds_on_expr(-summation(m.x))
print(expr_lb, expr_ub)
```
Note that `-summation(m.x)` creates a NegationExpression.

### Error Message

The above prints `(None, None)` instead of the expected `(-7, 2)`

### Information on your system

Pyomo version: main branch
Python version: 3.6.5
Operating system: linux
How Pyomo was installed (PyPI, conda, source): source
Solver (if applicable):


Discussion:

Resolving patch:
diff --git a/pyomo/contrib/fbbt/fbbt.py b/pyomo/contrib/fbbt/fbbt.py
--- a/pyomo/contrib/fbbt/fbbt.py
+++ b/pyomo/contrib/fbbt/fbbt.py
@@ -109,6 +109,19 @@ def _prop_bnds_leaf_to_root_SumExpression(node, bnds_dict, feasibility_tol):
     bnds_dict[node] = (lb, ub)
 
 
+def _prop_bnds_leaf_to_root_LinearExpression(node: numeric_expr.LinearExpression, bnds_dict, feasibility_tol):
+    """
+    This is very similar to sum expression
+    """
+    lb, ub = bnds_dict[node.constant]
+    for coef, v in zip(node.linear_coefs, node.linear_vars):
+        coef_bnds = bnds_dict[coef]
+        v_bnds = bnds_dict[v]
+        term_bounds = interval.mul(*coef_bnds, *v_bnds)
+        lb, ub = interval.add(lb, ub, *term_bounds)
+    bnds_dict[node] = (lb, ub)
+
+
 def _prop_bnds_leaf_to_root_DivisionExpression(node, bnds_dict, feasibility_tol):
     """
 
@@ -452,6 +465,7 @@ def _prop_bnds_leaf_to_root_GeneralExpression(node, bnds_dict, feasibility_tol):
 _prop_bnds_leaf_to_root_map[numeric_expr.MonomialTermExpression] = _prop_bnds_leaf_to_root_ProductExpression
 _prop_bnds_leaf_to_root_map[numeric_expr.NegationExpression] = _prop_bnds_leaf_to_root_NegationExpression
 _prop_bnds_leaf_to_root_map[numeric_expr.UnaryFunctionExpression] = _prop_bnds_leaf_to_root_UnaryFunctionExpression
+_prop_bnds_leaf_to_root_map[numeric_expr.LinearExpression] = _prop_bnds_leaf_to_root_LinearExpression
 
 _prop_bnds_leaf_to_root_map[numeric_expr.NPV_ProductExpression] = _prop_bnds_leaf_to_root_ProductExpression
 _prop_bnds_leaf_to_root_map[numeric_expr.NPV_DivisionExpression] = _prop_bnds_leaf_to_root_DivisionExpression
@@ -573,6 +587,49 @@ def _prop_bnds_root_to_leaf_SumExpression(node, bnds_dict, feasibility_tol):
     bnds_dict[node.arg(0)] = (lb, ub)
 
 
+def _prop_bnds_root_to_leaf_LinearExpression(node: numeric_expr.LinearExpression,
+                                             bnds_dict: ComponentMap,
+                                             feasibility_tol: float):
+    """
+    This is very similar to SumExpression.
+    """
+    # first accumulate bounds
+    accumulated_bounds = list()
+    accumulated_bounds.append(bnds_dict[node.constant])
+    lb0, ub0 = bnds_dict[node]
+    for coef, v in zip(node.linear_coefs, node.linear_vars):
+        _lb0, _ub0 = accumulated_bounds[-1]
+        _lb_coef, _ub_coef = bnds_dict[coef]
+        _lb_v, _ub_v = bnds_dict[v]
+        _lb_term, _ub_term = interval.mul(_lb_coef, _ub_coef, _lb_v, _ub_v)
+        accumulated_bounds.append(interval.add(_lb0, _ub0, _lb_term, _ub_term))
+    if lb0 > accumulated_bounds[-1][0]:
+        accumulated_bounds[-1] = (lb0, accumulated_bounds[-1][1])
+    if ub0 < accumulated_bounds[-1][1]:
+        accumulated_bounds[-1] = (accumulated_bounds[-1][0], ub0)
+
+    for i in reversed(range(len(node.linear_coefs))):
+        lb0, ub0 = accumulated_bounds[i + 1]
+        lb1, ub1 = accumulated_bounds[i]
+        coef = node.linear_coefs[i]
+        v = node.linear_vars[i]
+        coef_bnds = bnds_dict[coef]
+        v_bnds = bnds_dict[v]
+        lb2, ub2 = interval.mul(*coef_bnds, *v_bnds)
+        _lb1, _ub1 = interval.sub(lb0, ub0, lb2, ub2)
+        _lb2, _ub2 = interval.sub(lb0, ub0, lb1, ub1)
+        if _lb1 > lb1:
+            lb1 = _lb1
+        if _ub1 < ub1:
+            ub1 = _ub1
+        if _lb2 > lb2:
+            lb2 = _lb2
+        if _ub2 < ub2:
+            ub2 = _ub2
+        accumulated_bounds[i] = (lb1, ub1)
+        bnds_dict[v] = interval.div(lb2, ub2, *coef_bnds, feasibility_tol=feasibility_tol)
+
+
 def _prop_bnds_root_to_leaf_DivisionExpression(node, bnds_dict, feasibility_tol):
     """
 
@@ -1019,6 +1076,7 @@ def _prop_bnds_root_to_leaf_GeneralExpression(node, bnds_dict, feasibility_tol):
 _prop_bnds_root_to_leaf_map[numeric_expr.MonomialTermExpression] = _prop_bnds_root_to_leaf_ProductExpression
 _prop_bnds_root_to_leaf_map[numeric_expr.NegationExpression] = _prop_bnds_root_to_leaf_NegationExpression
 _prop_bnds_root_to_leaf_map[numeric_expr.UnaryFunctionExpression] = _prop_bnds_root_to_leaf_UnaryFunctionExpression
+_prop_bnds_root_to_leaf_map[numeric_expr.LinearExpression] = _prop_bnds_root_to_leaf_LinearExpression
 
 _prop_bnds_root_to_leaf_map[numeric_expr.NPV_ProductExpression] = _prop_bnds_root_to_leaf_ProductExpression
 _prop_bnds_root_to_leaf_map[numeric_expr.NPV_DivisionExpression] = _prop_bnds_root_to_leaf_DivisionExpression
@@ -1084,6 +1142,8 @@ def visiting_potential_leaf(self, node):
             return True, None
 
         if node.is_variable_type():
+            if node in self.bnds_dict:
+                return True, None
             if node.is_fixed():
                 lb = value(node.value)
                 ub = lb
@@ -1099,6 +1159,17 @@ def visiting_potential_leaf(self, node):
             self.bnds_dict[node] = (lb, ub)
             return True, None
 
+        if node.__class__ is numeric_expr.LinearExpression:
+            const_val = value(node.constant)
+            self.bnds_dict[node.constant] = (const_val, const_val)
+            for coef in node.linear_coefs:
+                coef_val = value(coef)
+                self.bnds_dict[coef] = (coef_val, coef_val)
+            for v in node.linear_vars:
+                self.visiting_potential_leaf(v)
+            _prop_bnds_leaf_to_root_LinearExpression(node, self.bnds_dict, self.feasibility_tol)
+            return True, None
+
         if not node.is_expression_type():
             assert is_fixed(node)
             val = value(node)
@@ -1195,6 +1266,12 @@ def visiting_potential_leaf(self, node):
                 node.setub(ub)
             return True, None
 
+        if node.__class__ is numeric_expr.LinearExpression:
+            _prop_bnds_root_to_leaf_LinearExpression(node, self.bnds_dict, self.feasibility_tol)
+            for v in node.linear_vars:
+                self.visiting_potential_leaf(v)
+            return True, None
+
         if not node.is_expression_type():
             lb, ub = self.bnds_dict[node]
             if abs(lb - value(node)) > self.feasibility_tol:


Test patch:
diff --git a/pyomo/contrib/fbbt/tests/test_fbbt.py b/pyomo/contrib/fbbt/tests/test_fbbt.py
--- a/pyomo/contrib/fbbt/tests/test_fbbt.py
+++ b/pyomo/contrib/fbbt/tests/test_fbbt.py
@@ -15,7 +15,8 @@
 from pyomo.common.log import LoggingIntercept
 from pyomo.common.errors import InfeasibleConstraintException
 from pyomo.core.expr.numeric_expr import (ProductExpression,
-                                          UnaryFunctionExpression)
+                                          UnaryFunctionExpression,
+                                          LinearExpression)
 import math
 from io import StringIO
 
@@ -819,3 +820,14 @@ def test_negative_power(self):
         lb, ub = compute_bounds_on_expr(e)
         self.assertAlmostEqual(lb, 0)
         self.assertIsNone(ub)
+
+    def test_linear_expression(self):
+        m = pyo.ConcreteModel()
+        m.x = pyo.Var(bounds=(1, 2))
+        m.y = pyo.Var()
+        m.p = pyo.Param(initialize=3, mutable=True)
+        e = LinearExpression(constant=1, linear_coefs=[1, m.p - 1], linear_vars=[m.x, m.y])
+        m.c = pyo.Constraint(expr=e == 0)
+        fbbt(m.c)
+        self.assertAlmostEqual(m.y.lb, -1.5)
+        self.assertAlmostEqual(m.y.ub, -1)