Repository: Pyomo/pyomo

Issue:
Error in FBBT if non-numeric leaf types encountered: TypeError: unsupported operand type(s) for -: 'str' and 'str'
## Summary

- IDAES recently (IDAES/idaes-pse@bf61436) introduced a new implementation for Helmholtz EOS
- As a consequence of that change (as shown by `git bisect` on the IDAES/idaes-pse repository), one of the models for the DISPATCHES project fails to solve with the exception `TypeError: unsupported operand type(s) for -: 'str' and 'str'` being thrown from `pyomo.contrib.fbbt.fbbt:_FBBTVisitorRootToLeaf.visiting_potential_leaf()`
- The stack trace (and the pytest debug information) seems to point to one of the encountered node being a `str`, possibly as a consequence of external functions being involved in the new IDAES Helmholtz implementation

### Steps to reproduce the issue

```sh
conda create --yes --name test-dispatches-165 python=3.8 && conda activate test-dispatches-165
pip install https://github.com/gmlc-dispatches/dispatches/archive/edba882.zip
pip uninstall idaes-pse --yes && pip install https://github.com/IDAES/idaes-pse/archive/bf61436.zip && idaes get-extensions --verbose
pytest --pyargs dispatches.case_studies.fossil_case.ultra_supercritical_plant.storage.tests.test_discharge_usc_powerplant -k test_main_function -svv  # adding the --pdb flag will open a pdb session at the location where the exception is thrown
```

### Error Message

```
Solved in 1 iterations and 8.34069 seconds
Optimal objective value inf
Relative optimality gap nan%
No feasible solutions found.
FAILED
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> captured log >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
WARNING  pyomo.core:PyomoModel.py:209 Loading a SolverResults object with a warning status into model.name="fs.discharge.es_turbine";
  - termination condition: other
  - message from solver: Too few degrees of freedom (rethrown)!
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> traceback >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>

    @pytest.mark.gdpopt
    @pytest.mark.integration
    def test_main_function():
    
        # Build ultra-supercritical plant base model
        m_usc = usc.build_plant_model()
    
        # Initialize ultra-supercritical plant base model
        usc.initialize(m_usc)
    
        # Build discharge model
        m = discharge_usc.main(m_usc, solver=solver, optarg=optarg)
    
        discharge_usc.model_analysis(m, heat_duty=heat_duty)
    
        # Solve model using GDPopt
>       results = discharge_usc.run_gdp(m)

../../dispatches/dispatches/case_studies/fossil_case/ultra_supercritical_plant/storage/tests/test_discharge_usc_powerplant.py:86: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 
../../dispatches/dispatches/case_studies/fossil_case/ultra_supercritical_plant/storage/discharge_design_ultra_supercritical_power_plant.py:1181: in run_gdp
    results = opt.solve(
../pyomo/pyomo/contrib/gdpopt/GDPopt.py:133: in solve
    return SolverFactory(
../pyomo/pyomo/contrib/gdpopt/algorithm_base_class.py:119: in solve
    self._solve_gdp(model, config)
../pyomo/pyomo/contrib/gdpopt/loa.py:108: in _solve_gdp
    self._fix_discrete_soln_solve_subproblem_and_add_cuts(
../pyomo/pyomo/contrib/gdpopt/oa_algorithm_utils.py:25: in _fix_discrete_soln_solve_subproblem_and_add_cuts
    nlp_termination = solve_subproblem(subprob_util_block, self,
../pyomo/pyomo/contrib/gdpopt/solve_subproblem.py:342: in solve_subproblem
    with preprocess_subproblem(subprob_util_block, config) as call_solver:
../pyomo/pyomo/contrib/gdpopt/solve_subproblem.py:238: in __enter__
    fbbt(m, integer_tol=self.config.integer_tolerance,
../pyomo/pyomo/contrib/fbbt/fbbt.py:1528: in fbbt
    _new_var_bounds = _fbbt_block(comp, config)
../pyomo/pyomo/contrib/fbbt/fbbt.py:1425: in _fbbt_block
    _new_var_bounds = _fbbt_con(c, config)
../pyomo/pyomo/contrib/fbbt/fbbt.py:1354: in _fbbt_con
    visitorB.dfs_postorder_stack(con.body)
../pyomo/pyomo/core/expr/visitor.py:880: in dfs_postorder_stack
    flag, value = self.visiting_potential_leaf(_sub)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <pyomo.contrib.fbbt.fbbt._FBBTVisitorRootToLeaf object at 0x7ff05835ca90>, node = 'H2O'

    def visiting_potential_leaf(self, node):
        if node.__class__ in nonpyomo_leaf_types:
            lb, ub = self.bnds_dict[node]
            _debug_node(node)
>           if abs(lb - value(node)) > self.feasibility_tol:
E           TypeError: unsupported operand type(s) for -: 'str' and 'str'

../pyomo/pyomo/contrib/fbbt/fbbt.py:1204: TypeError
```

