Repository: OpenMDAO/dymos (language: python)

Issue:
Trajectory results report is empty for Birkhoff transcription.
### Description

The trajectory results report is empty for Birkhoff phases. The `_load_data_sources` function is returning empty dictionaries of data for these phases.



### Example

See any of the Birkhoff examples, and set `make_plots=True` in the call to `dm.run_problem`.

### Dymos Version

1.10.1-dev

### Relevant environment information

_No response_


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 650d350f3..c2a245e63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,10 +81,15 @@ fort.6
 # mkdocs
 /mkdocs/site
 
-# Examples
-dymos/examples/*/*/_output/
-docs/examples/*/*.db
-docs/dymos_book/examples/*/coloring_files/
+# Docs
+docs/dymos_book/_build
+docs/dymos_book/_srcdocs
+
+# Dymos and OpenMDAO files
+**/*.db
+**/*.sql
+**/coloring_files
+**/reports
 
 # Misc files
 docs/faq/*/*.pkl
diff --git a/docs/dymos_book/api/phase_api.ipynb b/docs/dymos_book/api/phase_api.ipynb
index 5eeaf233f..01ca8d272 100644
--- a/docs/dymos_book/api/phase_api.ipynb
+++ b/docs/dymos_book/api/phase_api.ipynb
@@ -248,6 +248,12 @@
     "        :noindex:\n",
     "```\n",
     "\n",
+    "## duplicate\n",
+    "```{eval-rst}\n",
+    "    .. automethod:: dymos.Phase.duplicate\n",
+    "        :noindex:\n",
+    "```\n",
+    "\n",
     "## set_refine_options\n",
     "```{eval-rst}\n",
     "    .. automethod:: dymos.Phase.set_refine_options\n",
diff --git a/docs/dymos_book/examples/multi_phase_cannonball/multi_phase_cannonball.ipynb b/docs/dymos_book/examples/multi_phase_cannonball/multi_phase_cannonball.ipynb
index 10c987b17..b746b2b2d 100644
--- a/docs/dymos_book/examples/multi_phase_cannonball/multi_phase_cannonball.ipynb
+++ b/docs/dymos_book/examples/multi_phase_cannonball/multi_phase_cannonball.ipynb
@@ -350,24 +350,16 @@
     "\n",
     "# Second Phase (descent)\n",
     "transcription = dm.GaussLobatto(num_segments=5, order=3, compressed=True)\n",
-    "descent = dm.Phase(ode_class=CannonballODE, transcription=transcription)\n",
+    "# descent = dm.Phase(ode_class=CannonballODE, transcription=transcription)\n",
+    "descent = ascent.duplicate(transcription=transcription)\n",
     "\n",
     "traj.add_phase('descent', descent)\n",
     "\n",
-    "# All initial states and time are free, since\n",
-    "#    they will be linked to the final states of ascent.\n",
-    "# Final altitude is fixed, because we will set\n",
-    "#    it to zero so that the phase ends at ground impact)\n",
-    "descent.set_time_options(initial_bounds=(.5, 100), duration_bounds=(.5, 100),\n",
-    "                         duration_ref=100, units='s')\n",
-    "descent.add_state('r')\n",
-    "descent.add_state('h', fix_initial=False, fix_final=True)\n",
-    "descent.add_state('gam', fix_initial=False, fix_final=False)\n",
-    "descent.add_state('v', fix_initial=False, fix_final=False)\n",
-    "\n",
-    "descent.add_parameter('S', units='m**2', static_target=True)\n",
-    "descent.add_parameter('m', units='kg', static_target=True)\n",
-    "\n",
+    "# Because we copied the descent phase\n",
+    "# - The 'fix_initial' option for time was set to False\n",
+    "# - All state 'fix_initial' and 'fix_final' options are set to False.\n",
+    "# - We only need to fix the final value of altitude so the descent phase ends at ground impact.\n",
+    "descent.set_state_options('h', fix_final=True)\n",
     "descent.add_objective('r', loc='final', scaler=-1.0)\n",
     "\n",
     "# Add internally-managed design parameters to the trajectory.\n",
@@ -554,7 +546,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.9.7"
+   "version": "3.11.4"
   }
  },
  "nbformat": 4,
diff --git a/dymos/phase/analytic_phase.py b/dymos/phase/analytic_phase.py
index 3d6212c93..eb3b416c6 100644
--- a/dymos/phase/analytic_phase.py
+++ b/dymos/phase/analytic_phase.py
@@ -1,3 +1,5 @@
+from copy import deepcopy
+
 import openmdao.api as om
 
 from .phase import Phase
@@ -472,3 +474,67 @@ def set_simulate_options(self, *args, **kwargs):
             Simulation cannot be performed on AnalyticPhase.
         """
         raise NotImplementedError('Method set_simulate_options is not available for AnalyticPhase.')
+
+    def duplicate(self, num_nodes=None, boundary_constraints=False, path_constraints=False, objectives=False,
+                  fix_initial_time=False):
+        """
+        Create a copy of this phase where most options and attributes are deep copies of those in the original.
+
+        By default, a deepcopy of the transcription in the original phase is used.
+        Boundary constraints, path constraints, and objectives are _NOT_ copied by default, but the user may opt to do so.
+        By default, initial time is not fixed, nor are the initial or final state values.
+        These also can be overridden with the appropriate arguments.
+
+        Parameters
+        ----------
+        num_nodes : int or None
+            The number of nodes to use in the new phase, or None if it should use the same
+            number as the phase being duplicated.
+        boundary_constraints : bool
+            If True, retain all boundary constraints from the phase to be copied.
+        path_constraints : bool
+            If True, retain all path constraints from the phase to be copied.
+        objectives : bool
+            If True, retain all objectives from the phase to be copied.
+        fix_initial_time : bool
+            If True, fix the initial time of the returned phase.
+
+        Returns
+        -------
+        AnalyticPhase
+            The new phase created by duplicating this one.
+        """
+        nn = num_nodes if num_nodes is not None else self.options['num_nodes']
+        ode_class = self.options['ode_class']
+        ode_init_kwargs = self.options['ode_init_kwargs']
+        auto_solvers = self.options['auto_solvers']
+
+        p = AnalyticPhase(num_nodes=nn, ode_class=ode_class, ode_init_kwargs=ode_init_kwargs,
+                          auto_solvers=auto_solvers)
+
+        p.time_options.update(deepcopy(self.time_options))
+        p.time_options['fix_initial'] = fix_initial_time
+
+        for state_name, state_options in self.state_options.items():
+            p.state_options[state_name] = deepcopy(state_options)
+
+        for param_name, param_options in self.parameter_options.items():
+            p.parameter_options[param_name] = deepcopy(param_options)
+
+        p._timeseries = deepcopy(self._timeseries)
+
+        p.refine_options = deepcopy(self.refine_options)
+        p.simulate_options = deepcopy(self.simulate_options)
+        p.timeseries_options = deepcopy(self.timeseries_options)
+
+        if boundary_constraints:
+            p._initial_boundary_constraints = deepcopy(self._initial_boundary_constraints)
+            p._final_boundary_constraints = deepcopy(self._final_boundary_constraints)
+
+        if path_constraints:
+            p._path_constraints = deepcopy(self._path_constraints)
+
+        if objectives:
+            p._objectives = deepcopy(self._objectives)
+
+        return p
diff --git a/dymos/phase/phase.py b/dymos/phase/phase.py
index d036fafcb..ad3ff612d 100644
--- a/dymos/phase/phase.py
+++ b/dymos/phase/phase.py
@@ -1,5 +1,7 @@
 from collections.abc import Iterable, Callable, Sequence
+from copy import deepcopy
 import inspect
+from os import path
 import warnings
 
 import numpy as np
@@ -21,7 +23,7 @@
     TimeseriesOutputOptionsDictionary, PhaseTimeseriesOptionsDictionary
 
 from ..transcriptions.transcription_base import TranscriptionBase
-from ..transcriptions.grid_data import GaussLobattoGrid, RadauGrid, UniformGrid
+from ..transcriptions.grid_data import GaussLobattoGrid, RadauGrid, UniformGrid, BirkhoffGrid
 from ..transcriptions import ExplicitShooting, GaussLobatto, Radau
 from ..utils.indexing import get_constraint_flat_idxs
 from ..utils.introspection import configure_time_introspection, _configure_constraint_introspection, \
@@ -60,13 +62,18 @@ class Phase(om.Group):
     def __init__(self, from_phase=None, **kwargs):
         _kwargs = kwargs.copy()
 
+        if from_phase is not None:
+            raise RuntimeError('Instantiating a phase from another using the `from_phase`'
+                               ' argument is no longer avaiable.'
+                               ' Use the <phase>.duplicate() or <phase>.get_simulation_phase() methods.')
+
         # These are the options which will be set at setup time.
         # Prior to setup, the options are placed into the user_*_options dictionaries.
-        self.time_options = TimeOptionsDictionary()
-        self.state_options = {}
-        self.control_options = {}
-        self.polynomial_control_options = {}
-        self.parameter_options = {}
+        # self.time_options = TimeOptionsDictionary()
+        # self.state_options = {}
+        # self.control_options = {}
+        # self.polynomial_control_options = {}
+        # self.parameter_options = {}
         self.refine_options = GridRefinementOptionsDictionary()
         self.simulate_options = SimulateOptionsDictionary()
         self.timeseries_ec_vars = {}
@@ -74,34 +81,104 @@ def __init__(self, from_phase=None, **kwargs):
 
         # Dictionaries of variable options that are set by the user via the API
         # These will be applied over any defaults specified by decorators on the ODE
-        if from_phase is None:
-            self._initial_boundary_constraints = []
-            self._final_boundary_constraints = []
-            self._path_constraints = []
-            self._timeseries = {'timeseries': {'transcription': None,
-                                               'subset': 'all',
-                                               'outputs': {}}}
-            self._objectives = {}
-        else:
-            self.time_options.update(from_phase.time_options)
-            self.state_options = from_phase.state_options.copy()
-            self.control_options = from_phase.control_options.copy()
-            self.polynomial_control_options = from_phase.polynomial_control_options.copy()
-            self.parameter_options = from_phase.parameter_options.copy()
+        self._initial_boundary_constraints = []
+        self._final_boundary_constraints = []
+        self._path_constraints = []
+        self._timeseries = {'timeseries': {'transcription': None,
+                                           'subset': 'all',
+                                           'outputs': {}}}
+        self._objectives = {}
+
+        super(Phase, self).__init__(**_kwargs)
 
-            self.refine_options.update(from_phase.refine_options)
-            self.simulate_options.update(from_phase.simulate_options)
+    def duplicate(self, transcription=None, boundary_constraints=False, path_constraints=False, objectives=False,
+                  fix_initial_time=False, fix_initial_states=None, fix_final_states=None):
+        """
+        Create a copy of this phase where most options and attributes are deep copies of those in the original.
 
-            self._initial_boundary_constraints = from_phase._initial_boundary_constraints.copy()
-            self._final_boundary_constraints = from_phase._final_boundary_constraints.copy()
-            self._path_constraints = from_phase._path_constraints.copy()
-            self._timeseries = from_phase._timeseries.copy()
-            self._objectives = from_phase._objectives.copy()
+        By default, a deepcopy of the transcription in the original phase is used.
+        Boundary constraints, path constraints, and objectives are _NOT_ copied by default, but the user may opt to do so.
+        By default, initial time is not fixed, nor are the initial or final state values.
+        These also can be overridden with the appropriate arguments.
 
-            _kwargs['ode_class'] = from_phase.options['ode_class']
-            _kwargs['ode_init_kwargs'] = from_phase.options['ode_init_kwargs']
+        Parameters
+        ----------
+        transcription : TranscriptionBase or None
+            If given, use the specified transcription for the new phase.
+            If None, use a copy of the transcription of the phase to be copied.
+        boundary_constraints : bool
+            If True, retain all boundary constraints from the phase to be copied.
+        path_constraints : bool
+            If True, retain all path constraints from the phase to be copied.
+        objectives : bool
+            If True, retain all objectives from the phase to be copied.
+        fix_initial_time : bool
+            If True, fix the initial time of the returned phase.
+        fix_initial_states : Sequence of str or None
+            If given, set fix_initial=True for the given state names. Otherwise, all states will have fix_initial=False.
+        fix_final_states : Sequence of str or None
+            If given, set fix_final=True for the given state names. Otherwise, all states will have fix_final=False.
 
-        super(Phase, self).__init__(**_kwargs)
+        Returns
+        -------
+        Phase
+            The new phase created by duplicating this one.
+        """
+        t = deepcopy(self.options['transcription']) if transcription is None else transcription
+        ode_class = self.options['ode_class']
+        ode_init_kwargs = self.options['ode_init_kwargs']
+        auto_solvers = self.options['auto_solvers']
+
+        p = Phase(transcription=t, ode_class=ode_class, ode_init_kwargs=ode_init_kwargs,
+                  auto_solvers=auto_solvers)
+
+        # First copy over the variable information as is
+        p.time_options.update(deepcopy(self.time_options))
+
+        for state_name, state_options in self.state_options.items():
+            p.state_options[state_name] = deepcopy(state_options)
+
+        for param_name, param_options in self.parameter_options.items():
+            p.parameter_options[param_name] = deepcopy(param_options)
+
+        for control_name, control_options in self.control_options.items():
+            p.control_options[control_name] = deepcopy(control_options)
+
+        for pc_name, pc_options in self.polynomial_control_options.items():
+            p.polynomial_control_options[pc_name] = deepcopy(pc_options)
+
+        p.time_options['fix_initial'] = fix_initial_time
+
+        _fis = [] if fix_initial_states is None else fix_initial_states
+        _ffs = [] if fix_final_states is None else fix_final_states
+
+        for state_name, state_options in p.state_options.items():
+            if state_name in _fis:
+                state_options['fix_initial'] = True
+            else:
+                state_options['fix_initial'] = False
+
+            if state_name in _ffs:
+                state_options['fix_final'] = True
+            else:
+                state_options['fix_final'] = False
+
+        p._timeseries = deepcopy(self._timeseries)
+        p.refine_options = deepcopy(self.refine_options)
+        p.simulate_options = deepcopy(self.simulate_options)
+        p.timeseries_options = deepcopy(self.timeseries_options)
+
+        if boundary_constraints:
+            p._initial_boundary_constraints = deepcopy(self._initial_boundary_constraints)
+            p._final_boundary_constraints = deepcopy(self._final_boundary_constraints)
+
+        if path_constraints:
+            p._path_constraints = deepcopy(self._path_constraints)
+
+        if objectives:
+            p._objectives = deepcopy(self._objectives)
+
+        return p
 
     def initialize(self):
         """
@@ -116,6 +193,40 @@ def initialize(self):
                              desc='Transcription technique of the optimal control problem.')
         self.options.declare('auto_solvers', types=bool, default=True,
                              desc='If True, attempt to automatically assign solvers if necessary.')
+        self.options.declare('time_options', types=TimeOptionsDictionary, default=TimeOptionsDictionary(),
+                             desc='Options for time in this phase.')
+        self.options.declare('state_options', types=dict, default={},
+                             desc='Options for each state in this phase.')
+        self.options.declare('parameter_options', types=dict, default={},
+                             desc='Options for each parameter in this phase.')
+        self.options.declare('control_options', types=dict, default={},
+                             desc='Options for each control in this phase.')
+        self.options.declare('polynomial_control_options', types=dict, default={},
+                             desc='Options for each polynomial control in this phase.')
+
+    @property
+    def time_options(self):
+        return self.options['time_options']
+
+    @time_options.setter
+    def time_options(self, tod: TimeOptionsDictionary):
+        self.options['time_options'] = tod
+
+    @property
+    def state_options(self):
+        return self.options['state_options']
+
+    @property
+    def parameter_options(self):
+        return self.options['parameter_options']
+
+    @property
+    def control_options(self):
+        return self.options['control_options']
+
+    @property
+    def polynomial_control_options(self):
+        return self.options['polynomial_control_options']
 
     def add_state(self, name, units=_unspecified, shape=_unspecified,
                   rate_source=_unspecified, targets=_unspecified,
@@ -2254,9 +2365,92 @@ def get_simulation_phase(self, times_per_seg=_unspecified, method=_unspecified,
         """
         from .simulation_phase import SimulationPhase
 
-        sim_phase = SimulationPhase(from_phase=self, times_per_seg=times_per_seg, method=method,
-                                    atol=atol, rtol=rtol, first_step=first_step, max_step=max_step,
-                                    reports=reports)
+        # t = deepcopy(self.options['transcription']) if transcription is None else transcription
+        ode_class = self.options['ode_class']
+        ode_init_kwargs = self.options['ode_init_kwargs']
+        auto_solvers = self.options['auto_solvers']
+
+        self_tx = self.options['transcription']
+        num_seg = self_tx.grid_data.num_segments
+        seg_order = self_tx.grid_data.transcription_order
+        seg_ends = self_tx.grid_data.segment_ends
+        compressed = self_tx.grid_data.compressed
+
+        sim_options = self.simulate_options
+
+        _method = method if method is not _unspecified else sim_options['method']
+        _atol = atol if atol is not _unspecified else sim_options['atol']
+        _rtol = rtol if rtol is not _unspecified else sim_options['rtol']
+        _first_step = first_step if first_step is not _unspecified else sim_options['first_step']
+        _max_step = max_step if max_step is not _unspecified else sim_options['max_step']
+        _times_per_seg = times_per_seg if times_per_seg is not _unspecified else sim_options['times_per_seg']
+
+        if isinstance(self_tx, GaussLobatto):
+            grid = GaussLobattoGrid(num_segments=num_seg, nodes_per_seg=seg_order, segment_ends=seg_ends,
+                                    compressed=compressed)
+        elif isinstance(self_tx, Radau):
+            grid = RadauGrid(num_segments=num_seg, nodes_per_seg=seg_order + 1, segment_ends=seg_ends,
+                             compressed=compressed)
+        elif isinstance(self_tx.grid_data, GaussLobattoGrid) or \
+                isinstance(self_tx.grid_data, RadauGrid) or isinstance(self_tx.grid_data, BirkhoffGrid):
+            grid = self_tx.grid_data
+        else:
+            raise RuntimeError(f'Unexpected grid class for {phase_tx.grid_data}. Only phases with GaussLobatto '
+                               f'or Radau grids can be simulated.')
+
+        if _times_per_seg is None:
+            output_grid = None
+        else:
+            output_grid = UniformGrid(num_segments=num_seg, nodes_per_seg=_times_per_seg, segment_ends=seg_ends,
+                                      compressed=compressed)
+
+        tx = ExplicitShooting(propagate_derivs=False,
+                              subprob_reports=reports,
+                              grid=grid,
+                              output_grid=output_grid,
+                              method=_method,
+                              atol=_atol,
+                              rtol=_rtol,
+                              first_step=_first_step,
+                              max_step=_max_step)
+
+        sim_phase = SimulationPhase(transcription=tx,
+                                    ode_class=ode_class,
+                                    ode_init_kwargs=ode_init_kwargs)
+
+        sim_phase.time_options.update(self.time_options)
+        sim_phase.time_options['fix_initial'] = True
+        sim_phase.time_options['fix_duration'] = True
+        sim_phase.time_options['initial_bounds'] = (None, None)
+        sim_phase.time_options['duration_bounds'] = (None, None)
+
+        for state_name, state_options in self.state_options.items():
+            sim_phase.state_options[state_name] = deepcopy(state_options)
+            sim_phase.state_options[state_name]['fix_final'] = False
+            sim_phase.state_options[state_name]['input_initial'] = False
+
+        for param_name, param_options in self.parameter_options.items():
+            sim_phase.parameter_options[param_name] = deepcopy(param_options)
+            sim_phase.parameter_options[param_name]['opt'] = False
+
+        for control_name, control_options in self.control_options.items():
+            sim_phase.control_options[control_name] = deepcopy(control_options)
+            sim_phase.control_options[control_name]['opt'] = False
+
+        for pc_name, pc_options in self.polynomial_control_options.items():
+            sim_phase.polynomial_control_options[pc_name] = deepcopy(pc_options)
+            sim_phase.polynomial_control_options[pc_name]['opt'] = False
+
+        sim_phase._timeseries = {ts_name: ts_options for ts_name, ts_options in self._timeseries.items()
+                                 if ts_name == 'timeseries'}
+
+        for state_name, state_options in sim_phase.state_options.items():
+            state_options['fix_final'] = False
+            state_options['input_initial'] = False
+
+        sim_phase.refine_options = deepcopy(self.refine_options)
+        sim_phase.simulate_options = deepcopy(self.simulate_options)
+        sim_phase.timeseries_options = deepcopy(self.timeseries_options)
 
         return sim_phase
 
diff --git a/dymos/phase/simulation_phase.py b/dymos/phase/simulation_phase.py
index 36f33e960..e60fd1c66 100644
--- a/dymos/phase/simulation_phase.py
+++ b/dymos/phase/simulation_phase.py
@@ -17,85 +17,41 @@ class SimulationPhase(Phase):
 
     Parameters
     ----------
-    from_phase : <Phase> or None
-        A phase instance from which the initialized phase should copy its data.
-    times_per_seg : int
-        The number of output points per segment, uniformly distributed.
-    method : str or _unspecified
-        A valid scipy.solve_ivp method for integration.
-    atol : float or _unspecified
-        Absolute error tolerance of the integration.
-    rtol : float or _unspecified
-        Relative error tolerance of the integration.
-    first_step : float or _unspecified
-        Initial step size of the integration.
-    max_step : float or _unspecified
-        Maximum step size of the integration.
-    reports : float or _unspecified
-        If True, generate reports for the subproblem used in integration.
+    transcription : ExplicitShooting
+        The transcription used for the SimulationPhase. It must be an instance of ExplicitShooting.
 
     **kwargs : dict
         Dictionary of optional phase arguments.
     """
-    def __init__(self, from_phase, times_per_seg=_unspecified, method=_unspecified, atol=_unspecified,
-                 rtol=_unspecified, first_step=_unspecified, max_step=_unspecified,
-                 reports=False, **kwargs):
-
-        phase_tx = from_phase.options['transcription']
-        num_seg = phase_tx.grid_data.num_segments
-        seg_order = phase_tx.grid_data.transcription_order
-        seg_ends = phase_tx.grid_data.segment_ends
-        compressed = phase_tx.grid_data.compressed
-
-        sim_options = from_phase.simulate_options
-
-        _method = method if method is not _unspecified else sim_options['method']
-        _atol = atol if atol is not _unspecified else sim_options['atol']
-        _rtol = rtol if rtol is not _unspecified else sim_options['rtol']
-        _first_step = first_step if first_step is not _unspecified else sim_options['first_step']
-        _max_step = max_step if max_step is not _unspecified else sim_options['max_step']
-        _times_per_seg = times_per_seg if times_per_seg is not _unspecified else sim_options['times_per_seg']
-
-        if isinstance(phase_tx, GaussLobatto):
-            grid = GaussLobattoGrid(num_segments=num_seg, nodes_per_seg=seg_order, segment_ends=seg_ends,
-                                    compressed=compressed)
-        elif isinstance(phase_tx, Radau):
-            grid = RadauGrid(num_segments=num_seg, nodes_per_seg=seg_order + 1, segment_ends=seg_ends,
-                             compressed=compressed)
-        elif isinstance(phase_tx.grid_data, GaussLobattoGrid) or \
-                isinstance(phase_tx.grid_data, RadauGrid) or isinstance(phase_tx.grid_data, BirkhoffGrid):
-            grid = phase_tx.grid_data
-        else:
-            raise RuntimeError(f'Unexpected grid class for {phase_tx.grid_data}. Only phases with GaussLobatto '
-                               f'or Radau grids can be simulated.')
-
-        if _times_per_seg is None:
-            output_grid = None
-        else:
-            output_grid = UniformGrid(num_segments=num_seg, nodes_per_seg=_times_per_seg, segment_ends=seg_ends,
-                                      compressed=compressed)
-
-        tx = ExplicitShooting(propagate_derivs=False,
-                              subprob_reports=reports,
-                              grid=grid,
-                              output_grid=output_grid,
-                              method=_method,
-                              atol=_atol,
-                              rtol=_rtol,
-                              first_step=_first_step,
-                              max_step=_max_step)
-
-        super().__init__(from_phase=from_phase, transcription=tx, ode_class=from_phase.options['ode_class'],
-                         ode_init_kwargs=from_phase.options['ode_init_kwargs'])
-
-        # Remove invalid options
-        for state_name, options in self.state_options.items():
-            options['fix_final'] = False  # ExplicitShooting will raise if `fix_final` is True for any states.
-            options['input_initial'] = False  # Only simulate from the initial value, do not connect.
-
-        # Remove all but the default timeseries object
-        self._timeseries = {ts_name: ts_options for ts_name, ts_options in self._timeseries.items()
-                            if ts_name == 'timeseries'}
+    def __init__(self, transcription=None, **kwargs):
+        if not isinstance(transcription, ExplicitShooting):
+            raise ValueError('The transcription for a SimulationPhase must be '
+                             'ExplicitShooting. Use Phase.get_simulation_phase()'
+                             'to create a simulation Phase.')
+        super().__init__(transcription=transcription, **kwargs)
+
+    def duplicate(self, *args, **kwargs):
+        """
+        Create a copy of this phase where most options and attributes are deep copies of those in the original.
+
+        By default, a deepcopy of the transcription in the original phase is used.
+        Boundary constraints, path constraints, and objectives are _NOT_ copied by default, but the user may opt to do so.
+        By default, initial time is not fixed, nor are the initial or final state values.
+        These also can be overridden with the appropriate arguments.
+
+        Parameters
+        ----------
+        *args
+            Additional arguments.
+        **kwargs
+            Keyword arguments.
+
+        Raises
+        ------
+        NotImplmentedError
+            This method is not yet supported for SimulationPhase
+        """
+        raise NotImplementedError('SimulationPhase does not support the duplicate method.')
 
     def set_vals_from_phase(self, from_phase):
         """
@@ -140,72 +96,6 @@ def set_vals_from_phase(self, from_phase):
             val = from_phase.get_val(f'polynomial_controls:{name}', units=options['units'], from_src=False)
             self.set_val(f'polynomial_controls:{name}', val, units=options['units'])
 
-    def initialize_values_from_phase(self, prob, from_phase, phase_path=''):
-        """
-        Initializes values in the Phase using the phase from which it was created.
-
-        Parameters
-        ----------
-        prob : Problem
-            The problem instance to set values taken from the from_phase instance.
-        from_phase : Phase
-            The Phase instance from which the values in this phase are being initialized.
-        phase_path : str
-            The pathname of the system in prob that contains the phases.
-        """
-        phs = from_phase
-
-        op_dict = dict([(name, options) for (name, options) in phs.list_outputs(units=True,
-                                                                                list_autoivcs=True,
-                                                                                out_stream=None)])
-        ip_dict = dict([(name, options) for (name, options) in phs.list_inputs(units=True,
-                                                                               out_stream=None)])
-
-        if self.pathname.partition('.')[0] == self.name:
-            self_path = self.name + '.'
-        else:
-            self_path = self.pathname.partition('.')[0] + '.' + self.name + '.'
-
-        if MPI:
-            op_dict = MPI.COMM_WORLD.bcast(op_dict, root=0)
-
-        # Set the integration times
-        time_name = phs.time_options['name']
-        op = op_dict[f'timeseries.timeseries_comp.{time_name}']
-        prob.set_val(f'{self_path}t_initial', op['val'][0, ...])
-        prob.set_val(f'{self_path}t_duration', op['val'][-1, ...] - op['val'][0, ...])
-
-        # Assign initial state values
-        for name in phs.state_options:
-            prefix = 'states:' if from_phase.timeseries_options['use_prefix'] else ''
-            op = op_dict[f'timeseries.timeseries_comp.{prefix}{name}']
-            prob[f'{self_path}initial_states:{name}'][...] = op['val'][0, ...]
-
-        # Assign control values
-        for name, options in phs.control_options.items():
-            ip = ip_dict[f'control_group.control_interp_comp.controls:{name}']
-            prob[f'{self_path}controls:{name}'][...] = ip['val']
-
-        # Assign polynomial control values
-        for name, options in phs.polynomial_control_options.items():
-            ip = ip_dict[f'polynomial_control_group.interp_comp.'
-                         f'polynomial_controls:{name}']
-            prob[f'{self_path}polynomial_controls:{name}'][...] = ip['val']
-
-        # Assign parameter values
-        for name in phs.parameter_options:
-            units = phs.parameter_options[name]['units']
-
-            # We use this private function to grab the correctly sized variable from the
-            # auto_ivc source.
-            val = phs.get_val(f'parameters:{name}', units=units)
-
-            if phase_path:
-                prob_path = f'{phase_path}.{self.name}.parameters:{name}'
-            else:
-                prob_path = f'{self.name}.parameters:{name}'
-            prob.set_val(prob_path, val)
-
     def add_boundary_constraint(self, name, loc, constraint_name=None, units=None,
                                 shape=None, indices=None, lower=None, upper=None, equals=None,
                                 scaler=None, adder=None, ref=None, ref0=None, linear=False, flat_indices=False):
@@ -356,3 +246,55 @@ def add_objective(self, name, loc='final', index=None, shape=(1,), units=None, r
             calculations with other variables sharing the same parallel_deriv_color.
         """
         raise NotImplementedError('SimulationPhase does not support optimization objectives.')
+
+    def check_time_options(self):
+        """
+        Check that time options are valid and issue warnings if invalid options are provided.
+
+        This check is not performed by SimulationPhase.
+
+        Warns
+        -----
+        RuntimeWarning
+            RuntimeWarning is issued in the case of one or more invalid time options.
+        """
+        pass
+
+    def _check_control_options(self):
+        """
+        Check that control options are valid and issue warnings if invalid options are provided.
+
+        This check is not performed by SimulationPhase.
+
+        Warns
+        -----
+        RuntimeWarning
+            RuntimeWarning is issued in the case of one or more invalid time options.
+        """
+        pass
+
+    def _check_polynomial_control_options(self):
+        """
+        Check that polynomial control options are valid and issue warnings if invalid options are provided.
+
+        This check is not performed by SimulationPhase.
+
+        Warns
+        -----
+        RuntimeWarning
+            RuntimeWarning is issued in the case of one or more invalid time options.
+        """
+        pass
+
+    def _check_parameter_options(self):
+        """
+        Check that parameter options are valid and issue warnings if invalid options are provided.
+
+        This check is not performed by SimulationPhase.
+
+        Warns
+        -----
+        RuntimeWarning
+            RuntimeWarning is issued in the case of one or more invalid time options.
+        """
+        pass
diff --git a/dymos/trajectory/trajectory.py b/dymos/trajectory/trajectory.py
index 55886fe79..0f4c12db1 100644
--- a/dymos/trajectory/trajectory.py
+++ b/dymos/trajectory/trajectory.py
@@ -60,7 +60,6 @@ def __init__(self, **kwargs):
         self._phase_graph = nx.DiGraph()
         self._has_connected_phases = False
 
-        self.parameter_options = {}
         self.phases = om.ParallelGroup() if self.options['parallel_phases'] else om.Group()
 
     def initialize(self):
@@ -75,6 +74,12 @@ def initialize(self):
                                   'otherwise it will be a standard OpenMDAO Group.')
         self.options.declare('auto_solvers', types=bool, default=True,
                              desc='If True, attempt to automatically assign solvers if necessary.')
+        self.options.declare('parameter_options', types=dict, default={},
+                             desc='Options for each parameter in this Trajectory')
+
+    @property
+    def parameter_options(self):
+        return self.options['parameter_options']
 
     def add_phase(self, name, phase, **kwargs):
         """
diff --git a/dymos/visualization/timeseries/bokeh_timeseries_report.py b/dymos/visualization/timeseries/bokeh_timeseries_report.py
index 2bdbdc91f..1d67495be 100644
--- a/dymos/visualization/timeseries/bokeh_timeseries_report.py
+++ b/dymos/visualization/timeseries/bokeh_timeseries_report.py
@@ -1,6 +1,7 @@
 import datetime
 from pathlib import Path
 import os.path
+from numpy import isin
 
 try:
     from bokeh.io import output_notebook, output_file, save, show
@@ -16,6 +17,7 @@
 
 import openmdao.api as om
 from openmdao.utils.units import conversion_to_base_units
+from openmdao.recorders.sqlite_recorder import format_version, META_KEY_SEP
 import dymos as dm
 
 
@@ -92,7 +94,7 @@
 """
 
 
-def _meta_tree_subsys_iter(tree, recurse=True, cls=None):
+def _meta_tree_subsys_iter(tree, recurse=True, cls=None, path=None):
     """
     Yield a generator of local subsystems of this system.
 
@@ -102,6 +104,7 @@ def _meta_tree_subsys_iter(tree, recurse=True, cls=None):
         If True, iterate over the whole tree under this system.
     cls : None, str, or Sequence
         The class of the nodes to be iterated
+    path : The absolute path of the given tree.
 
     Yields
     ------
@@ -112,72 +115,179 @@ def _meta_tree_subsys_iter(tree, recurse=True, cls=None):
     for s in tree['children']:
         if s['type'] != 'subsystem':
             continue
+        else:
+            s['path'] = f'{path}.{s["name"]}' if path else s['name']
+
         if cls is None or s['class'] in _cls:
             yield s
         if recurse:
-            for child in _meta_tree_subsys_iter(s, recurse=True, cls=_cls):
+            for child in _meta_tree_subsys_iter(s, recurse=True, cls=_cls, path=s['path']):
                 yield child
 
 
-def _load_data_sources(prob, solution_record_file=None, simulation_record_file=None):
+def _get_model_options(cr, system, run_number=None):
+    """
+    The the options for system stored in the given case reader.
+    If there is more than one set of model options, this function returns the last recorded ones.
+
+    Parameters
+    ----------
+    cr : CaseReader
+        The CaseReader instance holding the data.
+    system : str or None
+        Pathname of system (None for all systems).
+    run_number : int or None
+        Run_driver or run_model iteration to inspect, if given. If None, return the last available.
+
+    Returns
+    -------
+    dict
+        {system: {key: val}}.
+    """
+    if not cr._system_options:
+        raise RuntimeError('System options were not recorded.')
+
+    comp_options = None
+
+    # need to handle edge case for v11 recording
+    if cr._format_version < 12:
+        SEP = '_'
+    else:
+        SEP = META_KEY_SEP
+
+    for key in cr._system_options:
+        if key.find(SEP) > 0:
+            name, num = key.rsplit(SEP, 1)
+        else:
+            name = key
+            num = 0
+
+        # Get the component associated with the highest run number
+        if name == system and (run_number is None or run_number == int(num)):
+            comp_options = cr._system_options[key]['component_options']
+
+    if comp_options is None:
+        raise ValueError(f'No options found for system {system}')
+
+    return comp_options
+
+
+def _get_trajs_and_phases(cr):
+    trajs = {}
+    traj_cls = 'dymos.trajectory.trajectory:Trajectory'
+    phase_cls = ['dymos.phase.phase:Phase',
+                 'dymos.phase.phase:AnalyticPhase',
+                 'dymos.phase.phase:SimualationPhase']
+
+    traj_nodes = [n for n in _meta_tree_subsys_iter(cr.problem_metadata['tree'], cls=traj_cls)]
+    phase_nodes = [n for n in _meta_tree_subsys_iter(cr.problem_metadata['tree'], cls=phase_cls)]
+
+    for tn in traj_nodes:
+        phase_nodes = [n for n in _meta_tree_subsys_iter(tn, cls=phase_cls, path=tn['path'])]
+        traj_path = tn['path']
+        trajs[traj_path] = {'phases': {}, 'parameter_options': {}, 'name': tn['name']}
+        traj_options = _get_model_options(cr=cr, system=traj_path)
+        for param_name, param_options in traj_options['parameter_options'].items():
+            trajs[traj_path]['parameter_options'][param_name] = param_options
+        for pn in phase_nodes:
+            phase_path = pn['path']
+            phase_options = _get_model_options(cr=cr, system=phase_path)
+            phase_meta = trajs[traj_path]['phases'][phase_path] = {'time_options': None,
+                                                                   'parameter_options': None,
+                                                                   'state_options': None,
+                                                                   'control_options': None,
+                                                                   'polynomial_control_options': None,
+                                                                   'name': pn['name']}
+            phase_meta['time_options'] = phase_options['time_options']
+            phase_meta['parameter_options'] = phase_options['parameter_options']
+            phase_meta['state_options'] = phase_options['state_options']
+            phase_meta['control_options'] = phase_options['control_options']
+            phase_meta['polynomial_control_options'] = phase_options['polynomial_control_options']
+
+    return trajs
+
+
+def _load_data_sources(solution_record_file=None, simulation_record_file=None):
+    """
+    Load the data for the timeseries plots from the given solution and record files.
+
+    Parameters
+    ----------
+    solution_record_file : str
+        The path to the solution record file.
+    sim_record_file : str
+        The path to the corresponding simulation record file.
+
+    Returns
+    -------
+    dict
+        A dictionary containing parameter data, solution timeseries data, simulation timeseries data, and
+        units for each timeseries output.
+    """
 
+    traj_and_phase_meta = None
     data_dict = {}
 
     if Path(solution_record_file).is_file():
         sol_cr = om.CaseReader(solution_record_file)
         sol_case = sol_cr.get_case('final')
-        outputs = {name: meta for name, meta in sol_case.list_outputs(units=True, out_stream=None)}
         abs2prom_map = sol_cr.problem_metadata['abs2prom']
+        traj_and_phase_meta = _get_trajs_and_phases(sol_cr)
     else:
         sol_case = None
 
     if Path(simulation_record_file).is_file():
         sim_cr = om.CaseReader(simulation_record_file)
         sim_case = sim_cr.get_case('final')
-        outputs = {name: meta for name, meta in sim_case.list_outputs(units=True, out_stream=None)}
         abs2prom_map = sim_cr.problem_metadata['abs2prom']
+        if traj_and_phase_meta is None:
+            traj_and_phase_meta = _get_trajs_and_phases(sim_cr)
     else:
         sim_case = None
 
+    source_case = sol_case if sol_case else sim_case
+    outputs = {abs_path: meta for abs_path, meta in source_case.list_outputs(out_stream=None, units=True)}
+
     if sol_cr is None and sim_cr is None:
         om.issue_warning('No recorded data provided. Trajectory results report will not be created.')
         return
 
-    for traj in prob.model.system_iter(include_self=True, recurse=True, typ=dm.Trajectory):
-        traj_name = traj.pathname.split('.')[-1]
-        data_dict[traj_name] = {'param_data_by_phase': {},
-                                'sol_data_by_phase': {},
-                                'sim_data_by_phase': {},
-                                'timeseries_units': {}}
+    for traj_path, traj_data in traj_and_phase_meta.items():
+        traj_params = traj_data['parameter_options']
+        traj_name = traj_data['name']
+        data_dict[traj_data['name']] = {'param_data_by_phase': {},
+                                        'sol_data_by_phase': {},
+                                        'sim_data_by_phase': {},
+                                        'timeseries_units': {}}
 
-        for phase_name, phase in traj._phases.items():
+        for phase_path, phase_data in traj_data['phases'].items():
+            phase_name = phase_data['name']
+            phase_param_data = \
+                data_dict[traj_name]['param_data_by_phase'][phase_name] = \
+                {'param': [], 'val': [], 'units': []}
 
-            data_dict[traj_name]['param_data_by_phase'][phase_name] = {'param': [], 'val': [], 'units': []}
-            phase_sol_data = data_dict[traj_name]['sol_data_by_phase'][phase_name] = {}
-            phase_sim_data = data_dict[traj_name]['sim_data_by_phase'][phase_name] = {}
-            ts_units_dict = data_dict[traj_name]['timeseries_units']
+            phase_sol_data = data_dict[traj_data['name']]['sol_data_by_phase'][phase_name] = {}
+            phase_sim_data = data_dict[traj_data['name']]['sim_data_by_phase'][phase_name] = {}
 
             param_outputs = {op: meta for op, meta in outputs.items()
-                             if op.startswith(f'{phase.pathname}.param_comp.parameter_vals')}
-            param_case = sol_case if sol_case else sim_case
-
-            for output_name in sorted(param_outputs.keys(), key=str.casefold):
-                meta = param_outputs[output_name]
-                param_dict = data_dict[traj_name]['param_data_by_phase'][phase_name]
-
-                prom_name = abs2prom_map['output'][output_name]
-                param_name = output_name.replace(f'{phase.pathname}.param_comp.parameter_vals:', '', 1)
-
-                param_dict['param'].append(param_name)
-                param_dict['units'].append(meta['units'])
-                param_dict['val'].append(param_case.get_val(prom_name, units=meta['units']))
-
-            ts_outputs = {op: meta for op, meta in outputs.items() if op.startswith(f'{phase.pathname}.timeseries')}
+                             if op.startswith(f'{phase_path}.param_comp.parameter_vals:')}
+            ts_outputs = {op: meta for op, meta in outputs.items()
+                          if op.startswith(f'{phase_path}.timeseries.')}
+
+            # Populate the phase parameter data
+            phase_params = traj_and_phase_meta[traj_path]['phases'][phase_path]['parameter_options']
+            for param_name in sorted(phase_params, key=str.casefold):
+                units = phase_params[param_name]['units']
+                param_path = f'{traj_path}.{phase_name}.parameter_vals:{param_name}'
+                phase_param_data['param'].append(param_name)
+                phase_param_data['units'].append(units)
+                phase_param_data['val'].append(source_case.get_val(param_path, units=units))
 
             # Find the "largest" unit used for any timeseries output across all phases
-            for output_name in sorted(ts_outputs.keys(), key=str.casefold):
-                meta = ts_outputs[output_name]
-                prom_name = abs2prom_map['output'][output_name]
+            ts_units_dict = data_dict[traj_data['name']]['timeseries_units']
+            for abs_name in sorted(ts_outputs.keys(), key=str.casefold):
+                meta = ts_outputs[abs_name]
+                prom_name = meta['prom_name']
                 var_name = prom_name.split('.')[-1]
 
                 if var_name not in ts_units_dict:
@@ -188,16 +298,7 @@ def _load_data_sources(prob, solution_record_file=None, simulation_record_file=N
                     if new_conv_factor < old_conv_factor:
                         ts_units_dict[var_name] = meta['units']
 
-        # Now a second pass through the phases since we know the units in which to plot
-        # each timeseries variable output.
-        for phase_name, phase in traj._phases.items():
-
-            phase_sol_data = data_dict[traj_name]['sol_data_by_phase'][phase_name] = {}
-            phase_sim_data = data_dict[traj_name]['sim_data_by_phase'][phase_name] = {}
-            ts_units_dict = data_dict[traj_name]['timeseries_units']
-
-            ts_outputs = {op: meta for op, meta in outputs.items() if op.startswith(f'{phase.pathname}.timeseries')}
-
+            # Populate the phase timeseries data
             for output_name in sorted(ts_outputs.keys(), key=str.casefold):
                 meta = ts_outputs[output_name]
                 prom_name = abs2prom_map['output'][output_name]
@@ -233,23 +334,23 @@ def make_timeseries_report(prob, solution_record_file=None, simulation_record_fi
     theme : str
         A valid bokeh theme name to style the report.
     """
+    report_dir = Path(prob.get_reports_dir()) if prob is not None else Path(os.getcwd())
+    if not report_dir.exists():
+        report_dir = Path(os.getcwd())
+
     # For the primary timeseries in each phase in each trajectory, build a set of the pathnames
     # to be plotted.
-    source_data = _load_data_sources(prob, solution_record_file, simulation_record_file)
+    source_data = _load_data_sources(solution_record_file, simulation_record_file)
 
     # Colors of each phase in the plot. Start with the bright colors followed by the faded ones.
     if not _NO_BOKEH:
         colors = bp.d3['Category20'][20][0::2] + bp.d3['Category20'][20][1::2]
         curdoc().theme = theme
 
-    for traj in prob.model.system_iter(include_self=True, recurse=True, typ=dm.Trajectory):
-        traj_name = traj.pathname.split('.')[-1]
-        report_filename = f'{traj.pathname}_results_report.html'
-        report_dir = Path(prob.get_reports_dir())
+    for traj_path, traj_data in source_data.items():
+        traj_name = traj_path.split('.')[-1]
+        report_filename = f'{traj_path}_results_report.html'
         report_path = report_dir / report_filename
-        if not os.path.isdir(report_dir):
-            om.issue_warning(f'Reports directory not available. {report_path} will not be created.')
-            continue
         if _NO_BOKEH:
             with open(report_path, 'wb') as f:
                 f.write("<html>\n<head>\n<title> \nError: bokeh not available</title>\n</head> <body>\n"
@@ -258,21 +359,19 @@ def make_timeseries_report(prob, solution_record_file=None, simulation_record_fi
             continue
 
         param_tables = []
-        phase_names = []
-
-        for phase_name, phase in traj._phases.items():
-
-            phase_names.append(phase_name)
-
+        phase_names = [k.split('.')[-1] for k in traj_data['param_data_by_phase']]
+        for phase_name, param_data in traj_data['param_data_by_phase'].items():
             # Make the parameter table
-            source = ColumnDataSource(source_data[traj_name]['param_data_by_phase'][phase_name])
             columns = [
                 TableColumn(field='param', title='Parameter'),
                 TableColumn(field='val', title='Value'),
                 TableColumn(field='units', title='Units'),
             ]
-            param_tables.append(DataTable(source=source, columns=columns, index_position=None,
-                                          height=30*len(source.data['param']), sizing_mode='stretch_both'))
+            param_tables.append(DataTable(source=ColumnDataSource(param_data),
+                                          columns=columns,
+                                          index_position=None,
+                                          height=30*len(param_data['param']),
+                                          sizing_mode='stretch_both'))
 
         # Plot the timeseries
         ts_units_dict = source_data[traj_name]['timeseries_units']
diff --git a/joss/flow_charts/coupled_co_design.tex b/joss/flow_charts/coupled_co_design.tex
new file mode 100644
index 000000000..3f88ebd56
--- /dev/null
+++ b/joss/flow_charts/coupled_co_design.tex
@@ -0,0 +1,27 @@
+
+% XDSM diagram created with pyXDSM 2.1.3.
+\documentclass{article}
+\usepackage{geometry}
+\usepackage{amsfonts}
+\usepackage{amsmath}
+\usepackage{amssymb}
+\usepackage{tikz}
+
+% Optional packages such as sfmath set through python interface
+\usepackage{}
+
+% Define the set of TikZ packages to be included in the architecture diagram document
+\usetikzlibrary{arrows,chains,positioning,scopes,shapes.geometric,shapes.misc,shadows}
+
+
+% Set the border around all of the architecture diagrams to be tight to the diagrams themselves
+% (i.e. no longer need to tinker with page size parameters)
+\usepackage[active,tightpage]{preview}
+\PreviewEnvironment{tikzpicture}
+\setlength{\PreviewBorder}{5pt}
+
+\begin{document}
+
+\input{"coupled_co_design.tikz"}
+
+\end{document}
diff --git a/joss/flow_charts/coupled_co_design.tikz b/joss/flow_charts/coupled_co_design.tikz
new file mode 100644
index 000000000..891a136e4
--- /dev/null
+++ b/joss/flow_charts/coupled_co_design.tikz
@@ -0,0 +1,55 @@
+
+%%% Preamble Requirements %%%
+% \usepackage{geometry}
+% \usepackage{amsfonts}
+% \usepackage{amsmath}
+% \usepackage{amssymb}
+% \usepackage{tikz}
+
+% Optional packages such as sfmath set through python interface
+% \usepackage{}
+
+% \usetikzlibrary{arrows,chains,positioning,scopes,shapes.geometric,shapes.misc,shadows}
+
+%%% End Preamble Requirements %%%
+
+\input{"/Users/rfalck/anaconda3/envs/py37/lib/python3.7/site-packages/pyxdsm/diagram_styles"}
+\begin{tikzpicture}
+
+\matrix[MatrixSetup]{
+%Row 0
+\node [Optimization] (OPT) {$\text{Optimizer}$};&
+\node [DataInter] (OPT-static) {$\begin{array}{c}\bar{d}\end{array}$};&
+\node [DataInter] (OPT-dynamic) {$\begin{array}{c}t, \bar{x}, \bar{u}\end{array}$};\\
+%Row 1
+\node [DataInter] (static-OPT) {$\begin{array}{c}g_\text{static}\end{array}$};&
+\node [Group] (static) {$\text{Static System Model}$};&
+\node [DataInter] (static-dynamic) {$\begin{array}{c}\text{static} \\ \text{outputs}\end{array}$};\\
+%Row 2
+\node [DataInter] (dynamic-OPT) {$\begin{array}{c}J, \bar{g}_0, \bar{g}_f, \bar{p}\end{array}$};&
+\node [DataInter] (dynamic-static) {$\begin{array}{c}\text{dynamic} \\ \text{outputs}\end{array}$};&
+\node [Group] (dynamic) {$\text{ODE or DAE}$};\\
+};
+
+% XDSM process chains
+
+
+\begin{pgfonlayer}{data}
+\path
+% Horizontal edges
+(dynamic) edge [DataLine] (dynamic-OPT)
+(OPT) edge [DataLine] (OPT-dynamic)
+(static) edge [DataLine] (static-OPT)
+(OPT) edge [DataLine] (OPT-static)
+(static) edge [DataLine] (static-dynamic)
+(dynamic) edge [DataLine] (dynamic-static)
+% Vertical edges
+(dynamic-OPT) edge [DataLine] (OPT)
+(OPT-dynamic) edge [DataLine] (dynamic)
+(static-OPT) edge [DataLine] (OPT)
+(OPT-static) edge [DataLine] (static)
+(static-dynamic) edge [DataLine] (dynamic)
+(dynamic-static) edge [DataLine] (static);
+\end{pgfonlayer}
+
+\end{tikzpicture}
diff --git a/joss/flow_charts/opt_control.tex b/joss/flow_charts/opt_control.tex
new file mode 100644
index 000000000..aec07cbcf
--- /dev/null
+++ b/joss/flow_charts/opt_control.tex
@@ -0,0 +1,27 @@
+
+% XDSM diagram created with pyXDSM 2.1.3.
+\documentclass{article}
+\usepackage{geometry}
+\usepackage{amsfonts}
+\usepackage{amsmath}
+\usepackage{amssymb}
+\usepackage{tikz}
+
+% Optional packages such as sfmath set through python interface
+\usepackage{}
+
+% Define the set of TikZ packages to be included in the architecture diagram document
+\usetikzlibrary{arrows,chains,positioning,scopes,shapes.geometric,shapes.misc,shadows}
+
+
+% Set the border around all of the architecture diagrams to be tight to the diagrams themselves
+% (i.e. no longer need to tinker with page size parameters)
+\usepackage[active,tightpage]{preview}
+\PreviewEnvironment{tikzpicture}
+\setlength{\PreviewBorder}{5pt}
+
+\begin{document}
+
+\input{"opt_control.tikz"}
+
+\end{document}
diff --git a/joss/flow_charts/opt_control.tikz b/joss/flow_charts/opt_control.tikz
new file mode 100644
index 000000000..2c6e777b2
--- /dev/null
+++ b/joss/flow_charts/opt_control.tikz
@@ -0,0 +1,41 @@
+
+%%% Preamble Requirements %%%
+% \usepackage{geometry}
+% \usepackage{amsfonts}
+% \usepackage{amsmath}
+% \usepackage{amssymb}
+% \usepackage{tikz}
+
+% Optional packages such as sfmath set through python interface
+% \usepackage{}
+
+% \usetikzlibrary{arrows,chains,positioning,scopes,shapes.geometric,shapes.misc,shadows}
+
+%%% End Preamble Requirements %%%
+
+\input{"/Users/rfalck/anaconda3/envs/py37/lib/python3.7/site-packages/pyxdsm/diagram_styles"}
+\begin{tikzpicture}
+
+\matrix[MatrixSetup]{
+%Row 0
+\node [Optimization] (OPT) {$\text{Optimizer}$};&
+\node [DataInter] (OPT-ODE) {$\begin{array}{c}t, \bar{x}, \bar{u}, \bar{d}\end{array}$};\\
+%Row 1
+\node [DataInter] (ODE-OPT) {$\begin{array}{c}J, \bar{g}_0, \bar{g}_f, \bar{p}\end{array}$};&
+\node [Group] (ODE) {$\text{ODE or DAE}$};\\
+};
+
+% XDSM process chains
+
+
+\begin{pgfonlayer}{data}
+\path
+% Horizontal edges
+(ODE) edge [DataLine] (ODE-OPT)
+(OPT) edge [DataLine] (OPT-ODE)
+% Vertical edges
+(ODE-OPT) edge [DataLine] (OPT)
+(OPT-ODE) edge [DataLine] (ODE);
+\end{pgfonlayer}
+
+\end{tikzpicture}
diff --git a/joss/flow_charts/sequential_co_design.tex b/joss/flow_charts/sequential_co_design.tex
new file mode 100644
index 000000000..b7696981f
--- /dev/null
+++ b/joss/flow_charts/sequential_co_design.tex
@@ -0,0 +1,27 @@
+
+% XDSM diagram created with pyXDSM 2.1.3.
+\documentclass{article}
+\usepackage{geometry}
+\usepackage{amsfonts}
+\usepackage{amsmath}
+\usepackage{amssymb}
+\usepackage{tikz}
+
+% Optional packages such as sfmath set through python interface
+\usepackage{}
+
+% Define the set of TikZ packages to be included in the architecture diagram document
+\usetikzlibrary{arrows,chains,positioning,scopes,shapes.geometric,shapes.misc,shadows}
+
+
+% Set the border around all of the architecture diagrams to be tight to the diagrams themselves
+% (i.e. no longer need to tinker with page size parameters)
+\usepackage[active,tightpage]{preview}
+\PreviewEnvironment{tikzpicture}
+\setlength{\PreviewBorder}{5pt}
+
+\begin{document}
+
+\input{"sequential_co_design.tikz"}
+
+\end{document}
diff --git a/joss/flow_charts/sequential_co_design.tikz b/joss/flow_charts/sequential_co_design.tikz
new file mode 100644
index 000000000..b8808f526
--- /dev/null
+++ b/joss/flow_charts/sequential_co_design.tikz
@@ -0,0 +1,63 @@
+
+%%% Preamble Requirements %%%
+% \usepackage{geometry}
+% \usepackage{amsfonts}
+% \usepackage{amsmath}
+% \usepackage{amssymb}
+% \usepackage{tikz}
+
+% Optional packages such as sfmath set through python interface
+% \usepackage{}
+
+% \usetikzlibrary{arrows,chains,positioning,scopes,shapes.geometric,shapes.misc,shadows}
+
+%%% End Preamble Requirements %%%
+
+\input{"/Users/rfalck/anaconda3/envs/py37/lib/python3.7/site-packages/pyxdsm/diagram_styles"}
+\begin{tikzpicture}
+
+\matrix[MatrixSetup]{
+%Row 0
+\node [Optimization] (OPT_static) {$\text{Static Optimizer}$};&
+\node [DataInter] (OPT_static-static) {$\begin{array}{c}\bar{d}\end{array}$};&
+\node [DataInter] (OPT_static-OPT_dynamic) {$\begin{array}{c}\text{static optimization} \\ \text{outputs}\end{array}$};&
+\\
+%Row 1
+\node [DataInter] (static-OPT_static) {$\begin{array}{c}J_\text{static}, g_\text{static}\end{array}$};&
+\node [Group] (static) {$\text{Static System Model}$};&
+&
+\\
+%Row 2
+\node [DataInter] (OPT_dynamic-OPT_static) {$\begin{array}{c}\text{dynamic optimization} \\ \text{outputs}\end{array}$};&
+&
+\node [Optimization] (OPT_dynamic) {$\text{Dynamic Optimizer}$};&
+\node [DataInter] (OPT_dynamic-dynamic) {$\begin{array}{c}t, \bar{x}, \bar{u}\end{array}$};\\
+%Row 3
+&
+&
+\node [DataInter] (dynamic-OPT_dynamic) {$\begin{array}{c}J_\text{dynamic}, \bar{g}_0, \bar{g}_f, \bar{p}\end{array}$};&
+\node [Group] (dynamic) {$\text{ODE or DAE}$};\\
+};
+
+% XDSM process chains
+
+
+\begin{pgfonlayer}{data}
+\path
+% Horizontal edges
+(dynamic) edge [DataLine] (dynamic-OPT_dynamic)
+(OPT_dynamic) edge [DataLine] (OPT_dynamic-dynamic)
+(static) edge [DataLine] (static-OPT_static)
+(OPT_static) edge [DataLine] (OPT_static-static)
+(OPT_static) edge [DataLine] (OPT_static-OPT_dynamic)
+(OPT_dynamic) edge [DataLine] (OPT_dynamic-OPT_static)
+% Vertical edges
+(dynamic-OPT_dynamic) edge [DataLine] (OPT_dynamic)
+(OPT_dynamic-dynamic) edge [DataLine] (dynamic)
+(static-OPT_static) edge [DataLine] (OPT_static)
+(OPT_static-static) edge [DataLine] (static)
+(OPT_static-OPT_dynamic) edge [DataLine] (OPT_dynamic)
+(OPT_dynamic-OPT_static) edge [DataLine] (OPT_static);
+\end{pgfonlayer}
+
+\end{tikzpicture}


Test patch:
diff --git a/dymos/examples/cannonball/test/test_two_phase_cannonball_birkhoff.py b/dymos/examples/cannonball/test/test_two_phase_cannonball_birkhoff.py
index 17ecf9157..db453d5c7 100644
--- a/dymos/examples/cannonball/test/test_two_phase_cannonball_birkhoff.py
+++ b/dymos/examples/cannonball/test/test_two_phase_cannonball_birkhoff.py
@@ -96,7 +96,7 @@ def setup(self):
         rho_data = USatm1976Data.rho * om.unit_conversion('slug/ft**3', 'kg/m**3')[0]
         self.rho_interp = interp1d(np.array(alt_data, dtype=complex),
                                    np.array(rho_data, dtype=complex),
-                                   kind='linear')
+                                   kind='linear', bounds_error=False, fill_value='extrapolate')
 
     def compute(self, inputs, outputs):
 
diff --git a/dymos/phase/test/test_analytic_phase.py b/dymos/phase/test/test_analytic_phase.py
index 06baafb3d..e47cf6d47 100644
--- a/dymos/phase/test/test_analytic_phase.py
+++ b/dymos/phase/test/test_analytic_phase.py
@@ -150,6 +150,32 @@ def test_simple_bvp(self):
 
         assert_near_equal(y, expected)
 
+    def test_duplicate(self):
+
+        p = om.Problem()
+        traj = p.model.add_subsystem('traj', dm.Trajectory())
+
+        phase = dm.AnalyticPhase(ode_class=SimpleBVPSolution, num_nodes=11)
+
+        phase.set_time_options(units='s', targets=['x'], fix_initial=True, fix_duration=True,
+                               initial_val=0.0, duration_val=1.0)
+        phase.add_parameter('y0', opt=False, val=0.0)
+        phase.add_parameter('y1', opt=False, val=0.0)
+
+        phase2 = phase.duplicate()
+        traj.add_phase('phase2', phase2)
+
+        p.setup()
+
+        p.run_model()
+
+        t = p.get_val('traj.phase2.timeseries.time', units='s')
+        y = p.get_val('traj.phase2.timeseries.y', units='unitless')
+
+        expected = t * (1 - t) * (1 + t - t**2) / 12
+
+        assert_near_equal(y, expected)
+
     def test_renamed_state(self):
 
         class SolutionWithRenamedState(om.ExplicitComponent):
diff --git a/dymos/visualization/linkage/test/test_gui.py b/dymos/visualization/linkage/test/test_gui.py
index 232b8a8de..641b92472 100644
--- a/dymos/visualization/linkage/test/test_gui.py
+++ b/dymos/visualization/linkage/test/test_gui.py
@@ -9,7 +9,7 @@
 
 if playwright is not None:
     os.system("playwright install")
-    from linkage_report_ui_test import dymos_linkage_gui_test_case  # nopep8: E402
+    from dymos.visualization.linkage.test.linkage_report_ui_test import dymos_linkage_gui_test_case  # nopep8: E402
 
 
 if __name__ == "__main__":
diff --git a/joss/test/test_cannonball_for_joss.py b/joss/test/test_cannonball_for_joss.py
index 472fde06d..395521135 100644
--- a/joss/test/test_cannonball_for_joss.py
+++ b/joss/test/test_cannonball_for_joss.py
@@ -3,7 +3,7 @@
 from openmdao.utils.assert_utils import assert_near_equal
 
 
-@use_tempdirs
+# @use_tempdirs
 class TestCannonballForJOSS(unittest.TestCase):
 
     @require_pyoptsparse(optimizer='SLSQP')
@@ -11,6 +11,7 @@ def test_results(self):
         # begin code for paper
         import numpy as np
         from scipy.interpolate import interp1d
+        import matplotlib
         import matplotlib.pyplot as plt
 
         import openmdao.api as om
@@ -222,7 +223,7 @@ def compute(self, inputs, outputs):
         y0 = p.get_val('traj.ascent.timeseries.h', units='m')
         x1 = p.get_val('traj.descent.timeseries.r', units='m')
         y1 = p.get_val('traj.descent.timeseries.h', units='m')
-        tab20 = plt.cm.get_cmap('tab20').colors
+        tab20 = matplotlib.colormaps['tab20'].colors
         ax.plot(x0, y0, marker='o', label='ascent', color=tab20[0])
         ax.plot(x1, y1, marker='o', label='descent', color=tab20[1])
         ax.legend(loc='best')
@@ -232,3 +233,7 @@ def compute(self, inputs, outputs):
         # End code for paper
 
         assert_near_equal(x1[-1], 3064, tolerance=1.0E-4)
+
+
+if __name__ == '__main__':
+    unittest.main()