Repository: OpenMDAO/dymos (python)

Issue:
ControlGroup cleanup.
### Task Description

Previously `ControlGroup` existed to house separate `ControlInterpComp` and `PolynomialControlInterpComp` components. Since these have been united, there is no longer a reason for the `ControlGroup` to exist.

With the development of new Radau and GaussLobatto transcriptions that handle state continuity defects, there will no longer be a need for `ContinuityComp` for these transcriptions. `ControlComp` should handle the compute and constraining of control value and rate defects by itself so that we may remove the ContinuityComp from these transcriptions.

Pull request description:
Removed ControlGroup since ControlInterpComp handles all types of controls
### Summary

It is no longer necessary to have the ControlGroup since ControlInterpComp now handles both fully dynamic and polynomial controls.

### Related Issues

- Resolves #1128 

### Backwards incompatibilities

None

### New Dependencies

None


Patch:
diff --git a/dymos/phase/phase.py b/dymos/phase/phase.py
index b83fa50e..e123f2b2 100644
--- a/dymos/phase/phase.py
+++ b/dymos/phase/phase.py
@@ -2632,7 +2632,7 @@ class Phase(om.Group):
 
         # Assign control values
         for name, options in phs.control_options.items():
-            ip = ip_dict[f'control_group.control_interp_comp.controls:{name}']
+            ip = ip_dict[f'control_comp.controls:{name}']
             prob[f'{self_path}controls:{name}'][...] = ip['val']
 
         # Assign parameter values
diff --git a/dymos/transcriptions/common/__init__.py b/dymos/transcriptions/common/__init__.py
index 273402be..da70e743 100644
--- a/dymos/transcriptions/common/__init__.py
+++ b/dymos/transcriptions/common/__init__.py
@@ -1,5 +1,5 @@
 from .continuity_comp import RadauPSContinuityComp, GaussLobattoContinuityComp
-from .control_group import ControlGroup
+from .control_comp import ControlInterpComp
 from .parameter_comp import ParameterComp
 from .time_comp import TimeComp
 from .timeseries_group import TimeseriesOutputGroup
diff --git a/dymos/transcriptions/common/control_group.py b/dymos/transcriptions/common/control_comp.py
similarity index 91%
rename from dymos/transcriptions/common/control_group.py
rename to dymos/transcriptions/common/control_comp.py
index 0da3df90..4f6065a2 100644
--- a/dymos/transcriptions/common/control_group.py
+++ b/dymos/transcriptions/common/control_comp.py
@@ -5,7 +5,7 @@ from scipy.linalg import block_diag
 import openmdao.api as om
 
 from ..grid_data import GridData
-from ...utils.misc import get_rate_units, CoerceDesvar, reshape_val
+from ...utils.misc import get_rate_units, CoerceDesvar
 from ...utils.lgl import lgl
 from ...utils.lagrange import lagrange_matrices
 from ...utils.indexing import get_desvar_indices
@@ -236,6 +236,73 @@ class ControlInterpComp(om.ExplicitComponent):
                                       wrt=self._input_names[name],
                                       rows=rs, cols=cs)
 