### Information on your system

Pyomo version: 6.4.2
Python version: 3.8.15
Operating system: Ubuntu 20.04.5
How Pyomo was installed (PyPI, conda, source): Pyomo installed via pip from GitHub ZIP URL in a Conda environment
Solver (if applicable): GDPopt (?)

### Additional information

See gmlc-dispatches/dispatches#165 for the downstream bug report.




Discussion:
Adding to the discussion - the issue is related to an `ExternalFunction` in the model with a string argument, and the fact that the FBBT tools does not know what to do with it.

This probably ends up being a more general issue of "what should the FBBT tool do when it encounters an ExternalFunction"? My initial preferred response would be to return infinite bounds, as this means the tool would at least keep working and tighten the other bounds in the model, whereas an Exception would preclude the FBBT tool from being used on any model which contains an ExternalFunction.

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
@@ -459,14 +459,6 @@ def _prop_bnds_leaf_to_root_GeneralExpression(node, bnds_dict, feasibility_tol):
 _prop_bnds_leaf_to_root_map[numeric_expr.LinearExpression] = _prop_bnds_leaf_to_root_LinearExpression
 _prop_bnds_leaf_to_root_map[numeric_expr.AbsExpression] = _prop_bnds_leaf_to_root_abs
 
-_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
-_prop_bnds_leaf_to_root_map[numeric_expr.NPV_PowExpression] = _prop_bnds_leaf_to_root_PowExpression
-_prop_bnds_leaf_to_root_map[numeric_expr.NPV_SumExpression] = _prop_bnds_leaf_to_root_SumExpression
-_prop_bnds_leaf_to_root_map[numeric_expr.NPV_NegationExpression] = _prop_bnds_leaf_to_root_NegationExpression
-_prop_bnds_leaf_to_root_map[numeric_expr.NPV_UnaryFunctionExpression] = _prop_bnds_leaf_to_root_UnaryFunctionExpression
-_prop_bnds_leaf_to_root_map[numeric_expr.NPV_AbsExpression] = _prop_bnds_leaf_to_root_abs
-
 _prop_bnds_leaf_to_root_map[_GeneralExpressionData] = _prop_bnds_leaf_to_root_GeneralExpression
 _prop_bnds_leaf_to_root_map[ScalarExpression] = _prop_bnds_leaf_to_root_GeneralExpression
 
@@ -1062,14 +1054,6 @@ def _prop_bnds_root_to_leaf_GeneralExpression(node, bnds_dict, feasibility_tol):
 _prop_bnds_root_to_leaf_map[numeric_expr.LinearExpression] = _prop_bnds_root_to_leaf_LinearExpression
 _prop_bnds_root_to_leaf_map[numeric_expr.AbsExpression] = _prop_bnds_root_to_leaf_abs
 
-_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
-_prop_bnds_root_to_leaf_map[numeric_expr.NPV_PowExpression] = _prop_bnds_root_to_leaf_PowExpression
-_prop_bnds_root_to_leaf_map[numeric_expr.NPV_SumExpression] = _prop_bnds_root_to_leaf_SumExpression
-_prop_bnds_root_to_leaf_map[numeric_expr.NPV_NegationExpression] = _prop_bnds_root_to_leaf_NegationExpression
-_prop_bnds_root_to_leaf_map[numeric_expr.NPV_UnaryFunctionExpression] = _prop_bnds_root_to_leaf_UnaryFunctionExpression
-_prop_bnds_root_to_leaf_map[numeric_expr.NPV_AbsExpression] = _prop_bnds_root_to_leaf_abs
-
 _prop_bnds_root_to_leaf_map[_GeneralExpressionData] = _prop_bnds_root_to_leaf_GeneralExpression
 _prop_bnds_root_to_leaf_map[ScalarExpression] = _prop_bnds_root_to_leaf_GeneralExpression
 
