Repository: Pyomo/pyomo

Issue:
Expression should validate rule result is an expression
## Summary

As reported on the [pyomo-forum](https://groups.google.com/g/pyomo-forum/c/x3IEN3ybp9M/m/Wc3msdBTAgAJ), `Expression` components do not validate that the result from the rule is actually a valid numeric expression.  In particular, the following does not produce an error (but should):

### Steps to reproduce the issue

```python
model = ConcreteModel()
model.TIME = RangeSet(2)
model.x = Var(model.TIME)
@model.Expression(model.TIME)
def exp1(m, t):
    return m.x[t]

@model.Expression(model.TIME)
def exp2(m, t):
    return m.exp1

model.pprint()
```

```
1 RangeSet Declarations
    TIME : Dimen=1, Size=2, Bounds=(1, 2)
        Key  : Finite : Members
        None :   True :   [1:2]

1 Var Declarations
    x : Size=2, Index=TIME
        Key : Lower : Value : Upper : Fixed : Stale : Domain
          1 :  None :  None :  None : False :  True :  Reals
          2 :  None :  None :  None : False :  True :  Reals

2 Expression Declarations
    exp1 : Size=2, Index=TIME
        Key : Expression
          1 :       x[1]
          2 :       x[2]
    exp2 : Size=2, Index=TIME
        Key : Expression
          1 :       exp1
          2 :       exp1

4 Declarations: TIME x exp1 exp2
```


Discussion:

Resolving patch:
diff --git a/pyomo/core/expr/numvalue.py b/pyomo/core/expr/numvalue.py
--- a/pyomo/core/expr/numvalue.py
+++ b/pyomo/core/expr/numvalue.py
@@ -117,7 +117,7 @@ def value(obj, exception=True):
     # Test if we have a duck types for Pyomo expressions
     #
     try:
-        obj.is_expression_type()
+        obj.is_numeric_type()
     except AttributeError:
         #
         # If not, then try to coerce this into a numeric constant.  If that
@@ -137,7 +137,6 @@ def value(obj, exception=True):
         #
         # Here, we try to catch the exception
         #
-
         try:
             tmp = obj(exception=True)
             if tmp is None:
@@ -257,7 +256,7 @@ def is_numeric_data(obj):
         # this likely means it is a string
         return False
     try:
-        # Test if this is an expression object that 
+        # Test if this is an expression object that
         # is not potentially variable
         return not obj.is_potentially_variable()
     except AttributeError:
@@ -330,7 +329,7 @@ def as_numeric(obj):
     Args:
         obj: The numeric value that may be wrapped.
 
-    Raises: TypeError if the object is in native_types and not in 
+    Raises: TypeError if the object is in native_types and not in
         native_numeric_types
 
     Returns: A NumericConstant object or the original object.
@@ -338,7 +337,7 @@ def as_numeric(obj):
     if obj.__class__ in native_numeric_types:
         val = _KnownConstants.get(obj, None)
         if val is not None:
-            return val 
+            return val
         #
         # Coerce the value to a float, if possible
         #
@@ -367,11 +366,20 @@ def as_numeric(obj):
         #
         return retval
     #
-    # Ignore objects that are duck types to work with Pyomo expressions
+    # Ignore objects that are duck typed to work with Pyomo expressions
     #
     try:
-        obj.is_expression_type()
-        return obj
+        if obj.is_numeric_type():
+            return obj
+        else:
+            try:
+                _name = obj.name
+            except AttributeError:
+                _name = str(obj)
+            raise TypeError(
+                "The '%s' object '%s' is not a valid type for Pyomo "
+                "numeric expressions" % (type(obj).__name__, _name))
+
     except AttributeError:
         pass
     #
@@ -386,10 +394,11 @@ def as_numeric(obj):
     # Generate errors
     #
     if obj.__class__ in native_types:
-        raise TypeError("Cannot treat the value '%s' as a constant" % str(obj))
+        raise TypeError("%s values ('%s') are not allowed in Pyomo "
+                        "numeric expressions" % (type(obj).__name__, str(obj)))
     raise TypeError(
-        "Cannot treat the value '%s' as a constant because it has unknown "
-        "type '%s'" % (str(obj), type(obj).__name__))
+        "Cannot treat the value '%s' as a numeric value because it has "
+        "unknown type '%s'" % (str(obj), type(obj).__name__))
 
 
 def check_if_numeric_type_and_cache(obj):
@@ -415,7 +424,7 @@ def check_if_numeric_type_and_cache(obj):
         retval = NumericConstant(obj)
         try:
             #
-            # Create the numeric constant and add to the 
+            # Create the numeric constant and add to the
             # list of known constants.
             #
             # Note: we don't worry about the size of the
@@ -647,7 +656,7 @@ def __lt__(self,other):
         Less than operator
 
         This method is called when Python processes statements of the form::
-        
+
             self < other
             other > self
         """
@@ -658,7 +667,7 @@ def __gt__(self,other):
         Greater than operator
 
         This method is called when Python processes statements of the form::
-        
+
             self > other
             other < self
         """
@@ -669,7 +678,7 @@ def __le__(self,other):
         Less than or equal operator
 
         This method is called when Python processes statements of the form::
-        
+
             self <= other
             other >= self
         """
@@ -680,7 +689,7 @@ def __ge__(self,other):
         Greater than or equal operator
 
         This method is called when Python processes statements of the form::
-        
+
             self >= other
             other <= self
         """
@@ -691,7 +700,7 @@ def __eq__(self,other):
         Equal to operator
 
         This method is called when Python processes the statement::
-        
+
             self == other
         """
         return _generate_relational_expression(_eq, self, other)
@@ -701,7 +710,7 @@ def __add__(self,other):
         Binary addition
 
         This method is called when Python processes the statement::
-        
+
             self + other
         """
         return _generate_sum_expression(_add,self,other)
@@ -711,7 +720,7 @@ def __sub__(self,other):
         Binary subtraction
 
         This method is called when Python processes the statement::
-        
+
             self - other
         """
         return _generate_sum_expression(_sub,self,other)
@@ -721,7 +730,7 @@ def __mul__(self,other):
         Binary multiplication
 
         This method is called when Python processes the statement::
-        
+
             self * other
         """
         return _generate_mul_expression(_mul,self,other)
@@ -731,7 +740,7 @@ def __div__(self,other):
         Binary division
 
         This method is called when Python processes the statement::
-        
+
             self / other
         """
         return _generate_mul_expression(_div,self,other)
@@ -741,7 +750,7 @@ def __truediv__(self,other):
         Binary division (when __future__.division is in effect)
 
         This method is called when Python processes the statement::
-        
+
             self / other
         """
         return _generate_mul_expression(_div,self,other)
@@ -751,7 +760,7 @@ def __pow__(self,other):
         Binary power
 
         This method is called when Python processes the statement::
-        
+
             self ** other
         """
         return _generate_other_expression(_pow,self,other)
@@ -761,7 +770,7 @@ def __radd__(self,other):
         Binary addition
 
         This method is called when Python processes the statement::
-        
+
             other + self
         """
         return _generate_sum_expression(_radd,self,other)
@@ -771,7 +780,7 @@ def __rsub__(self,other):
         Binary subtraction
 
         This method is called when Python processes the statement::
-        
+
             other - self
         """
         return _generate_sum_expression(_rsub,self,other)
@@ -781,7 +790,7 @@ def __rmul__(self,other):
         Binary multiplication
 
         This method is called when Python processes the statement::
-        
+
             other * self
 
         when other is not a :class:`NumericValue <pyomo.core.expr.numvalue.NumericValue>` object.
@@ -792,7 +801,7 @@ def __rdiv__(self,other):
         """Binary division
 
         This method is called when Python processes the statement::
-        
+
             other / self
         """
         return _generate_mul_expression(_rdiv,self,other)
@@ -802,7 +811,7 @@ def __rtruediv__(self,other):
         Binary division (when __future__.division is in effect)
 
         This method is called when Python processes the statement::
-        
+
             other / self
         """
         return _generate_mul_expression(_rdiv,self,other)
@@ -812,7 +821,7 @@ def __rpow__(self,other):
         Binary power
 
         This method is called when Python processes the statement::
-        
+
             other ** self
         """
         return _generate_other_expression(_rpow,self,other)
@@ -822,7 +831,7 @@ def __iadd__(self,other):
         Binary addition
 
         This method is called when Python processes the statement::
-        
+
             self += other
         """
         return _generate_sum_expression(_iadd,self,other)
@@ -852,7 +861,7 @@ def __idiv__(self,other):
         Binary division
 
         This method is called when Python processes the statement::
-        
+
             self /= other
         """
         return _generate_mul_expression(_idiv,self,other)
@@ -862,7 +871,7 @@ def __itruediv__(self,other):
         Binary division (when __future__.division is in effect)
 
         This method is called when Python processes the statement::
-        
+
             self /= other
         """
         return _generate_mul_expression(_idiv,self,other)
@@ -872,7 +881,7 @@ def __ipow__(self,other):
         Binary power
 
         This method is called when Python processes the statement::
-        
+
             self **= other
         """
         return _generate_other_expression(_ipow,self,other)
@@ -882,7 +891,7 @@ def __neg__(self):
         Negation
 
         This method is called when Python processes the statement::
-        
+
             - self
         """
         return _generate_sum_expression(_neg, self, None)
@@ -892,7 +901,7 @@ def __pos__(self):
         Positive expression
 
         This method is called when Python processes the statement::
-        
+
             + self
         """
         return self
@@ -901,7 +910,7 @@ def __abs__(self):
         """ Absolute value
 
         This method is called when Python processes the statement::
-        
+
             abs(self)
         """
         return _generate_other_expression(_abs,self, None)
@@ -912,25 +921,32 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
 
     def to_string(self, verbose=None, labeler=None, smap=None,
                   compute_values=False):
-        """
-        Return a string representation of the expression tree.
+        """Return a string representation of the expression tree.
 
         Args:
-            verbose (bool): If :const:`True`, then the the string 
+            verbose (bool): If :const:`True`, then the string
                 representation consists of nested functions.  Otherwise,
-                the string representation is an algebraic equation.
+                the string representation is an infix algebraic equation.
                 Defaults to :const:`False`.
-            labeler: An object that generates string labels for 
-                variables in the expression tree.  Defaults to :const:`None`.
+            labeler: An object that generates string labels for
+                non-constant in the expression tree.  Defaults to
+                :const:`None`.
+            smap: A SymbolMap instance that stores string labels for
+                non-constant nodes in the expression tree.  Defaults to
+                :const:`None`.
+            compute_values (bool): If :const:`True`, then fixed
+                expressions are evaluated and the string representation
+                of the resulting value is returned.
 
         Returns:
             A string representation for the expression tree.
+
         """
         if compute_values and self.is_fixed():
             try:
                 return str(self())
             except:
-                pass        
+                pass
         if not self.is_constant():
             if smap is not None:
                 return smap.getSymbol(self, labeler)


Test patch:
diff --git a/pyomo/core/tests/unit/test_numvalue.py b/pyomo/core/tests/unit/test_numvalue.py
--- a/pyomo/core/tests/unit/test_numvalue.py
+++ b/pyomo/core/tests/unit/test_numvalue.py
@@ -12,19 +12,21 @@
 # Unit Tests for Python numeric values
 #
 
-import os
-from os.path import abspath, dirname
-currdir = dirname(abspath(__file__))+os.sep
-
 from math import nan, inf
 import pyomo.common.unittest as unittest
 
-from pyomo.environ import (value, ConcreteModel, Param, Var, 
-                           polynomial_degree, is_constant, is_fixed,
-                           is_potentially_variable, is_variable_type)
-from pyomo.core.expr.numvalue import (NumericConstant,
-                                      as_numeric,
-                                      is_numeric_data)
+from pyomo.environ import (
+    value, ConcreteModel, Param, Var,
+    polynomial_degree, is_constant, is_fixed,
+    is_potentially_variable, is_variable_type
+)
+
+from pyomo.core.pyomoobject import PyomoObject
+from pyomo.core.expr.numvalue import (
+    NumericConstant, as_numeric, is_numeric_data,
+    native_types, native_numeric_types, native_integer_types,
+    native_boolean_types,
+)
 
 try:
     import numpy
@@ -41,74 +43,6 @@ def __add__(self, other):
         return MyBogusNumericType(self.val + float(other))
 
 
-class Test_value(unittest.TestCase):
-
-    def test_none(self):
-        val = None
-        try:
-            value(val)
-            self.fail("Expected ValueError")
-        except ValueError:
-            pass
-
-    def test_bool(self):
-        val = False
-        self.assertEqual(val, value(val))
-        val = True
-        self.assertEqual(val, value(val))
-
-    def test_float(self):
-        val = 1.1
-        self.assertEqual(val, value(val))
-
-    def test_int(self):
-        val = 1
-        self.assertEqual(val, value(val))
-
-    def test_long(self):
-        val = int(1e10)
-        self.assertEqual(val, value(val))
-
-    def test_string(self):
-        val = 'foo'
-        try:
-            value(val)
-            self.fail("Expected ValueError")
-        except ValueError:
-            pass
-
-    def test_const1(self):
-        val = NumericConstant(1.0)
-        self.assertEqual(1.0, value(val))
-
-    def test_error1(self):
-        class A(object): pass
-        val = A()
-        try:
-            value(val)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
-
-    def test_unknownType(self):
-        ref = MyBogusType(42)
-        try:
-            val = value(ref)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
-
-    def test_unknownNumericType(self):
-        ref = MyBogusNumericType(42)
-        val = value(ref)
-        self.assertEqual(val().val, 42)
-        from pyomo.core.base.numvalue import native_numeric_types, native_types
-        self.assertIn(MyBogusNumericType, native_numeric_types)
-        self.assertIn(MyBogusNumericType, native_types)
-        native_numeric_types.remove(MyBogusNumericType)
-        native_types.remove(MyBogusNumericType)
-
-
 class Test_is_numeric_data(unittest.TestCase):
 
     def test_string(self):
@@ -132,7 +66,6 @@ class A(object): pass
     def test_unknownNumericType(self):
         ref = MyBogusNumericType(42)
         self.assertTrue(is_numeric_data(ref))
-        from pyomo.core.base.numvalue import native_numeric_types, native_types
         self.assertIn(MyBogusNumericType, native_numeric_types)
         self.assertIn(MyBogusNumericType, native_types)
         native_numeric_types.remove(MyBogusNumericType)
@@ -213,18 +146,22 @@ def test_var2(self):
     def test_error1(self):
         class A(object): pass
         val = A()
-        try:
+        with self.assertRaisesRegex(
+                TypeError, "Cannot evaluate object with unknown type: A"):
             value(val)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
+
+    def test_unknownType(self):
+        ref = MyBogusType(42)
+        with self.assertRaisesRegex(
+                TypeError,
+                "Cannot evaluate object with unknown type: MyBogusType"):
+            value(ref)
 
     def test_unknownNumericType(self):
         ref = MyBogusNumericType(42)
         val = value(ref)
         self.assertEqual(val.val, 42.0)
         #self.assertEqual(val().val, 42)
-        from pyomo.core.base.numvalue import native_numeric_types, native_types
         self.assertIn(MyBogusNumericType, native_numeric_types)
         self.assertIn(MyBogusNumericType, native_types)
         native_numeric_types.remove(MyBogusNumericType)
@@ -295,18 +232,16 @@ def test_var1(self):
     def test_error1(self):
         class A(object): pass
         val = A()
-        try:
+        with self.assertRaisesRegex(
+                TypeError, "Cannot assess properties of object "
+                "with unknown type: A"):
             polynomial_degree(val)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
 
     def test_unknownNumericType(self):
         ref = MyBogusNumericType(42)
         val = polynomial_degree(ref)
         self.assertEqual(val, 0)
         #self.assertEqual(val().val, 42)
-        from pyomo.core.base.numvalue import native_numeric_types, native_types
         self.assertIn(MyBogusNumericType, native_numeric_types)
         self.assertIn(MyBogusNumericType, native_types)
         native_numeric_types.remove(MyBogusNumericType)
@@ -345,16 +280,14 @@ def test_const2(self):
     def test_error(self):
         class A(object): pass
         val = A()
-        try:
+        with self.assertRaisesRegex(
+                TypeError, "Cannot assess properties of object "
+                "with unknown type: A"):
             is_constant(val)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
 
     def test_unknownNumericType(self):
         ref = MyBogusNumericType(42)
         self.assertTrue(is_constant(ref))
-        from pyomo.core.base.numvalue import native_numeric_types, native_types
         self.assertIn(MyBogusNumericType, native_numeric_types)
         self.assertIn(MyBogusNumericType, native_types)
         native_numeric_types.remove(MyBogusNumericType)
@@ -389,16 +322,14 @@ def test_const1(self):
     def test_error(self):
         class A(object): pass
         val = A()
-        try:
+        with self.assertRaisesRegex(
+                TypeError, "Cannot assess properties of object "
+                "with unknown type: A"):
             is_fixed(val)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
 
     def test_unknownNumericType(self):
         ref = MyBogusNumericType(42)
         self.assertTrue(is_fixed(ref))
-        from pyomo.core.base.numvalue import native_numeric_types, native_types
         self.assertIn(MyBogusNumericType, native_numeric_types)
         self.assertIn(MyBogusNumericType, native_types)
         native_numeric_types.remove(MyBogusNumericType)
@@ -479,25 +410,14 @@ class Test_as_numeric(unittest.TestCase):
 
     def test_none(self):
         val = None
-        try:
+        with self.assertRaisesRegex(
+                TypeError, r"NoneType values \('None'\) are not allowed "
+                "in Pyomo numeric expressions"):
             as_numeric(val)
-            self.fail("Expected ValueError")
-        except:
-            pass
 
     def test_bool(self):
-        val = False
-        try:
-            as_numeric(val)
-            self.fail("Expected ValueError")
-        except:
-            pass
-        val = True
-        try:
-            as_numeric(val)
-            self.fail("Expected ValueError")
-        except:
-            pass
+        self.assertEqual(as_numeric(False), 0)
+        self.assertEqual(as_numeric(True), 1)
 
     def test_float(self):
         val = 1.1
@@ -521,11 +441,10 @@ def test_long(self):
 
     def test_string(self):
         val = 'foo'
-        try:
+        with self.assertRaisesRegex(
+                TypeError, r"str values \('foo'\) are not allowed "
+                "in Pyomo numeric expressions"):
             as_numeric(val)
-            self.fail("Expected ValueError")
-        except:
-            pass
 
     def test_const1(self):
         val = NumericConstant(1.0)
@@ -534,35 +453,46 @@ def test_const1(self):
     def test_error1(self):
         class A(object): pass
         val = A()
-        try:
+        with self.assertRaisesRegex(
+                TypeError, r"Cannot treat the value '.*' as a "
+                "numeric value because it has unknown type 'A'"):
             as_numeric(val)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
 
     def test_unknownType(self):
         ref = MyBogusType(42)
-        try:
-            val = as_numeric(ref)
-            self.fail("Expected TypeError")
-        except TypeError:
-            pass
+        with self.assertRaisesRegex(
+                TypeError, r"Cannot treat the value '.*' as a "
+                "numeric value because it has unknown type 'MyBogusType'"):
+            as_numeric(ref)
+
+    def test_non_numeric_component(self):
+        m = ConcreteModel()
+        m.v = Var([1,2])
+        with self.assertRaisesRegex(
+                TypeError, "The 'IndexedVar' object 'v' is not a valid "
+                "type for Pyomo numeric expressions"):
+            as_numeric(m.v)
+
+        obj = PyomoObject()
+        with self.assertRaisesRegex(
+                TypeError, "The 'PyomoObject' object '.*' is not a valid "
+                "type for Pyomo numeric expressions"):
+            as_numeric(obj)
 
     def test_unknownNumericType(self):
         ref = MyBogusNumericType(42)
-        val = as_numeric(ref)
-        self.assertEqual(val().val, 42.0)
-        #self.assertEqual(val().val, 42)
-        from pyomo.core.base.numvalue import native_numeric_types, native_types
-        self.assertIn(MyBogusNumericType, native_numeric_types)
-        self.assertIn(MyBogusNumericType, native_types)
-        native_numeric_types.remove(MyBogusNumericType)
-        native_types.remove(MyBogusNumericType)
+        self.assertNotIn(MyBogusNumericType, native_numeric_types)
+        self.assertNotIn(MyBogusNumericType, native_types)
+        try:
+            val = as_numeric(ref)
+            self.assertEqual(val().val, 42.0)
+        finally:
+            native_numeric_types.remove(MyBogusNumericType)
+            native_types.remove(MyBogusNumericType)
 
     def test_numpy_basic_float_registration(self):
         if not numpy_available:
             self.skipTest("This test requires NumPy")
-        from pyomo.core.base.numvalue import native_numeric_types, native_integer_types, native_boolean_types, native_types
         self.assertIn(numpy.float_, native_numeric_types)
         self.assertNotIn(numpy.float_, native_integer_types)
         self.assertIn(numpy.float_, native_boolean_types)
@@ -571,7 +501,6 @@ def test_numpy_basic_float_registration(self):
     def test_numpy_basic_int_registration(self):
         if not numpy_available:
             self.skipTest("This test requires NumPy")
-        from pyomo.core.base.numvalue import native_numeric_types, native_integer_types, native_boolean_types, native_types
         self.assertIn(numpy.int_, native_numeric_types)
         self.assertIn(numpy.int_, native_integer_types)
         self.assertIn(numpy.int_, native_boolean_types)
@@ -580,7 +509,6 @@ def test_numpy_basic_int_registration(self):
     def test_numpy_basic_bool_registration(self):
         if not numpy_available:
             self.skipTest("This test requires NumPy")
-        from pyomo.core.base.numvalue import native_numeric_types, native_integer_types, native_boolean_types, native_types
         self.assertNotIn(numpy.bool_, native_numeric_types)
         self.assertNotIn(numpy.bool_, native_integer_types)
         self.assertIn(numpy.bool_, native_boolean_types)