Repository: Pyomo/pyomo

Issue:
Request for an `identify_inconsistent_units` method
## Summary

The Pyomo unit consistency checks current have an `assert_units_consistent` function which will iterate over the constraints in a model and raise a `UnitsError` if a constraint is encountered with inconsistent units. Whilst this is useful for testing, in most cases a model developer would like to get a list of all constraints with unit consistency issues, not just an exception for the first issue encountered.

Additionally, the `assert_units_consistent` function has some `print` statements embedded in it which clutter the output somewhat which could probably be removed (the proposed `identify_inconsistent_units` method would provide a means for getting the full list of problematic constraints in its place).

### Rationale

I am writing a tool as part of https://github.com/IDAES/idaes-pse/issues/1208 which aims to provide users with a summary of all (or at least most) the issues in their model, one of which is unit consistency. As such I would like to generate the list of problematic constraints for the user to reference and to do so without any extraneous text output.

### Description

For the time being, I am using:

```
constraints_with_inconsistent_units = ComponentSet()
for c in self.model.component_data_objects(Constraint, descend_into=True):
    try:
        assert_units_consistent(c)
    except UnitsError:
        constraints_with_inconsistent_units.add(c)
return constraints_with_inconsistent_units
```

This generates the desired list, but produces a line of text for each failing constraint; e.g. `Error in units when checking b.c4`.


Discussion:

Resolving patch:
diff --git a/pyomo/util/check_units.py b/pyomo/util/check_units.py
--- a/pyomo/util/check_units.py
+++ b/pyomo/util/check_units.py
@@ -14,6 +14,8 @@
 This module has some helpful methods to support checking units on Pyomo
 module objects.
 """
+import logging
+
 from pyomo.core.base.units_container import units, UnitsError
 from pyomo.core.base import (
     Objective,
@@ -39,6 +41,9 @@
 from pyomo.core.expr.template_expr import IndexTemplate
 from pyomo.core.expr.numvalue import native_types
 from pyomo.util.components import iter_component
+from pyomo.common.collections import ComponentSet
+
+logger = logging.getLogger(__name__)
 
 
 def check_units_equivalent(*args):
@@ -243,7 +248,7 @@ def assert_units_consistent(obj):
         try:
             _assert_units_consistent_expression(obj)
         except UnitsError:
-            print('Units problem with expression {}'.format(obj))
+            logger.error('Units problem with expression {}'.format(obj))
             raise
         return
 
@@ -264,11 +269,37 @@ def assert_units_consistent(obj):
             try:
                 handler(cdata)
             except UnitsError:
-                print('Error in units when checking {}'.format(cdata))
+                logger.error('Error in units when checking {}'.format(cdata))
                 raise
     else:
+        handler(obj)
+
+
+def identify_inconsistent_units(block):
+    """
+    This function generates a ComponentSet of all Constraints, Expressions, and Objectives
+    in a Block or model which have inconsistent units.
+
+    Parameters
+    ----------
+    block : Pyomo Block or Model to test
+
+    Returns
+    ------
+    ComponentSet : contains all Constraints, Expressions or Objectives which were
+        identified as having unit consistency issues
+    """
+    # It would be nice (and more efficient) if there were a method that would check
+    # unit consistency and return a bool for success or failure.
+    # However, the underlying methods (at least as deep as I looked) all raise exceptions
+    # so we need to iterate over the block here and do a try/except for each component
+
+    inconsistent_units = ComponentSet()
+    for obj in block.component_data_objects(
+        [Constraint, Expression, Objective], descend_into=True
+    ):
         try:
-            handler(obj)
+            assert_units_consistent(obj)
         except UnitsError:
-            print('Error in units when checking {}'.format(obj))
-            raise
+            inconsistent_units.add(obj)
+    return inconsistent_units


Test patch:
diff --git a/pyomo/util/tests/test_check_units.py b/pyomo/util/tests/test_check_units.py
--- a/pyomo/util/tests/test_check_units.py
+++ b/pyomo/util/tests/test_check_units.py
@@ -38,6 +38,7 @@
     assert_units_consistent,
     assert_units_equivalent,
     check_units_equivalent,
+    identify_inconsistent_units,
 )
 
 
@@ -45,7 +46,7 @@ def python_callback_function(arg1, arg2):
     return 42.0
 
 
-@unittest.skipIf(not pint_available, 'Testing units requires pint')
+@unittest.skipIf(not pint_available, "Testing units requires pint")
 class TestUnitsChecking(unittest.TestCase):
     def _create_model_and_vars(self):
         u = units
@@ -222,10 +223,10 @@ def test_assert_units_consistent_all_components(self):
         m.dvar = DerivativeVar(sVar=m.svar, units=u.m / u.s)
 
         def prt1_rule(m):
-            return {'avar': m.dx}
+            return {"avar": m.dx}
 
         def prt2_rule(m):
-            return {'avar': m.dy}
+            return {"avar": m.dy}
 
         m.prt1 = Port(rule=prt1_rule)
         m.prt2 = Port(rule=prt2_rule)
@@ -265,6 +266,56 @@ def test_units_roundoff_error(self):
         m.var_2 = Var(initialize=400, units=units.kg / units.s**3 / units.K)
         assert_units_equivalent(m.var_1, m.var_2)
 
+    def test_identify_inconsistent_units(self):
+        u = units
+        m = ConcreteModel()
+        m.S = Set(initialize=[1, 2, 3])
+        m.x = Var(units=u.m)
+        m.t = Var(units=u.s)
+
+        # Scalar constraints
+        m.c1 = Constraint(expr=m.x == 10 * u.m)  # Units consistent
+        m.c2 = Constraint(expr=m.x == m.t)  # Units inconsistent
+
+        # Indexed Constraint
+        @m.Constraint(m.S)
+        def c3(blk, i):
+            if i == 1:
+                return m.t == 10 * u.m  # Inconsistent units
+            return m.t == 10 * u.s  # Consistent units
+
+        # Scalar Expressions
+        m.e1 = Expression(expr=m.x + 10 * u.m)  # Units consistent
+        m.e2 = Expression(expr=m.x + m.t)  # Units inconsistent
+
+        # Indexed Expression
+        @m.Expression(m.S)
+        def e3(blk, i):
+            if i == 1:
+                return m.t + 10 * u.m  # Inconsistent units
+            return m.t + 10 * u.s  # Consistent units
+
+        # Scalar Objectives
+        m.o1 = Objective(expr=m.x + 10 * u.m)  # Units consistent
+        m.o2 = Objective(expr=m.x + m.t)  # Units inconsistent
+
+        # Indexed Objective
+        @m.Objective(m.S)
+        def o3(blk, i):
+            if i == 1:
+                return m.t + 10 * u.m  # Inconsistent units
+            return m.t + 10 * u.s  # Consistent units
+
+        failures = identify_inconsistent_units(m)
+
+        assert len(failures) == 6
+        assert m.c2 in failures
+        assert m.c3[1] in failures
+        assert m.e2 in failures
+        assert m.e3[1] in failures
+        assert m.o2 in failures
+        assert m.o3[1] in failures
+
 
 if __name__ == "__main__":
     unittest.main()