Repository: OpenMDAO/dymos (python)

Issue:
Automatically determined rate_units are wrong when state or time units are 'unitless'.
### Description

Dymos attempts to automatically determine rate units for certain outputs depending upon state, control, and time units.

States that are 'unitless' should have rate units of the shape `'1/time_units'`, but are determined to be `'unitless'/time_units`.

The rate units calculation should lump `'unitless'` in with `None` for units which do not appear in the numerator of the rate units.

### Example

The following examples fail currently

```
    def test_rate_units_for_unitless(self):
        rate_units = get_rate_units('unitless', 's', deriv=1)
        self.assertEqual(rate_units, '1/s')
        rate2_units = get_rate_units('unitless', 's', deriv=2)
        self.assertEqual(rate2_units, '1/s**2')

    def test_rate_units_for_unitless_time(self):
        for time_units in (None, 'unitless'):
            with self.subTest(f'{time_units=}'):
                rate_units = get_rate_units('unitless', time_units, deriv=1)
                self.assertEqual(rate_units, None)
                rate2_units = get_rate_units('unitless', time_units, deriv=2)
                self.assertEqual(rate2_units, None)
```

### Dymos Version

1.13.2-dev

### Relevant environment information

_No response_

Pull request description:
Fixed a bug where get_rate_units included "unitless" in the numerator of the resulting rate units.
### Summary