@@ -1144,6 +1128,13 @@ def visiting_potential_leaf(self, node):
             self.bnds_dict[node] = (lb, ub)
             return True, None
 
+        if not node.is_potentially_variable():
+            # NPV nodes are effectively constant leaves.  Evaluate it
+            # and return the value.
+            val = value(node)
+            self.bnds_dict[node] = (val, val)
+            return True, None
+
         if node.__class__ is numeric_expr.LinearExpression:
             const_val = value(node.constant)
             self.bnds_dict[node.constant] = (const_val, const_val)
@@ -1155,10 +1146,11 @@ def visiting_potential_leaf(self, node):
             _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)
-            self.bnds_dict[node] = (val, val)
+        if node.__class__ is numeric_expr.ExternalFunctionExpression:
+            # TODO: provide some mechanism for users to provide interval
+            # arithmetic callback functions for general external
+            # functions
+            self.bnds_dict[node] = (-interval.inf, interval.inf)
             return True, None
 
         return False, None
@@ -1251,18 +1243,21 @@ def visiting_potential_leaf(self, node):
                 node.setub(ub)
             return True, None
 
+        if not node.is_potentially_variable():
+            lb, ub = self.bnds_dict[node]
+            if abs(lb - value(node)) > self.feasibility_tol:
+                raise InfeasibleConstraintException('Detected an infeasible constraint.')
+            if abs(ub - value(node)) > self.feasibility_tol:
+                raise InfeasibleConstraintException('Detected an infeasible constraint.')
+            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:
-                raise InfeasibleConstraintException('Detected an infeasible constraint.')
-            if abs(ub - value(node)) > self.feasibility_tol:
-                raise InfeasibleConstraintException('Detected an infeasible constraint.')
+        if node.__class__ is numeric_expr.ExternalFunctionExpression:
             return True, None
 
         if node.__class__ in _prop_bnds_root_to_leaf_map:


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
@@ -13,14 +13,19 @@
 import pyomo.environ as pyo
 from pyomo.contrib.fbbt.fbbt import fbbt, compute_bounds_on_expr
 from pyomo.common.dependencies import numpy as np, numpy_available
+from pyomo.common.fileutils import find_library
 from pyomo.common.log import LoggingIntercept
 from pyomo.common.errors import InfeasibleConstraintException
 from pyomo.core.expr.numeric_expr import (ProductExpression,
                                           UnaryFunctionExpression,
                                           LinearExpression)
 import math
+import platform
 from io import StringIO
 
+flib = find_library("asl_external_demo")
+is_pypy = platform.python_implementation().lower().startswith('pypy')
+
 
 class DummyExpr(ProductExpression):
     pass
@@ -1045,6 +1050,37 @@ def test_fixed_var(self):
         self.assertAlmostEqual(m.y.lb, 1)
         self.assertAlmostEqual(m.y.ub, 2)
 
+    @unittest.skipUnless(
+        flib, 'Could not find the "asl_external_demo.so" library')
+    @unittest.skipIf(is_pypy, 'Cannot evaluate external functions under pypy')
+    def test_external_function(self):
+        if self.tightener is not fbbt:
+            raise unittest.SkipTest('Appsi FBBT does not support unkown expressions yet')
+
+        m = pyo.ConcreteModel()
+        m.x = pyo.Var(bounds=(0,1))
+        m.y = pyo.Var(bounds=(0,5))
+        m.p = pyo.Param(initialize=1)
+        m.q = pyo.Param(initialize=3)
+        m.ef = pyo.ExternalFunction(library=flib, function="demo_function")
+
+        m.con1 = pyo.Constraint(expr=m.ef("sum", m.x, m.y) >= 1)
+
+        # No change due to variable EF
+        self.tightener(m)
+        self.assertAlmostEqual(m.x.lb, 0)
+        self.assertAlmostEqual(m.x.ub, 1)
+        self.assertAlmostEqual(m.y.lb, 0)
+        self.assertAlmostEqual(m.y.ub, 5)
+
+        m.con2 = pyo.Constraint(expr=m.ef("sum", m.p, m.q) - m.y >= 1)
+
+        self.tightener(m)
+        self.assertAlmostEqual(m.x.lb, 0)
+        self.assertAlmostEqual(m.x.ub, 1)
+        self.assertAlmostEqual(m.y.lb, 0)
+        self.assertAlmostEqual(m.y.ub, 3)
+
 
 class TestFBBT(FbbtTestBase, unittest.TestCase):
     def setUp(self) -> None: