Repository: Pyomo/pyomo

Issue:
Inconsistent Param mutability with units
## Summary
Utilizing the `units` keyword argument in `Param` leads to some unexpected behavior:
1. The `Param` is `mutable` by default.
2. Manually specifying `mutable = False` still returns a `mutable` Param.

I am sure there's technical limitations around having non-mutable Params with units. That said, I would have expected (2) to raise an error.

### Steps to reproduce the issue

```python
>>> from pyomo.environ import *
>>> m = ConcreteModel()
>>> m.p_with_units = Param(initialize=42, units=units.m**2/units.s)
>>> m.p_with_units.mutable
True
>>> m.p_with_units_non_mutable = Param(initialize=42, units=units.m**2/units.s, mutable=False)
>>> m.p_with_units_non_mutable.mutable
True
```

### Information on your system

Pyomo version: 6.6.2
Python version: 3.10.9
Operating system: macOS
How Pyomo was installed (PyPI, conda, source): PyPI
Solver (if applicable): N/A

Inconsistent Param mutability with units
## Summary
Utilizing the `units` keyword argument in `Param` leads to some unexpected behavior:
1. The `Param` is `mutable` by default.
2. Manually specifying `mutable = False` still returns a `mutable` Param.

I am sure there's technical limitations around having non-mutable Params with units. That said, I would have expected (2) to raise an error.

### Steps to reproduce the issue

```python
>>> from pyomo.environ import *
>>> m = ConcreteModel()
>>> m.p_with_units = Param(initialize=42, units=units.m**2/units.s)
>>> m.p_with_units.mutable
True
>>> m.p_with_units_non_mutable = Param(initialize=42, units=units.m**2/units.s, mutable=False)
>>> m.p_with_units_non_mutable.mutable
True
```

### Information on your system

Pyomo version: 6.6.2
Python version: 3.10.9
Operating system: macOS
How Pyomo was installed (PyPI, conda, source): PyPI
Solver (if applicable): N/A



Discussion:
`Params` with units must be mutable: if `mutable==False`, then the expression system is allowed to simplify things as it generates expressions.  Because the units are stored on the Param, if we were to simplify the Param (and replace it with raw floats), then we would lose the units.

It is a good point that (explicitly) specifying `mutable=False` should at least issue a warning (I don't think an exception is necessary).  I'll get a PR up shortly.
`Params` with units must be mutable: if `mutable==False`, then the expression system is allowed to simplify things as it generates expressions.  Because the units are stored on the Param, if we were to simplify the Param (and replace it with raw floats), then we would lose the units.

It is a good point that (explicitly) specifying `mutable=False` should at least issue a warning (I don't think an exception is necessary).  I'll get a PR up shortly.

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
@@ -308,13 +308,19 @@ def __init__(self, *args, **kwd):
         _init = self._pop_from_kwargs('Param', kwd, ('rule', 'initialize'), NOTSET)
         _domain_rule = self._pop_from_kwargs('Param', kwd, ('domain', 'within'))
         self._validate = kwd.pop('validate', None)
-        self._mutable = kwd.pop('mutable', Param.DefaultMutable)
+        self._mutable = kwd.pop('mutable', None)
         self._default_val = kwd.pop('default', Param.NoValue)
         self._dense_initialize = kwd.pop('initialize_as_dense', False)
         self._units = kwd.pop('units', None)
-        if self._units is not None:
-            self._units = units.get_units(self._units)
-            self._mutable = True
+
+        if self._mutable is None:
+            if self._units is None:
+                self._mutable = Param.DefaultMutable
+            else:
+                # Params with units *must* be mutable, so that
+                # expression simplification does not remove units from
+                # the expression.
+                self._mutable = True
 
         kwd.setdefault('ctype', Param)
         IndexedComponent.__init__(self, *args, **kwd)
@@ -769,6 +775,15 @@ def construct(self, data=None):
                 "Constructing Param, name=%s, from data=%s" % (self.name, str(data))
             )
 
+        if self._units is not None:
+            self._units = units.get_units(self._units)
+            if not self._mutable:
+                logger.warning(
+                    "Params with units must be mutable.  "
+                    f"Converting Param '{self.name}' to mutable."
+                )
+                self._mutable = True
+
         try:
             #
             # If the default value is a simple type, we check it versus


Test patch:
diff --git a/pyomo/core/tests/unit/test_param.py b/pyomo/core/tests/unit/test_param.py
--- a/pyomo/core/tests/unit/test_param.py
+++ b/pyomo/core/tests/unit/test_param.py
@@ -1516,6 +1516,25 @@ def test_set_value_units(self):
         """.strip(),
         )
 
+    @unittest.skipUnless(pint_available, "units test requires pint module")
+    def test_units_and_mutability(self):
+        m = ConcreteModel()
+        with LoggingIntercept() as LOG:
+            m.p = Param(units=units.g)
+        self.assertEqual(LOG.getvalue(), "")
+        self.assertTrue(m.p.mutable)
+        with LoggingIntercept() as LOG:
+            m.q = Param(units=units.g, mutable=True)
+        self.assertEqual(LOG.getvalue(), "")
+        self.assertTrue(m.q.mutable)
+        with LoggingIntercept() as LOG:
+            m.r = Param(units=units.g, mutable=False)
+        self.assertEqual(
+            LOG.getvalue(),
+            "Params with units must be mutable.  Converting Param 'r' to mutable.\n",
+        )
+        self.assertTrue(m.r.mutable)
+
     def test_scalar_get_mutable_when_not_present(self):
         m = ConcreteModel()
         m.p = Param(mutable=True)