`get_rate_units` should treat `'unitless'` the same way it treats `None`. A state with units of `'unitless'` and time units of 's' should result in rate units of `1/s'.

This PR fixes that issue.

### Related Issues

- Resolves #1181

### Backwards incompatibilities

None

### New Dependencies

None


Patch:
diff --git a/dymos/phase/options.py b/dymos/phase/options.py
index 0a8510ff..d94c2a9e 100644
--- a/dymos/phase/options.py
+++ b/dymos/phase/options.py
@@ -3,7 +3,7 @@ from numbers import Number
 import numpy as np
 
 import openmdao.api as om
-from ..utils.misc import _unspecified, _none_or_unspecified
+from dymos.utils.misc import is_none_or_unspecified, _unspecified
 
 
 class ControlOptionsDictionary(om.OptionsDictionary):
@@ -145,7 +145,7 @@ def check_valid_shape(name, value):
         Shape to check, should be a Iterable, Number, list, or tuple.
     """
     if name == 'shape':
-        if value not in _none_or_unspecified and not isinstance(value, (Iterable, Number, list, tuple)):
+        if not is_none_or_unspecified(value) and not isinstance(value, (Iterable, Number, list, tuple)):
             raise ValueError(f"Option '{name}' with value {value} is not valid.")
 
 
diff --git a/dymos/trajectory/trajectory.py b/dymos/trajectory/trajectory.py
index ce300915..08a46c4d 100644
--- a/dymos/trajectory/trajectory.py
+++ b/dymos/trajectory/trajectory.py
@@ -18,7 +18,8 @@ from .phase_linkage_comp import PhaseLinkageComp
 from ..phase.analytic_phase import AnalyticPhase
 from ..phase.options import TrajParameterOptionsDictionary
 from ..transcriptions.common import ParameterComp
-from ..utils.misc import create_subprob, get_rate_units, _unspecified, _none_or_unspecified
+from ..utils.misc import create_subprob, get_rate_units, \
+    _unspecified, is_unspecified, is_none_or_unspecified
 from ..utils.introspection import get_source_metadata, _get_common_metadata
 
 
@@ -505,10 +506,10 @@ class Trajectory(om.Group):
                        for phase_name, phs_params in params_by_phase.items()
                        if phase_name in targets_per_phase and targets_per_phase[phase_name] in phs_params}
 
-            if options['units'] is _unspecified:
+            if is_unspecified(options['units']):
                 options['units'] = _get_common_metadata(targets, metadata_key='units')
 
-            if options['shape'] in _none_or_unspecified:
+            if is_none_or_unspecified(options['shape']):
                 options['shape'] = _get_common_metadata(targets, metadata_key='shape')
 
             param_comp = self._get_subsystem('param_comp')
@@ -654,10 +655,10 @@ class Trajectory(om.Group):
         linkage_options._src_b = sources['b']
         linkage_options['shape'] = shapes['b']
 
-        if linkage_options['units_a'] is _unspecified:
+        if is_unspecified(linkage_options['units_a']):
             linkage_options['units_a'] = units['a']
 
-        if linkage_options['units_b'] is _unspecified:
+        if is_unspecified(linkage_options['units_b']):
             linkage_options['units_b'] = units['b']
 
         if units['a'] is not None and units['b'] is not None:
@@ -666,7 +667,7 @@ class Trajectory(om.Group):
             conversion_scaler, conversion_offset = (1.0, 0.0)
 
         if not linkage_options['connected'] \
-                and linkage_options['units'] is _unspecified \
+                and is_unspecified(linkage_options['units']) \
                 and (abs(conversion_scaler - 1.0) > 1.0E-15 or abs(conversion_offset) > 1.0E-15):
             raise ValueError(f'{info_str}Linkage units were not specified but the units of {phase_name_a}.{var_a} '
                              f'({units["a"]}) and {phase_name_b}.{var_b} ({units["b"]}) are not equivalent. '
@@ -1160,32 +1161,32 @@ class Trajectory(om.Group):
             If True, this constraint is enforced by direct connection rather than a constraint
             for the optimizer. This is only valid for states and time.
         """
-        if sign_a is not _unspecified:
-            if mult_a is not _unspecified:
+        if not is_unspecified(sign_a):
+            if not is_unspecified(mult_a):
                 raise ValueError(
                     "Both the deprecated 'sign_a' option and option 'mult_a' were specified."
                     "Going forward, please use only option mult_a.")
             warn_deprecation("'sign_a' has been deprecated. Use 'mult_a' instead.")
             mult_a = sign_a
-        else:  # sign_a is _unspecified
-            if mult_a is _unspecified:
+        else:
+            if is_unspecified(mult_a):
                 mult_a = 1.0
 
-        if sign_b is not _unspecified:
-            if mult_b is not _unspecified:
+        if not is_unspecified(sign_b):
+            if not is_unspecified(mult_b):
                 raise ValueError(
                     "Both the deprecated 'sign_b' option and option 'mult_b' were specified."
                     "Going forward, please use only option mult_b.")
             warn_deprecation("'sign_b' has been deprecated. Use 'mult_b' instead.")
             mult_b = sign_b
-        else:  # sign_a is _unspecified
-            if mult_b is _unspecified:
+        else:
+            if is_unspecified(mult_b):
                 mult_b = -1.0
 
         if connected:
             invalid_options = []
             for arg in ['lower', 'upper', 'equals', 'scaler', 'adder', 'ref0', 'ref', 'units']:
-                if locals()[arg] is not None and locals()[arg] is not _unspecified:
+                if locals()[arg] is not None and not is_unspecified(locals()[arg]):
                     invalid_options.append(arg)
             if locals()['linear']:
                 invalid_options.append('linear')
diff --git a/dymos/transcriptions/common/parameter_comp.py b/dymos/transcriptions/common/parameter_comp.py
index 00e7472d..b69a0d8c 100644
--- a/dymos/transcriptions/common/parameter_comp.py
+++ b/dymos/transcriptions/common/parameter_comp.py
@@ -4,8 +4,8 @@
 import numpy as np
 
 from openmdao.core.explicitcomponent import ExplicitComponent
-from ...utils.misc import _none_or_unspecified
-from ..._options import options as dymos_options
+from dymos.utils.misc import is_none_or_unspecified
+from dymos._options import options as dymos_options
 
 
 class ParameterComp(ExplicitComponent):
@@ -133,7 +133,7 @@ class ParameterComp(ExplicitComponent):
 
         _val = np.asarray(val)
 
-        if shape in _none_or_unspecified:
+        if is_none_or_unspecified(shape):
             _shape = (1,)
             size = _val.size
         else:
diff --git a/dymos/transcriptions/explicit_shooting/ode_evaluation_group.py b/dymos/transcriptions/explicit_shooting/ode_evaluation_group.py
index f37d1b93..285bc860 100644
--- a/dymos/transcriptions/explicit_shooting/ode_evaluation_group.py
+++ b/dymos/transcriptions/explicit_shooting/ode_evaluation_group.py
@@ -13,7 +13,7 @@ from ...utils.introspection import configure_controls_introspection, \
     configure_time_introspection, configure_parameters_introspection, \
     configure_states_discovery, configure_states_introspection, _get_targets_metadata, \
     _get_common_metadata, get_promoted_vars
-from ...utils.misc import get_rate_units, _unspecified, _none_or_unspecified
+from ...utils.misc import get_rate_units, is_unspecified, is_none_or_unspecified
 from ...utils.ode_utils import _make_ode_system
 
 
@@ -237,12 +237,12 @@ class ODEEvaluationGroup(om.Group):
 
             targets = _get_targets_metadata(ode_inputs, name=name, user_targets=options['targets'])
 
-            if options['units'] is _unspecified:
+            if is_unspecified(options['units']):
                 units = _get_common_metadata(targets, 'units')
             else:
                 units = options['units']
 
-            if options['shape'] in _none_or_unspecified:
+            if is_none_or_unspecified(options['shape']):
                 shape = _get_common_metadata(targets, 'shape')
             else:
                 shape = options['shape']
diff --git a/dymos/utils/introspection.py b/dymos/utils/introspection.py
index 9c6a360a..fe283d2b 100644
--- a/dymos/utils/introspection.py
+++ b/dymos/utils/introspection.py
@@ -5,7 +5,7 @@ import openmdao.api as om
 import numpy as np
 from openmdao.utils.units import simplify_unit
 from openmdao.utils.general_utils import ensure_compatible
-from dymos.utils.misc import _unspecified, _none_or_unspecified
+from dymos.utils.misc import _unspecified, is_unspecified, is_none_or_unspecified
 from ..phase.options import StateOptionsDictionary, TimeseriesOutputOptionsDictionary
 from .misc import get_rate_units
 
@@ -132,7 +132,7 @@ def get_targets(ode, name, user_targets):
         ode_inputs = ode
     else:
         ode_inputs = get_promoted_vars(ode, iotypes=('input',))
-    if user_targets is _unspecified:
+    if is_unspecified(user_targets):
         if name in ode_inputs:
             return [name]
     elif user_targets:
@@ -169,10 +169,10 @@ def configure_controls_introspection(control_options, ode, time_units='s'):
 
         options['targets'] = list(targets.keys())
         if targets:
-            if options['units'] is _unspecified:
+            if is_unspecified(options['units']):
                 options['units'] = _get_common_metadata(targets, metadata_key='units')
 
-            if options['shape'] in _none_or_unspecified:
+            if is_none_or_unspecified(options['shape']):
                 shape = _get_common_metadata(targets, metadata_key='shape')
                 if len(shape) == 1:
                     options['shape'] = (1,)
@@ -189,12 +189,12 @@ def configure_controls_introspection(control_options, ode, time_units='s'):
 
         options['rate_targets'] = list(rate_targets.keys())
         if rate_targets:
-            if options['units'] is _unspecified:
+            if is_unspecified(options['units']):
                 rate_target_units = _get_common_metadata(rate_targets, metadata_key='units')
                 options['units'] = time_units if rate_target_units is None else \
                     simplify_unit(f'{rate_target_units}*{time_units}')
 
-            if options['shape'] in _none_or_unspecified:
+            if is_none_or_unspecified(options['shape']):
                 shape = _get_common_metadata(rate_targets, metadata_key='shape')
                 if len(shape) == 1:
                     options['shape'] = (1,)
@@ -211,12 +211,12 @@ def configure_controls_introspection(control_options, ode, time_units='s'):
 
         options['rate2_targets'] = list(rate2_targets.keys())
         if rate2_targets:
-            if options['units'] is _unspecified:
+            if is_unspecified(options['units']):
                 rate2_target_units = _get_common_metadata(rate_targets, metadata_key='units')
                 options['units'] = f'{time_units**2}' if rate2_target_units is None \
                     else simplify_unit(f'{rate2_target_units}*{time_units}**2')
 
-            if options['shape'] in _none_or_unspecified:
+            if is_none_or_unspecified(options['shape']):
                 shape = _get_common_metadata(rate2_targets, metadata_key='shape')
                 if len(shape) == 1:
                     options['shape'] = (1,)
@@ -253,7 +253,7 @@ def configure_parameters_introspection(parameter_options, ode):
         # This is a bit of a hack. Any target with a shape of (1,) is unambiguously static.
         # We may want to consider forcing users to tag these as static for dymos 2.0.0
         shape_1_targets = {tgt for tgt, meta in targets.items() if meta['shape'] == (1,)}
-        if options['static_targets'] is _unspecified:
+        if is_unspecified(options['static_targets']):
             options['static_targets'] = static_tagged_targets.union(shape_1_targets)
         elif options['static_targets']:
             options['static_targets'] = options['targets'].copy()
@@ -265,7 +265,7 @@ def configure_parameters_introspection(parameter_options, ode):
                              f"User has specified 'static_target = False' for parameter `{name}`,\nbut one or more "
                              f"targets is tagged with 'dymos.static_target':\n{static_tagged_targets}")
 
-        if options['units'] is _unspecified:
+        if is_unspecified(options['units']):
             options['units'] = _get_common_metadata(targets, metadata_key='units')
 
         # Check that all targets have the same shape.
@@ -289,7 +289,7 @@ def configure_parameters_introspection(parameter_options, ode):
         else:
             introspected_shape = None
 
-        if options['shape'] in _none_or_unspecified:
+        if is_none_or_unspecified(options['shape']):
             if isinstance(options['val'], Number):
                 options['shape'] = introspected_shape
             else:
@@ -330,7 +330,7 @@ def configure_time_introspection(time_options, ode):
 
     time_options['targets'] = targets
 
-    if time_options['units'] is _unspecified:
+    if is_unspecified(time_options['units']):
         time_options['units'] = _get_common_metadata(targets, 'units')
 
     if any(['dymos.static_target' in meta['tags'] for meta in targets.values()]):
@@ -384,10 +384,10 @@ def configure_states_introspection(state_options, time_options, control_options,
 
         options['targets'] = list(targets.keys())
         if targets:
-            if options['units'] is _unspecified:
+            if is_unspecified(options['units']):
                 options['units'] = _get_common_metadata(targets, metadata_key='units')
 
-            if options['shape'] in _none_or_unspecified:
+            if is_none_or_unspecified(options['shape']):
                 shape = _get_common_metadata(targets, metadata_key='shape')
                 if len(shape) == 1:
                     options['shape'] = (1,)
@@ -432,10 +432,10 @@ def configure_states_introspection(state_options, time_options, control_options,
             rate_src_shape = (1,)
             rate_src_units = None
 
-        if options['shape'] in _none_or_unspecified:
+        if is_none_or_unspecified(options['shape']):
             options['shape'] = rate_src_shape
 
-        if options['units'] is _unspecified:
+        if is_unspecified(options['units']):
             if rate_src_units is None:
                 options['units'] = time_units
             else:
@@ -482,10 +482,10 @@ def configure_analytic_states_introspection(state_options, ode):
             raise RuntimeError(f'ODE output {source} is tagged with `dymos.static_output` and cannot be used as a '
                                f'state variable in an AnalyticPhase.')
 
-        if options['shape'] in _none_or_unspecified:
+        if is_none_or_unspecified(options['shape']):
             options['shape'] = src_shape
 
-        if options['units'] is _unspecified:
+        if is_unspecified(options['units']):
             options['units'] = src_units
 
 
@@ -672,10 +672,10 @@ def configure_timeseries_output_introspection(phase):
             output_options['src'] = output_meta['src']
             output_options['src_idxs'] = output_meta['src_idxs']
 
-            if output_options['shape'] in (None, _unspecified):
+            if is_none_or_unspecified(output_options['shape']):
                 output_options['shape'] = output_meta['shape']
 
-            if output_options['units'] in (None, _unspecified):
+            if is_none_or_unspecified(output_options['units']):
                 output_options['units'] = output_meta['units']
 
         if not_found:
@@ -762,22 +762,22 @@ def _configure_boundary_balance_introspection(phase):
             raise ValueError(f'{phase.msginfo}: For boundary balance, param must be one of t_initial, t_duration, '
                              'a parameter in the phase, initial_states:{name}, or final_states:{name}')
 
-        if options.get('units', _unspecified) is _unspecified:
+        if is_unspecified(options.get('units', _unspecified)):
             options['units'] = param_units
 
-        if options.get('lower', _unspecified) is _unspecified:
+        if is_unspecified(options.get('lower', _unspecified)):
             if param_bounds is None:
                 options['lower'] = None
             else:
                 options['lower'] = param_bounds[0]
 
-        if options.get('upper', _unspecified) is _unspecified:
+        if is_unspecified(options.get('upper', _unspecified)):
             if param_bounds is None:
                 options['upper'] = None
             else:
                 options['upper'] = param_bounds[1]
 
-        if resid_units is _unspecified:
+        if is_unspecified(resid_units):
             if resid_type in ['t', 't_phase']:
                 options['eq_units'] = time_units
             elif resid_type == 'state':
@@ -1044,12 +1044,12 @@ def get_source_metadata(ode, src, user_units=_unspecified, user_shape=_unspecifi
     if src not in ode_outputs:
         raise ValueError(f"Unable to find the source '{src}' in the ODE.")
 
-    if user_units in _none_or_unspecified:
+    if is_none_or_unspecified(user_units):
         meta['units'] = ode_outputs[src]['units']
     else:
         meta['units'] = user_units
 
-    if user_shape in _none_or_unspecified:
+    if is_none_or_unspecified(user_shape):
         ode_shape = ode_outputs[src]['shape']
         meta['shape'] = (1,) if len(ode_shape) == 1 else ode_shape[1:]
     else:
diff --git a/dymos/utils/misc.py b/dymos/utils/misc.py
index 33868607..6187ddba 100644
--- a/dymos/utils/misc.py
+++ b/dymos/utils/misc.py
@@ -1,3 +1,4 @@
+from collections.abc import Iterable
 
 import numpy as np
 
@@ -11,7 +12,46 @@ from .indexing import get_desvar_indices
 
 # unique object to check if default is given (when None is an allowed value)
 _unspecified = _ReprClass("unspecified")
-_none_or_unspecified = {None, _unspecified}
+
+
+def is_unspecified(obj):
+    """
+    Return True if the object is _unspecified.
+
+    This function should be used instead of `{obj} is _unspecified`, which
+    is not reliable across processes. The use of `{obj} == _unspecified` will
+    fail if `obj` is an array.
+
+    Parameters
+    ----------
+    obj : any
+        Any python object.
+
+    Returns
+    -------
+    bool
+        True if the obj is not an array, and obj == _UNDEFINED.
+    """
+    if isinstance(obj, Iterable):
+        return False
+    return obj == _unspecified
+
+
+def is_none_or_unspecified(obj):
+    """
+    Similar to is_undefined, but also returns True if obj is None.
+
+    Parameters
+    ----------
+    obj : any
+        Any python object.
+
+    Returns
+    -------
+    bool
+        True if the obj is not an array, and obj == _UNDEFINED or obj is None.
+    """
+    return is_unspecified(obj) or obj is None
 
 
 def get_rate_units(units, time_units, deriv=1):
@@ -38,12 +78,14 @@ def get_rate_units(units, time_units, deriv=1):
 
     tu = time_units if deriv == 1 else f'{time_units}**2'
 
-    if units is not None and time_units is not None:
+    if units not in (None, 'unitless') and time_units not in (None, 'unitless'):
         return f'{units}/{tu}'
-    elif units is not None:
+    elif units not in (None, 'unitless'):
         return units
-    elif time_units is not None:
-        return f'1.0/{tu}'
+    elif time_units not in (None, 'unitless'):
+        return f'1/{tu}'
+    else:  # Explicitly return None if both units and time_units are None or 'unitless'
+        return None
 
 
 def reshape_val(val, shape, num_input_nodes):


Test patch:
diff --git a/dymos/examples/brachistochrone/test/test_brachistochrone_simulate_options.py b/dymos/examples/brachistochrone/test/test_brachistochrone_simulate_options.py
index c438fcdc..8f3892c1 100644
--- a/dymos/examples/brachistochrone/test/test_brachistochrone_simulate_options.py
+++ b/dymos/examples/brachistochrone/test/test_brachistochrone_simulate_options.py
@@ -5,6 +5,8 @@ import numpy as np
 import openmdao.api as om
 from openmdao.utils.testing_utils import use_tempdirs
 
+from dymos.utils.misc import is_unspecified
+
 
 class BrachistochroneODE(om.ExplicitComponent):
 
@@ -152,7 +154,7 @@ class TestBrachistochroneSimulate(unittest.TestCase):
                 opt_times_per_seg = phase.simulate_options['times_per_seg']
                 num_segments = phase.options['transcription'].grid_data.num_segments
 
-                expected_num_times = opt_times_per_seg * num_segments if times_per_seg is _unspecified \
+                expected_num_times = opt_times_per_seg * num_segments if is_unspecified(times_per_seg) \
                     else times_per_seg * num_segments
 
                 self.assertEqual(expected_num_times, t.size)
diff --git a/dymos/utils/test/test_misc.py b/dymos/utils/test/test_misc.py
index 4d8d614c..c052bc80 100644
--- a/dymos/utils/test/test_misc.py
+++ b/dymos/utils/test/test_misc.py
@@ -14,6 +14,20 @@ class TestMisc(unittest.TestCase):
 
         self.assertEqual(rate2_units, 'm/s**2')
 
+    def test_rate_units_for_unitless(self):
+        rate_units = get_rate_units('unitless', 's', deriv=1)
+        self.assertEqual(rate_units, '1/s')
+        rate2_units = get_rate_units('unitless', 's', deriv=2)
+        self.assertEqual(rate2_units, '1/s**2')
+
+    def test_rate_units_for_unitless_time(self):
+        for time_units in (None, 'unitless'):
+            with self.subTest(f'{time_units=}'):
+                rate_units = get_rate_units('unitless', time_units, deriv=1)
+                self.assertEqual(rate_units, None)
+                rate2_units = get_rate_units('unitless', time_units, deriv=2)
+                self.assertEqual(rate2_units, None)
+
     def test_get_rate_units_invalid_deriv(self):
 
         with self.assertRaises(ValueError) as e: