Repository: Pyomo/pyomo

Issue:
Make physical units check trigger for `_GeneralVarData.set_value` more consistent
## Summary

<!-- Explain, in a clear and concise way, the command you ran and the result you were trying to achieve.
Example: "I ran `pyomo solve` to optimize a model and ..." -->
As of #3077 (and, in particular, [`cfa6ff49f`](https://github.com/Pyomo/pyomo/pull/3077/commits/cfa6ff49f5d20c07af63faaba0b0877e1e327cb5)), invoking `_GeneralVarData.set_value()` on an variable object with units to a dimensionless numpy type (such as `numpy.float64`) may result in a `UnitsError`, but this appears to depend on on whether or not numpy types were added to `native_numeric_types` before or after `set_value()` was invoked.

### Steps to reproduce the issue

<!-- Please include the command run and/or provide an example script that demonstrates the problem. -->

Before [`cfa6ff49f`](https://github.com/Pyomo/pyomo/pull/3077/commits/cfa6ff49f5d20c07af63faaba0b0877e1e327cb5)
```python
>>> import numpy as np
>>> import pyomo.environ as pyo
>>> m = pyo.ConcreteModel()
>>> m.v = pyo.Var(units=pyo.units.m)
>>> m.v.set_value(np.float64(1))
>>> # (no error)
```

After [`cfa6ff49f`](https://github.com/Pyomo/pyomo/pull/3077/commits/cfa6ff49f5d20c07af63faaba0b0877e1e327cb5)
```python
>>> import numpy as np
>>> import pyomo.environ as pyo
>>> m = pyo.ConcreteModel()
>>> m.v = pyo.Var(units=pyo.units.m)
>>> m.v.set_value(np.float64(1))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "...pyomo/pyomo/core/base/var.py", line 397, in set_value
    val = units.convert_value(
  File "...pyomo/pyomo/core/base/units_container.py", line 1384,
 in convert_value
    raise UnitsError(
pyomo.core.base.units_container.UnitsError: Cannot convert dimensionless to m. Units are not compatible.
>>> m.v.set_value(np.float64(1))
>>> # (no error on second attempt; numpy numeric types registered before exception raised)
```

### Error Message

<!-- If Pyomo reported an error, provide the error message. PLEASE INCLUDE THE ENTIRE STACK TRACE. -->

<!-- If it did not report an error but the output appears incorrect, provide the incorrect output. If there was no error message and no output but the result is incorrect, describe how it does not match what you expect. -->
See previous section.

### Information on your system

Pyomo version: 6.7.1dev0 (before and after [`cfa6ff49f`](https://github.com/Pyomo/pyomo/pull/3077/commits/cfa6ff49f5d20c07af63faaba0b0877e1e327cb5))
Python version: 3.9.17
Operating system: Ubuntu 20.04
How Pyomo was installed (PyPI, conda, source): source
Solver (if applicable):



### Additional information
Other dependencies: numpy 1.23.1, Pint 0.19.2
<!-- If you have any additional information, please list it here. -->


<!-- We encourage you to try, as much as possible, to reduce your problem to the minimal example that still reproduces the issue. That would help us a lot in fixing it quickly and effectively!

If you want to ask a question (how to use Pyomo, what it can do, etc.), please refer to our [online documentation](https://github.com/Pyomo/pyomo#getting-help) on ways to get help. -->




Discussion:

Resolving patch:
diff --git a/pyomo/core/base/param.py b/pyomo/core/base/param.py
--- a/pyomo/core/base/param.py
+++ b/pyomo/core/base/param.py
@@ -162,16 +162,31 @@ def set_value(self, value, idx=NOTSET):
         # required to be mutable.
         #
         _comp = self.parent_component()
-        if type(value) in native_types:
+        if value.__class__ in native_types:
             # TODO: warn/error: check if this Param has units: assigning
             # a dimensionless value to a united param should be an error
             pass
         elif _comp._units is not None:
             _src_magnitude = expr_value(value)
-            _src_units = units.get_units(value)
-            value = units.convert_value(
-                num_value=_src_magnitude, from_units=_src_units, to_units=_comp._units
-            )
+            # Note: expr_value() could have just registered a new numeric type
+            if value.__class__ in native_types:
+                value = _src_magnitude
+            else:
+                _src_units = units.get_units(value)
+                value = units.convert_value(
+                    num_value=_src_magnitude,
+                    from_units=_src_units,
+                    to_units=_comp._units,
+                )
+        # FIXME: we should call value() here [to ensure types get
+        # registered], but doing so breaks non-numeric Params (which we
+        # allow).  The real fix will be to follow the precedent from
+        # GetItemExpression and have separate types based on which
+        # expression "system" the Param should participate in (numeric,
+        # logical, or structural).
+        #
+        # else:
+        #     value = expr_value(value)
 
         old_value, self._value = self._value, value
         try:
diff --git a/pyomo/core/base/var.py b/pyomo/core/base/var.py
--- a/pyomo/core/base/var.py
+++ b/pyomo/core/base/var.py
@@ -384,17 +384,22 @@ def set_value(self, val, skip_validation=False):
         #
         # Check if this Var has units: assigning dimensionless
         # values to a variable with units should be an error
-        if type(val) not in native_numeric_types:
-            if self.parent_component()._units is not None:
-                _src_magnitude = value(val)
+        if val.__class__ in native_numeric_types:
+            pass
+        elif self.parent_component()._units is not None:
+            _src_magnitude = value(val)
+            # Note: value() could have just registered a new numeric type
+            if val.__class__ in native_numeric_types:
+                val = _src_magnitude
+            else:
                 _src_units = units.get_units(val)
                 val = units.convert_value(
                     num_value=_src_magnitude,
                     from_units=_src_units,
                     to_units=self.parent_component()._units,
                 )
-            else:
-                val = value(val)
+        else:
+            val = value(val)
 
         if not skip_validation:
             if val not in self.domain:


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
@@ -562,7 +562,8 @@ def test_numpy_basic_bool_registration(self):
     @unittest.skipUnless(numpy_available, "This test requires NumPy")
     def test_automatic_numpy_registration(self):
         cmd = (
-            'import pyomo; from pyomo.core.base import Var, Param; import numpy as np; '
+            'import pyomo; from pyomo.core.base import Var, Param; '
+            'from pyomo.core.base.units_container import units; import numpy as np; '
             'print(np.float64 in pyomo.common.numeric_types.native_numeric_types); '
             '%s; print(np.float64 in pyomo.common.numeric_types.native_numeric_types)'
         )
@@ -582,6 +583,11 @@ def _tester(expr):
         _tester('Var() + np.float64(5)')
         _tester('v = Var(); v.construct(); v.value = np.float64(5)')
         _tester('p = Param(mutable=True); p.construct(); p.value = np.float64(5)')
+        _tester('v = Var(units=units.m); v.construct(); v.value = np.float64(5)')
+        _tester(
+            'p = Param(mutable=True, units=units.m); p.construct(); '
+            'p.value = np.float64(5)'
+        )
 
 
 if __name__ == "__main__":