+    def _configure_desvars(self):
+        control_options = self.options['control_options']
+        gd = self.options['grid_data']
+
+        for name, options in control_options.items():
+            if options['control_type'] == 'polynomial':
+                num_input_nodes = options['order'] + 1
+                shape = options['shape']
+                # default_val = reshape_val(options['val'], shape, num_input_nodes)
+                if options['opt']:
+
+                    desvar_indices = np.arange(num_input_nodes, dtype=int)
+                    if options['fix_initial']:
+                        desvar_indices = desvar_indices[1:]
+                    if options['fix_final']:
+                        desvar_indices = desvar_indices[:-1]
+
+                    lb = -INF_BOUND if options['lower'] is None else options['lower']
+                    ub = INF_BOUND if options['upper'] is None else options['upper']
+
+                    self.add_design_var(f'controls:{name}',
+                                        lower=lb,
+                                        upper=ub,
+                                        ref=options['ref'],
+                                        ref0=options['ref0'],
+                                        adder=options['adder'],
+                                        scaler=options['scaler'],
+                                        indices=desvar_indices,
+                                        flat_indices=True)
+
+                # self.set_input_defaults(name=f'controls:{name}', val=default_val, units=options['units'])
+            else:
+                num_input_nodes = gd.subset_num_nodes['control_input']
+
+                dvname = f'controls:{name}'
+                shape = options['shape']
+                size = np.prod(shape)
+                if options['opt']:
+                    desvar_indices = get_desvar_indices(size, num_input_nodes,
+                                                        options['fix_initial'], options['fix_final'])
+
+                    if len(desvar_indices) > 0:
+                        coerce_desvar_option = CoerceDesvar(num_input_nodes, desvar_indices,
+                                                            options=options)
+
+                        lb = np.zeros_like(desvar_indices, dtype=float)
+                        lb[:] = -INF_BOUND if coerce_desvar_option('lower') is None else \
+                            coerce_desvar_option('lower')
+
+                        ub = np.zeros_like(desvar_indices, dtype=float)
+                        ub[:] = INF_BOUND if coerce_desvar_option('upper') is None else \
+                            coerce_desvar_option('upper')
+
+                        self.add_design_var(name=dvname,
+                                            lower=lb,
+                                            upper=ub,
+                                            scaler=coerce_desvar_option('scaler'),
+                                            adder=coerce_desvar_option('adder'),
+                                            ref0=coerce_desvar_option('ref0'),
+                                            ref=coerce_desvar_option('ref'),
+                                            indices=desvar_indices,
+                                            flat_indices=True)
+
+                # default_val = reshape_val(options['val'], shape, num_input_nodes)
+
+                # self.set_input_defaults(name=dvname, val=default_val, units=options['units'])
+
     def configure_io(self):
         """
         I/O creation is delayed until configure so we can determine shape and units for the states.
@@ -310,6 +377,7 @@ class ControlInterpComp(om.ExplicitComponent):
         self.D2 = D_da.dot(D_dd.dot(L_id))
 
         self._configure_controls()
+        self._configure_desvars()
 
     def compute(self, inputs, outputs):
         """
@@ -374,6 +442,7 @@ class ControlInterpComp(om.ExplicitComponent):
         partials : Jacobian
             Subjac components written to partials[output_name, input_name].
         """
+        ogd = self.options['output_grid_data'] or self.options['grid_data']
         control_options = self.options['control_options']
 
         dstau_dt = np.reciprocal(inputs['dt_dstau'])
@@ -388,7 +457,7 @@ class ControlInterpComp(om.ExplicitComponent):
                 control_name = self._input_names[name]
                 num_input_nodes = options['order'] + 1
                 L_de, D_de, D2_de = self._matrices[name]
-                nn = self.options['output_grid_data'].num_nodes
+                nn = ogd.num_nodes
 
                 size = self.sizes[name]
                 rate_name = self._output_rate_names[name]
@@ -434,116 +503,3 @@ class ControlInterpComp(om.ExplicitComponent):
                 partials[rate_name, control_name] = self.rate_jacs[name].multiply(dstau_dt_x_size).data
 
                 partials[rate2_name, control_name] = self.rate2_jacs[name].multiply(dstau_dt2_x_size).data
-
-
-class ControlGroup(om.Group):
-    """
-    Class definition for the ControlGroup.
-
-    Parameters
-    ----------
-    **kwargs : dict
-        Dictionary of optional arguments.
-    """
-    def initialize(self):
-        """
-        Declare group options.
-        """
-        self.options.declare('control_options', types=dict,
-                             desc='Dictionary of options for the dynamic controls.')
-        self.options.declare('time_units', default=None, allow_none=True, types=str,
-                             desc='Units of time.')
-        self.options.declare('grid_data', types=GridData, desc='Container object for grid info for the control inputs.')
-        self.options.declare('output_grid_data', types=GridData, allow_none=True, default=None,
-                             desc='GridData object for the output grid. If None, use the same grid_data as the inputs.')
-
-    def setup(self):
-        """
-        Define the structure of the control group.
-        """
-        gd = self.options['grid_data']
-        ogd = self.options['output_grid_data'] or self.options['grid_data']
-        control_options = self.options['control_options']
-        time_units = self.options['time_units']
-
-        if len(control_options) < 1:
-            return
-
-        self.add_subsystem(
-            'control_interp_comp',
-            subsys=ControlInterpComp(time_units=time_units, grid_data=gd, output_grid_data=ogd,
-                                     control_options=control_options),
-            promotes_inputs=['*'],
-            promotes_outputs=['*'])
-
-    def configure_io(self):
-        """
-        I/O creation is delayed until configure so we can determine shape and units for the states.
-        """
-        control_options = self.options['control_options']
-        gd = self.options['grid_data']
-
-        self.control_interp_comp.configure_io()
-
-        for name, options in control_options.items():
-            if options['control_type'] == 'polynomial':
-                num_input_nodes = options['order'] + 1
-                shape = options['shape']
-                default_val = reshape_val(options['val'], shape, num_input_nodes)
-                if options['opt']:
-
-                    desvar_indices = np.arange(num_input_nodes, dtype=int)
-                    if options['fix_initial']:
-                        desvar_indices = desvar_indices[1:]
-                    if options['fix_final']:
-                        desvar_indices = desvar_indices[:-1]
-
-                    lb = -INF_BOUND if options['lower'] is None else options['lower']
-                    ub = INF_BOUND if options['upper'] is None else options['upper']
-
-                    self.add_design_var(f'controls:{name}',
-                                        lower=lb,
-                                        upper=ub,
-                                        ref=options['ref'],
-                                        ref0=options['ref0'],
-                                        adder=options['adder'],
-                                        scaler=options['scaler'],
-                                        indices=desvar_indices,
-                                        flat_indices=True)
-
-                self.set_input_defaults(name=f'controls:{name}', val=default_val, units=options['units'])
-            else:
-                num_input_nodes = gd.subset_num_nodes['control_input']
-
-                dvname = f'controls:{name}'
-                shape = options['shape']
-                size = np.prod(shape)
-                if options['opt']:
-                    desvar_indices = get_desvar_indices(size, num_input_nodes,
-                                                        options['fix_initial'], options['fix_final'])
-
-                    if len(desvar_indices) > 0:
-                        coerce_desvar_option = CoerceDesvar(num_input_nodes, desvar_indices,
-                                                            options=options)
-
-                        lb = np.zeros_like(desvar_indices, dtype=float)
-                        lb[:] = -INF_BOUND if coerce_desvar_option('lower') is None else \
-                            coerce_desvar_option('lower')
-
-                        ub = np.zeros_like(desvar_indices, dtype=float)
-                        ub[:] = INF_BOUND if coerce_desvar_option('upper') is None else \
-                            coerce_desvar_option('upper')
-
-                        self.add_design_var(name=dvname,
-                                            lower=lb,
-                                            upper=ub,
-                                            scaler=coerce_desvar_option('scaler'),
-                                            adder=coerce_desvar_option('adder'),
-                                            ref0=coerce_desvar_option('ref0'),
-                                            ref=coerce_desvar_option('ref'),
-                                            indices=desvar_indices,
-                                            flat_indices=True)
-
-                default_val = reshape_val(options['val'], shape, num_input_nodes)
-
-                self.set_input_defaults(name=dvname, val=default_val, units=options['units'])
diff --git a/dymos/transcriptions/explicit_shooting/explicit_shooting.py b/dymos/transcriptions/explicit_shooting/explicit_shooting.py
index d60f2912..ee834ce0 100644
--- a/dymos/transcriptions/explicit_shooting/explicit_shooting.py
+++ b/dymos/transcriptions/explicit_shooting/explicit_shooting.py
@@ -14,7 +14,7 @@ from ...utils.misc import get_rate_units, CoerceDesvar
 from ...utils.indexing import get_src_indices_by_row
 from ...utils.introspection import get_promoted_vars, get_source_metadata, get_targets, _get_targets_metadata
 from ...utils.constants import INF_BOUND
-from ..common import TimeComp, TimeseriesOutputGroup, ControlGroup, ParameterComp
+from ..common import TimeComp, TimeseriesOutputGroup, ControlInterpComp, ParameterComp
 
 
 class ExplicitShooting(TranscriptionBase):
@@ -348,13 +348,13 @@ class ExplicitShooting(TranscriptionBase):
         phase._check_control_options()
 
         if phase.control_options:
-            control_group = ControlGroup(control_options=phase.control_options,
-                                         time_units=phase.time_options['units'],
-                                         grid_data=self.options['grid'],
-                                         output_grid_data=self._output_grid_data)
+            control_comp = ControlInterpComp(control_options=phase.control_options,
+                                             time_units=phase.time_options['units'],
+                                             grid_data=self.options['grid'],
+                                             output_grid_data=self._output_grid_data)
 
-            phase.add_subsystem('control_group',
-                                subsys=control_group,
+            phase.add_subsystem('control_comp',
+                                subsys=control_comp,
                                 promotes=[('t_duration', 't_duration_val'), 'dt_dstau',
                                           '*controls:*', '*control_values:*', '*control_rates:*'])
 
@@ -390,8 +390,8 @@ class ExplicitShooting(TranscriptionBase):
 
         integrator_comp = phase._get_subsystem('integrator')
         integrator_comp._configure_controls()
-        control_group = phase._get_subsystem('control_group')
-        control_group.configure_io()
+        control_comp = phase._get_subsystem('control_comp')
+        control_comp.configure_io()
 
         ode = phase._get_subsystem('ode')
         ode_inputs = get_promoted_vars(ode, 'input')
diff --git a/dymos/transcriptions/pseudospectral/birkhoff.py b/dymos/transcriptions/pseudospectral/birkhoff.py
index 8687d2fd..e81cc03e 100644
--- a/dymos/transcriptions/pseudospectral/birkhoff.py
+++ b/dymos/transcriptions/pseudospectral/birkhoff.py
@@ -171,11 +171,11 @@ class Birkhoff(TranscriptionBase):
         super().configure_controls(phase)
 
         if phase.control_options:
-            phase.control_group.configure_io()
-            phase.promotes('control_group',
+            phase.control_comp.configure_io()
+            phase.promotes('control_comp',
                            any=['*controls:*', '*control_values:*', '*control_rates:*'])
 
-            phase.connect('dt_dstau', 'control_group.dt_dstau')
+            phase.connect('dt_dstau', 'control_comp.dt_dstau')
 
         for name, options in phase.control_options.items():
             if options['targets']:
diff --git a/dymos/transcriptions/pseudospectral/pseudospectral_base.py b/dymos/transcriptions/pseudospectral/pseudospectral_base.py
index 3df618fc..9d614c76 100644
--- a/dymos/transcriptions/pseudospectral/pseudospectral_base.py
+++ b/dymos/transcriptions/pseudospectral/pseudospectral_base.py
@@ -119,11 +119,11 @@ class PseudospectralBase(TranscriptionBase):
         super().configure_controls(phase)
 
         if phase.control_options:
-            phase.control_group.configure_io()
-            phase.promotes('control_group',
+            phase.control_comp.configure_io()
+            phase.promotes('control_comp',
                            any=['*controls:*', '*control_values:*', '*control_rates:*'])
 
-            phase.connect('dt_dstau', 'control_group.dt_dstau')
+            phase.connect('dt_dstau', 'control_comp.dt_dstau')
 
     def configure_states(self, phase):
         """
diff --git a/dymos/transcriptions/transcription_base.py b/dymos/transcriptions/transcription_base.py
index cbced1fb..5c0c072e 100644
--- a/dymos/transcriptions/transcription_base.py
+++ b/dymos/transcriptions/transcription_base.py
@@ -4,7 +4,7 @@ import numpy as np
 
 import openmdao.api as om
 
-from .common import ControlGroup, ParameterComp
+from .common import ControlInterpComp, ParameterComp
 from ..utils.constants import INF_BOUND
 from ..utils.indexing import get_constraint_flat_idxs
 from ..utils.introspection import configure_states_introspection, get_promoted_vars, \
@@ -141,14 +141,14 @@ class TranscriptionBase(object):
         phase._check_control_options()
 
         if phase.control_options:
-            control_group = ControlGroup(control_options=phase.control_options,
-                                         time_units=phase.time_options['units'],
-                                         grid_data=self.grid_data)
+            control_comp = ControlInterpComp(control_options=phase.control_options,
+                                             time_units=phase.time_options['units'],
+                                             grid_data=self.grid_data)
 
-            phase.add_subsystem('control_group',
-                                subsys=control_group)
+            phase.add_subsystem('control_comp',
+                                subsys=control_comp)
 
-            phase.connect('t_duration_val', 'control_group.t_duration')
+            phase.connect('t_duration_val', 'control_comp.t_duration')
 
     def configure_controls(self, phase):
         """


Test patch:
diff --git a/dymos/transcriptions/common/test/test_control_group.py b/dymos/transcriptions/common/test/test_control_group.py
index 8990d34c..b2d6ed33 100644
--- a/dymos/transcriptions/common/test/test_control_group.py
+++ b/dymos/transcriptions/common/test/test_control_group.py
@@ -24,7 +24,7 @@ class MyODE(om.ExplicitComponent):
         pass
 
 
-class TestControlGroup(unittest.TestCase):
+class TestControlInterpComp(unittest.TestCase):
 
     def test_poly_con_bug(self):
         prob = om.Problem()
diff --git a/dymos/transcriptions/common/test/test_control_interp_comp.py b/dymos/transcriptions/common/test/test_control_interp_comp.py
index 9b19588f..fe90123e 100644
--- a/dymos/transcriptions/common/test/test_control_interp_comp.py
+++ b/dymos/transcriptions/common/test/test_control_interp_comp.py
@@ -8,7 +8,7 @@ from dymos.utils.testing_utils import assert_check_partials
 
 import dymos as dm
 from dymos.transcriptions.common import TimeComp
-from dymos.transcriptions.common.control_group import ControlInterpComp
+from dymos.transcriptions.common import ControlInterpComp
 from dymos.transcriptions.grid_data import GridData
 
 # Modify class so we can run it standalone.
@@ -92,8 +92,8 @@ class TestControlRateComp(unittest.TestCase):
 
                 p = om.Problem(model=om.Group())
 
-                controls = {'a': {'units': 'm', 'shape': (1,), 'dynamic': True},
-                            'b': {'units': 'm', 'shape': (1,), 'dynamic': True}}
+                controls = {'a': {'units': 'm', 'shape': (1,), 'dynamic': True, 'opt': False},
+                            'b': {'units': 'm', 'shape': (1,), 'dynamic': True, 'opt': False}}
 
                 ivc = om.IndepVarComp()
                 p.model.add_subsystem('ivc', ivc, promotes_outputs=['*'])
@@ -183,7 +183,7 @@ class TestControlRateComp(unittest.TestCase):
 
                 p = om.Problem(model=om.Group())
 
-                controls = {'a': {'units': 'm', 'shape': (3,), 'dynamic': True}}
+                controls = {'a': {'units': 'm', 'shape': (3,), 'dynamic': True, 'opt': False}}
 
                 ivc = om.IndepVarComp()
                 p.model.add_subsystem('ivc', ivc, promotes_outputs=['*'])
@@ -283,7 +283,7 @@ class TestControlRateComp(unittest.TestCase):
 
                 p = om.Problem(model=om.Group())
 
-                controls = {'a': {'units': 'm', 'shape': (3, 1), 'dynamic': True}}
+                controls = {'a': {'units': 'm', 'shape': (3, 1), 'dynamic': True, 'opt': False}}
 
                 ivc = om.IndepVarComp()
                 p.model.add_subsystem('ivc', ivc, promotes_outputs=['*'])
@@ -382,7 +382,7 @@ class TestControlRateComp(unittest.TestCase):
 
                 p = om.Problem(model=om.Group())
 
-                controls = {'a': {'units': 'm', 'shape': (2, 2), 'dynamic': True}}
+                controls = {'a': {'units': 'm', 'shape': (2, 2), 'dynamic': True, 'opt': False}}
 
                 ivc = om.IndepVarComp()
                 p.model.add_subsystem('ivc', ivc, promotes_outputs=['*'])