Repository: OpenMDAO/dymos
Pull request #952 (2023-07-18 01:11:56)

Refactored time_extents away in favor of housing extents in param_comp
### Summary

In support of upcoming changes, it made sense to treat `t_initial` and `t_duration` as parameters that are always present in the ParameterComp. This change is mostly invisible to the user with a single backwards compatibility issue noted below.

### Related Issues

- Required for #878

### Backwards incompatibilities

`t_initial` and `t_duration` are now inputs to the phase and cannot be connected to other targets (other than via promotion.)
Their values are available as outputs through `t_initial_val` and `t_duration_val`.

### New Dependencies

- Minimum OpenMDAO version is now 3.26.0


review-notebook-app[bot]: Check out this pull request on&nbsp; <a href="https://app.reviewnb.com/OpenMDAO/dymos/pull/952"><img align="absmiddle"  alt="ReviewNB" height="28" class="BotMessageButtonImage" src="https://raw.githubusercontent.com/ReviewNB/support/master/images/button_reviewnb.png"/></a> 

 See visual diffs & provide feedback on Jupyter Notebooks. 

---

 <i>Powered by <a href='https://www.reviewnb.com/?utm_source=gh'>ReviewNB</a></i>
coveralls: 
[![Coverage Status](https://coveralls.io/builds/61482599/badge)](https://coveralls.io/builds/61482599)

coverage: 92.588% (+0.04%) from 92.55% when pulling **ba713b67b2e1b016f1da75c05a049583c0044ab8 on robfalck:time_refactor** into **9a370ad59373661c3bb1a271315619bd9132b787 on OpenMDAO:master**.


Patch:
diff --git a/docs/dymos_book/examples/brachistochrone/brachistochrone_tandem_phases.ipynb b/docs/dymos_book/examples/brachistochrone/brachistochrone_tandem_phases.ipynb
index 819cc44b..6c2f17a4 100644
--- a/docs/dymos_book/examples/brachistochrone/brachistochrone_tandem_phases.ipynb
+++ b/docs/dymos_book/examples/brachistochrone/brachistochrone_tandem_phases.ipynb
@@ -215,7 +215,7 @@
     "#\n",
     "# Connect the two phases\n",
     "#\n",
-    "p.model.connect('phase0.t_duration', 'phase1.t_duration')\n",
+    "p.model.connect('phase0.t_duration_val', 'phase1.t_duration')\n",
     "\n",
     "p.model.connect('phase0.timeseries2.theta', 'phase1.controls:theta')\n",
     "p.model.connect('phase0.timeseries2.v', 'phase1.controls:v')\n",
@@ -357,7 +357,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.11.0"
+   "version": "3.11.4"
   }
  },
  "nbformat": 4,
diff --git a/docs/dymos_book/faq/tandem_phases.ipynb b/docs/dymos_book/faq/tandem_phases.ipynb
index d7ad5b90..03e4fdb4 100644
--- a/docs/dymos_book/faq/tandem_phases.ipynb
+++ b/docs/dymos_book/faq/tandem_phases.ipynb
@@ -241,7 +241,7 @@
     "#\n",
     "# Connect the two phases\n",
     "#\n",
-    "p.model.connect('phase0.t_duration', 'phase1.t_duration')\n",
+    "p.model.connect('phase0.t_duration_val', 'phase1.t_duration')\n",
     "\n",
     "p.model.connect('phase0.timeseries2.theta', 'phase1.controls:theta')\n",
     "p.model.connect('phase0.timeseries2.v', 'phase1.controls:v')\n",
@@ -320,7 +320,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.11.0"
+   "version": "3.11.4"
   }
  },
  "nbformat": 4,
diff --git a/dymos/transcriptions/common/parameter_comp.py b/dymos/transcriptions/common/parameter_comp.py
index d5951704..203c4129 100644
--- a/dymos/transcriptions/common/parameter_comp.py
+++ b/dymos/transcriptions/common/parameter_comp.py
@@ -14,6 +14,8 @@ class ParameterComp(ExplicitComponent):
 
     Parameters
     ----------
+    time_options : TimeOptionsDictionary or None
+        If None, specify time options for the creation of t_initial and t_duration inputs and outputs.
     **kwargs : dict
         Arguments to be passed to the component initialization method.
 
@@ -25,18 +27,38 @@ class ParameterComp(ExplicitComponent):
         Container mapping name of variables to be muxed with associated inputs.
     """
 
-    def __init__(self, **kwargs):
+    def __init__(self, time_options=None, **kwargs):
         """
         Instantiate MuxComp and populate private members.
         """
         super().__init__(**kwargs)
+        self.time_options = time_options
 
         self._no_check_partials = not dymos_options['include_check_partials']
 
+    def setup(self):
+        """
+        Add time-related I/O to the ParameterComp at setup, if provided.
+        """
+        time_options = self.time_options
+
+        if time_options:
+            ti_val = self.time_options['initial_val']
+            td_val = self.time_options['duration_val']
+            units = self.time_options['units']
+
+            self.add_input(name='t_initial', val=ti_val, shape=(1,), units=units)
+            self.add_output(name='t_initial_val', val=ti_val, shape=(1,), units=units)
+            self.declare_partials(of='t_initial_val', wrt='t_initial', val=1.0)
+
+            self.add_input(name=f't_duration', val=td_val, shape=(1,), units=units)
+            self.add_output(name='t_duration_val', val=td_val, shape=(1,), units=units)
+            self.declare_partials(of='t_duration_val', wrt='t_duration', val=1.0)
+
     def add_parameter(self, name, val=1.0, shape=None, output_name=None,
                       units=None, desc='', tags=None, input_tags=None, output_tags=None, input_shape_by_conn=False,
                       input_copy_shape=None, output_shape_by_conn=False, output_copy_shape=None,
-                      distributed=None, res_units=None, lower=None, upper=None, ref=1.0, ref0=0.0, res_ref=1.0, ):
+                      distributed=None, res_units=None, lower=None, upper=None, ref=1.0, ref0=0.0, res_ref=1.0,):
         """
         Add an input/output pair for a variable to this component.
 
diff --git a/dymos/transcriptions/explicit_shooting/explicit_shooting.py b/dymos/transcriptions/explicit_shooting/explicit_shooting.py
index 141e8165..aa244234 100644
--- a/dymos/transcriptions/explicit_shooting/explicit_shooting.py
+++ b/dymos/transcriptions/explicit_shooting/explicit_shooting.py
@@ -13,9 +13,10 @@ from .ode_integration_comp import ODEIntegrationComp
 from ..._options import options as dymos_options
 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
+from ...utils.introspection import get_promoted_vars, get_source_metadata, get_targets, get_target_metadata
 from ...utils.constants import INF_BOUND
-from ..common import TimeComp, TimeseriesOutputGroup, ControlGroup, PolynomialControlGroup
+from ..common import TimeComp, TimeseriesOutputGroup, ControlGroup, PolynomialControlGroup, \
+    ParameterComp
 
 
 class ExplicitShooting(TranscriptionBase):
@@ -152,7 +153,13 @@ class ExplicitShooting(TranscriptionBase):
         time_comp = TimeComp(num_nodes=num_nodes, node_ptau=node_ptau,
                              node_dptau_dstau=node_dptau_dstau, units=time_units)
 
-        phase.add_subsystem('time', time_comp, promotes=['*'])
+        phase.add_subsystem('param_comp', subsys=ParameterComp(time_options=time_options),
+                            promotes_inputs=['*'], promotes_outputs=['*'])
+
+        phase.add_subsystem('time', time_comp, promotes_outputs=['*'])
+
+        phase.connect('t_initial_val', ['time.t_initial', 'integrator.t_initial'])
+        phase.connect('t_duration_val', ['time.t_duration', 'integrator.t_duration'])
 
     def configure_time(self, phase):
         """
@@ -175,12 +182,6 @@ class ExplicitShooting(TranscriptionBase):
         time_comp.configure_io()
 
         ode = phase._get_subsystem('ode')
-        ode_inputs = get_promoted_vars(ode, 'input')
-
-        phase.set_input_defaults('t_initial', val=0.0)
-        phase.set_input_defaults('t_duration', val=1.0)
-
-        phase.promotes('integrator', inputs=['t_initial', 't_duration'])
 
         if not time_options['fix_initial']:
             lb, ub = time_options['initial_bounds']
@@ -219,9 +220,10 @@ class ExplicitShooting(TranscriptionBase):
         for name, targets in [('t_initial', time_options['t_initial_targets']),
                               ('t_duration', time_options['t_duration_targets'])]:
             for t in targets:
-                shape = ode_inputs[t]['shape']
-
-                if shape == (1,):
+                tgt_shape, _, static_tgt = get_target_metadata(ode, name=name,
+                                                               user_targets=t,
+                                                               user_units=time_options['units'])
+                if tgt_shape == (1,):
                     src_idxs = None
                     flat_src_idxs = None
                     src_shape = None
@@ -230,12 +232,9 @@ class ExplicitShooting(TranscriptionBase):
                     flat_src_idxs = True
                     src_shape = (1,)
 
-                phase.promotes('ode', inputs=[(t, name)], src_indices=src_idxs,
-                               flat_src_indices=flat_src_idxs, src_shape=src_shape)
-            if targets:
-                phase.set_input_defaults(name=name,
-                                         val=np.ones((1,)),
-                                         units=t_units)
+                phase.connect(f'{name}_val', f'ode.{name}',
+                              src_indices=src_idxs,
+                              flat_src_indices=flat_src_idxs)
 
     def setup_states(self, phase):
         """
@@ -564,6 +563,9 @@ class ExplicitShooting(TranscriptionBase):
                                                                control_options=phase.control_options,
                                                                time_units=phase.time_options['units']))
 
+            if rate_cont:
+                phase.connect('t_duration_val', 'continuity_comp.t_duration')
+
     def configure_defects(self, phase):
         """
         Not used in ExplicitShooting.
@@ -604,9 +606,6 @@ class ExplicitShooting(TranscriptionBase):
                                                control_rates_to_enforce=control_rates_to_enforce,
                                                control_rates2_to_enforce=control_rates2_to_enforce)
 
-        if any_rate_cnty:
-            phase.promotes('continuity_comp', inputs=['t_duration'])
-
     def setup_timeseries_outputs(self, phase):
         """
         Setup the timeseries for this transcription.
diff --git a/dymos/transcriptions/pseudospectral/pseudospectral_base.py b/dymos/transcriptions/pseudospectral/pseudospectral_base.py
index 86ed875f..cd744e9c 100644
--- a/dymos/transcriptions/pseudospectral/pseudospectral_base.py
+++ b/dymos/transcriptions/pseudospectral/pseudospectral_base.py
@@ -409,7 +409,7 @@ class PseudospectralBase(TranscriptionBase):
                               src_indices=(flattened_src_idxs,), flat_src_indices=True)
 
         if any_control_rate_cnty:
-            phase.promotes('continuity_comp', inputs=['t_duration'])
+            phase.connect('t_duration_val', 'continuity_comp.t_duration')
 
         for name, options in phase.control_options.items():
             control_src_name = f'control_values:{name}'
diff --git a/dymos/transcriptions/transcription_base.py b/dymos/transcriptions/transcription_base.py
index fb6ced99..bda17265 100644
--- a/dymos/transcriptions/transcription_base.py
+++ b/dymos/transcriptions/transcription_base.py
@@ -10,7 +10,6 @@ from ..utils.indexing import get_constraint_flat_idxs
 from ..utils.misc import _unspecified
 from ..utils.introspection import configure_states_introspection, get_promoted_vars, get_target_metadata, \
     configure_states_discovery
-from .._options import options as dymos_options
 
 
 class TranscriptionBase(object):
@@ -84,13 +83,8 @@ class TranscriptionBase(object):
         if phase.time_options['t_duration_balance_options']:
             self._implicit_duration = True
 
-        if not time_options['input_initial']:
-            phase.add_subsystem('time_extents', om.IndepVarComp(),
-                                promotes_outputs=['*'])
-        else:
-            if not time_options['input_duration'] and not self._implicit_duration:
-                phase.add_subsystem('time_extents', om.IndepVarComp(),
-                                    promotes_outputs=['*'])
+        phase.add_subsystem('param_comp', subsys=ParameterComp(time_options=time_options),
+                            promotes_inputs=['*'], promotes_outputs=['*'])
 
         for ts_name, ts_options in phase._timeseries.items():
             if t_name not in ts_options['outputs']:
@@ -120,20 +114,6 @@ class TranscriptionBase(object):
                                                                user_units=time_options['units'],
                                                                user_shape='')
 
-        time_units = time_options['units']
-        indeps = []
-        default_vals = {'t_initial': phase.time_options['initial_val'],
-                        't_duration': phase.time_options['duration_val']}
-
-        if not time_options['input_initial']:
-            indeps.append('t_initial')
-
-        if not time_options['input_duration'] and not self._implicit_duration:
-            indeps.append('t_duration')
-
-        for var in indeps:
-            phase.time_extents.add_output(var, val=default_vals[var], units=time_units)
-
         if not (time_options['input_initial'] or time_options['fix_initial']):
             lb, ub = time_options['initial_bounds']
             lb = -INF_BOUND if lb is None else lb
@@ -221,7 +201,11 @@ class TranscriptionBase(object):
                                          polynomial_control_options=phase.polynomial_control_options,
                                          time_units=phase.time_options['units'])
             phase.add_subsystem('polynomial_control_group', subsys=sys,
-                                promotes_inputs=['*'], promotes_outputs=['*'])
+                                promotes_inputs=['polynomial_controls:*'],
+                                promotes_outputs=['polynomial_control_values:*',
+                                                  'polynomial_control_rates:*'])
+
+            phase.connect('t_duration_val', 'polynomial_control_group.t_duration')
 
             prefix = 'polynomial_controls:' if phase.timeseries_options['use_prefix'] else ''
             rate_prefix = 'polynomial_control_rates:' if phase.timeseries_options['use_prefix'] else ''
@@ -265,10 +249,6 @@ class TranscriptionBase(object):
         param_prefix = 'parameters:' if phase.timeseries_options['use_prefix'] else ''
         include_params = phase.timeseries_options['include_parameters']
 
-        if phase.parameter_options:
-            param_comp = ParameterComp()
-            phase.add_subsystem('param_comp', subsys=param_comp, promotes_inputs=['*'], promotes_outputs=['*'])
-
         for name, options in phase.parameter_options.items():
             if (options['include_timeseries'] is None and include_params) or options['include_timeseries']:
                 for ts_name, ts_options in phase._timeseries.items():
diff --git a/dymos/visualization/linkage/js/DmLinkageModelData.js b/dymos/visualization/linkage/js/DmLinkageModelData.js
index adacbc57..9f52c62b 100644
--- a/dymos/visualization/linkage/js/DmLinkageModelData.js
+++ b/dymos/visualization/linkage/js/DmLinkageModelData.js
@@ -16,6 +16,8 @@ class DmLinkageModelData extends ModelData {
      * @returns {DmLinkageTreeNode} The newly-created object.
      */
     _newNode(element, attribNames, parent) {
+        console.log(element)
+        console.log(attribNames)
         return new DmLinkageTreeNode(element, attribNames, parent);
     }
 }
diff --git a/dymos/visualization/linkage/report.py b/dymos/visualization/linkage/report.py
index 18ab5564..96b19ee5 100644
--- a/dymos/visualization/linkage/report.py
+++ b/dymos/visualization/linkage/report.py
@@ -11,6 +11,25 @@ _default_linkage_report_title = 'Dymos Linkage Report'
 _default_linkage_report_filename = 'linkage_report.html'
 
 
+def _create_model_data(traj):
+    """
+    Creates the model_data dictionary for the given trajectory.
+
+    Parameters
+    ----------
+    traj : A dymos Trajectory object.
+
+    Returns
+    -------
+    dict
+        The generated model_data.
+    """
+    model_data = _trajectory_to_dict(traj)
+    model_data['connections_list'] = _linkages_to_list(traj, model_data)
+    model_data['connections_list'].extend(_parameter_connections(traj, model_data))
+    return model_data
+
+
 def create_linkage_report(traj, output_file: str = _default_linkage_report_filename,
                           show_all_vars=True,
                           title=_default_linkage_report_title, embedded=False):
@@ -30,9 +49,7 @@ def create_linkage_report(traj, output_file: str = _default_linkage_report_filen
     embedded : bool
         Whether the file is to be included in another. If True, leave out some tags.
     """
-    model_data = _trajectory_to_dict(traj)
-    model_data['connections_list'] = _linkages_to_list(traj, model_data)
-    model_data['connections_list'].extend(_parameter_connections(traj, model_data))
+    model_data = _create_model_data(traj)
 
     _convert_dicts_to_lists(model_data['tree'], show_all_vars)
 
@@ -295,7 +312,8 @@ def _conn_name_to_path(name):
 
 def _is_param_conn(name) -> bool:
     """ Determine if the specified connection involves a parameter. """
-    return re.match(r'.*param_comp.*', name) and not re.match(r'.*parameter_vals.*', name)
+    return re.match(r'.*param_comp\.parameters:.*', name) \
+        and not re.match(r'.*param_comp\.parameter_vals:.*', name)
 
 
 def _is_ignored_conn(name) -> bool:
diff --git a/setup.py b/setup.py
index 146f8a6e..7cdd5386 100644
--- a/setup.py
+++ b/setup.py
@@ -77,7 +77,8 @@ The software has two primary objectives:
     zip_safe=False,
     package_data={
         'dymos.examples.aircraft_steady_flight.aero': ['data/CRM_aero_inputs.dat', 'data/CRM_aero_outputs.dat'],
-        'dymos.visualization.linkage': ['report_template.html', 'js/*', 'style/*']
+        'dymos.visualization.linkage': ['report_template.html', 'js/*', 'style/*'],
+        'dymos.visualization.linkage.test': ['model_data.dat']
     },
     entry_points = {
         'openmdao_report': [


Test patch:
diff --git a/.github/workflows/dymos_tests_workflow.yml b/.github/workflows/dymos_tests_workflow.yml
index 34865634..4258c0d1 100644
--- a/.github/workflows/dymos_tests_workflow.yml
+++ b/.github/workflows/dymos_tests_workflow.yml
@@ -77,9 +77,9 @@ jobs:
             OPENMPI: '4.0'
             MPI4PY: '3.0'
             PETSc: 3.13
-            PYOPTSPARSE: 'v2.4.0'
+            PYOPTSPARSE: 'v2.6.1'
             SNOPT: 7.2
-            OPENMDAO: 3.19.0
+            OPENMDAO: 3.26.0
             OPTIONAL: '[test]'
 
     steps:
@@ -254,6 +254,7 @@ jobs:
         run: |
           conda info
           conda list
+          conda env export --name test --file ${{ matrix.NAME }}_environment.yml
 
           echo "============================================================="
           echo "Check installed versions of Python, Numpy and Scipy"
@@ -267,6 +268,13 @@ jobs:
           python -c "import scipy; assert str(scipy.__version__).startswith(str(${{ matrix.SCIPY }})), \
                     f'Scipy version {scipy.__version__} is not the requested version (${{ matrix.SCIPY }})'"
 
+      - name: 'Upload environment artifact'
+        uses: actions/upload-artifact@v3
+        with:
+          name: ${{ matrix.NAME }}_environment
+          path: ${{ matrix.NAME }}_environment.yml
+          retention-days: 5
+
       - name: Run tests
         if: env.RUN_BUILD
         env:
diff --git a/dymos/examples/brachistochrone/doc/test_doc_brachistochrone_tandem_phases.py b/dymos/examples/brachistochrone/doc/test_doc_brachistochrone_tandem_phases.py
index fbda5aeb..2375e38b 100644
--- a/dymos/examples/brachistochrone/doc/test_doc_brachistochrone_tandem_phases.py
+++ b/dymos/examples/brachistochrone/doc/test_doc_brachistochrone_tandem_phases.py
@@ -114,7 +114,7 @@ class TestBrachistochroneTandemPhases(unittest.TestCase):
         #
         # Connect the two phases
         #
-        p.model.connect('phase0.t_duration', 'phase1.t_duration')
+        p.model.connect('phase0.t_duration_val', 'phase1.t_duration')
 
         p.model.connect('phase0.timeseries2.theta', 'phase1.controls:theta')
         p.model.connect('phase0.timeseries2.v', 'phase1.controls:v')
diff --git a/dymos/examples/brachistochrone/test/test_tandem_phases.py b/dymos/examples/brachistochrone/test/test_tandem_phases.py
index e239a907..86ac656b 100644
--- a/dymos/examples/brachistochrone/test/test_tandem_phases.py
+++ b/dymos/examples/brachistochrone/test/test_tandem_phases.py
@@ -137,7 +137,7 @@ class TestTandemPhases(unittest.TestCase):
         #
         # Connect the two phases
         #
-        p.model.connect('phase0.t_duration', 'phase1.t_duration')
+        p.model.connect('phase0.t_duration_val', 'phase1.t_duration')
 
         p.model.connect('phase0.timeseries2.theta', 'phase1.controls:theta')
         p.model.connect('phase0.timeseries2.v', 'phase1.controls:v')
diff --git a/dymos/phase/test/test_time_targets.py b/dymos/phase/test/test_time_targets.py
index c6ef7922..5910c950 100644
--- a/dymos/phase/test/test_time_targets.py
+++ b/dymos/phase/test/test_time_targets.py
@@ -1,7 +1,6 @@
 import unittest
 
 import numpy as np
-import matplotlib
 
 import openmdao.api as om
 from openmdao.utils.assert_utils import assert_near_equal
@@ -10,9 +9,6 @@ from openmdao.utils.testing_utils import use_tempdirs
 import dymos as dm
 
 
-matplotlib.use('Agg')
-
-
 class _BrachistochroneTestODE(om.ExplicitComponent):
 
     def initialize(self):
@@ -138,10 +134,10 @@ class TestPhaseTimeTargets(unittest.TestCase):
         if input_duration:
             p.model.add_design_var('phase0.t_duration', lower=0, upper=3, scaler=1.0)
 
-        p.setup(check=True)
+        p.setup(check=True, force_alloc_complex=True)
 
         p['phase0.t_initial'] = 1.0
-        p['phase0.t_duration'] = 5.0
+        p['phase0.t_duration'] = 2.0
 
         if transcription == 'explicit-shooting':
             p['phase0.initial_states:x'] = 0
@@ -151,7 +147,7 @@ class TestPhaseTimeTargets(unittest.TestCase):
             p['phase0.states:x'] = phase.interp('x', [0, 10])
             p['phase0.states:y'] = phase.interp('y', [10, 5])
             p['phase0.states:v'] = phase.interp('v', [0, 9.9])
-        p['phase0.controls:theta'] = phase.interp('theta', [5, 100.5])
+        p['phase0.controls:theta'] = phase.interp('theta', [0.01, 100.5])
 
         return p
 
@@ -287,9 +283,9 @@ class TestPhaseTimeTargets(unittest.TestCase):
 
                 assert_near_equal(p['phase0.t_phase'][-1], 1.8016, tolerance=1.0E-3)
 
-                assert_near_equal(p['phase0.t_initial'], p['phase0.t_initial'])
+                assert_near_equal(p['phase0.t_initial'], p['phase0.integrator.t_initial'])
 
-                assert_near_equal(p['phase0.t_duration'], p['phase0.t_duration'])
+                assert_near_equal(p['phase0.t_duration'], p['phase0.integrator.t_duration'])
 
                 assert_near_equal(np.atleast_2d(p['phase0.t_phase']).T, time_phase_all)
 
diff --git a/dymos/trajectory/test/test_trajectory_parameters.py b/dymos/trajectory/test/test_trajectory_parameters.py
index 6d9084d9..48a837d4 100644
--- a/dymos/trajectory/test/test_trajectory_parameters.py
+++ b/dymos/trajectory/test/test_trajectory_parameters.py
@@ -440,8 +440,8 @@ class TestTrajectoryParametersDefinition(unittest.TestCase):
 
                 p.setup()
 
-                p['traj.phase0.t_initial'] = 0.0
-                p['traj.phase0.t_duration'] = 2.0
+                p['traj.t_initial'] = 0.0
+                p['traj.t_duration'] = 2.0
 
                 p['traj.phase0.states:x'] = phase.interp('x', ys=[0, 10])
                 p['traj.phase0.states:y'] = phase.interp('y', ys=[10, 5])
diff --git a/dymos/visualization/linkage/test/model_data.dat b/dymos/visualization/linkage/test/model_data.dat
new file mode 100644
index 00000000..993be8e9
--- /dev/null
+++ b/dymos/visualization/linkage/test/model_data.dat
@@ -0,0 +1,1 @@
+{"tree": {"name": "Trajectory", "type": "root", "children_by_name": {"params": {"name": "params", "type": "phase", "children_by_name": {"m": {"name": "m", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "T_nominal": {"name": "T_nominal", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "T_engine_out": {"name": "T_engine_out", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "T_shutdown": {"name": "T_shutdown", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "mu_r_nominal": {"name": "mu_r_nominal", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "mu_r_braking": {"name": "mu_r_braking", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "h_runway": {"name": "h_runway", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "rho": {"name": "rho", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "S": {"name": "S", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "CD0": {"name": "CD0", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "AR": {"name": "AR", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "e": {"name": "e", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "span": {"name": "span", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "h_w": {"name": "h_w", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "CL0": {"name": "CL0", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}, "CL_max": {"name": "CL_max", "type": "variable", "class": "parameter", "fixed": false, "paramOpt": false, "connected": true}}}, "br_to_v1": {"name": "br_to_v1", "type": "phase", "children_by_name": {"params": {"name": "params", "type": "condition", "children_by_name": {"alpha": {"name": "alpha", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "m": {"name": "m", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "T_nominal": {"name": "T_nominal", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "mu_r_nominal": {"name": "mu_r_nominal", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "h_runway": {"name": "h_runway", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "rho": {"name": "rho", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "S": {"name": "S", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CD0": {"name": "CD0", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "AR": {"name": "AR", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "e": {"name": "e", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "span": {"name": "span", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "h_w": {"name": "h_w", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CL0": {"name": "CL0", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CL_max": {"name": "CL_max", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}}}, "initial": {"name": "initial", "type": "condition", "children_by_name": {"time": {"name": "time", "type": "variable", "class": "t", "fixed": true, "paramOpt": null}, "r": {"name": "states:r", "type": "variable", "class": "state", "fixed": true, "paramOpt": null}, "v": {"name": "states:v", "type": "variable", "class": "state", "fixed": true, "paramOpt": null}}}, "final": {"name": "final", "type": "condition", "children_by_name": {"time": {"name": "time", "type": "variable", "class": "t", "fixed": true, "paramOpt": null, "linked": true, "connected": false}, "r": {"name": "states:r", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "v": {"name": "states:v", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}}}}}, "rto": {"name": "rto", "type": "phase", "children_by_name": {"params": {"name": "params", "type": "condition", "children_by_name": {"alpha": {"name": "alpha", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "m": {"name": "m", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "T_shutdown": {"name": "T_shutdown", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "mu_r_braking": {"name": "mu_r_braking", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "h_runway": {"name": "h_runway", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "rho": {"name": "rho", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "S": {"name": "S", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CD0": {"name": "CD0", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "AR": {"name": "AR", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "e": {"name": "e", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "span": {"name": "span", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "h_w": {"name": "h_w", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CL0": {"name": "CL0", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CL_max": {"name": "CL_max", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}}}, "initial": {"name": "initial", "type": "condition", "children_by_name": {"time": {"name": "time", "type": "variable", "class": "t", "fixed": true, "paramOpt": null, "linked": true, "connected": false}, "r": {"name": "states:r", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "v": {"name": "states:v", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}}}, "final": {"name": "final", "type": "condition", "children_by_name": {"time": {"name": "time", "type": "variable", "class": "t", "fixed": true, "paramOpt": null}, "r": {"name": "states:r", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "v": {"name": "states:v", "type": "variable", "class": "state", "fixed": false, "paramOpt": null}}}}}, "v1_to_vr": {"name": "v1_to_vr", "type": "phase", "children_by_name": {"params": {"name": "params", "type": "condition", "children_by_name": {"alpha": {"name": "alpha", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false, "linked": true, "connected": false}, "m": {"name": "m", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "T_engine_out": {"name": "T_engine_out", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "mu_r_nominal": {"name": "mu_r_nominal", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "h_runway": {"name": "h_runway", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "rho": {"name": "rho", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "S": {"name": "S", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CD0": {"name": "CD0", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "AR": {"name": "AR", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "e": {"name": "e", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "span": {"name": "span", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "h_w": {"name": "h_w", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CL0": {"name": "CL0", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CL_max": {"name": "CL_max", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}}}, "initial": {"name": "initial", "type": "condition", "children_by_name": {"time": {"name": "time", "type": "variable", "class": "t", "fixed": true, "paramOpt": null, "linked": true, "connected": false}, "r": {"name": "states:r", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "v": {"name": "states:v", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}}}, "final": {"name": "final", "type": "condition", "children_by_name": {"time": {"name": "time", "type": "variable", "class": "t", "fixed": true, "paramOpt": null, "linked": true, "connected": false}, "r": {"name": "states:r", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "v": {"name": "states:v", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}}}}}, "rotate": {"name": "rotate", "type": "phase", "children_by_name": {"params": {"name": "params", "type": "condition", "children_by_name": {"m": {"name": "m", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "T_engine_out": {"name": "T_engine_out", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "mu_r_nominal": {"name": "mu_r_nominal", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "h_runway": {"name": "h_runway", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "rho": {"name": "rho", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "S": {"name": "S", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CD0": {"name": "CD0", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "AR": {"name": "AR", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "e": {"name": "e", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "span": {"name": "span", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "h_w": {"name": "h_w", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CL0": {"name": "CL0", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CL_max": {"name": "CL_max", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}}}, "initial": {"name": "initial", "type": "condition", "children_by_name": {"time": {"name": "time", "type": "variable", "class": "t", "fixed": true, "paramOpt": null, "linked": true, "connected": false}, "r": {"name": "states:r", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "v": {"name": "states:v", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "alpha": {"name": "polynomial_controls:alpha", "type": "variable", "class": "indep_polynomial_control", "fixed": false, "paramOpt": null, "linked": true, "connected": false}}}, "final": {"name": "final", "type": "condition", "children_by_name": {"time": {"name": "time", "type": "variable", "class": "t", "fixed": true, "paramOpt": null, "linked": true, "connected": false}, "r": {"name": "states:r", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "v": {"name": "states:v", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "alpha": {"name": "polynomial_controls:alpha", "type": "variable", "class": "indep_polynomial_control", "fixed": false, "paramOpt": null, "linked": true, "connected": false}}}}}, "climb": {"name": "climb", "type": "phase", "children_by_name": {"params": {"name": "params", "type": "condition", "children_by_name": {"m": {"name": "m", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "T_engine_out": {"name": "T_engine_out", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "S": {"name": "S", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CD0": {"name": "CD0", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "AR": {"name": "AR", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "e": {"name": "e", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "span": {"name": "span", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "h_w": {"name": "h_w", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CL0": {"name": "CL0", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}, "CL_max": {"name": "CL_max", "type": "variable", "class": "parameter", "fixed": true, "paramOpt": false}}}, "initial": {"name": "initial", "type": "condition", "children_by_name": {"time": {"name": "time", "type": "variable", "class": "t", "fixed": true, "paramOpt": null, "linked": true, "connected": false}, "r": {"name": "states:r", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "h": {"name": "states:h", "type": "variable", "class": "state", "fixed": true, "paramOpt": null}, "v": {"name": "states:v", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "gam": {"name": "states:gam", "type": "variable", "class": "state", "fixed": true, "paramOpt": null}, "alpha": {"name": "controls:alpha", "type": "variable", "class": "indep_control", "fixed": false, "paramOpt": null, "linked": true, "connected": false}}}, "final": {"name": "final", "type": "condition", "children_by_name": {"time": {"name": "time", "type": "variable", "class": "t", "fixed": true, "paramOpt": null}, "r": {"name": "states:r", "type": "variable", "class": "state", "fixed": false, "paramOpt": null, "linked": true, "connected": false}, "h": {"name": "states:h", "type": "variable", "class": "state", "fixed": false, "paramOpt": null}, "v": {"name": "states:v", "type": "variable", "class": "state", "fixed": false, "paramOpt": null}, "gam": {"name": "states:gam", "type": "variable", "class": "state", "fixed": false, "paramOpt": null}, "alpha": {"name": "controls:alpha", "type": "variable", "class": "indep_control", "fixed": false, "paramOpt": null}}}}}}}, "connections_list": [{"src": "br_to_v1.final.time", "src_fixed": true, "tgt": "v1_to_vr.initial.time", "tgt_fixed": true}, {"src": "br_to_v1.final.states:r", "src_fixed": false, "tgt": "v1_to_vr.initial.states:r", "tgt_fixed": false}, {"src": "br_to_v1.final.states:v", "src_fixed": false, "tgt": "v1_to_vr.initial.states:v", "tgt_fixed": false}, {"src": "v1_to_vr.final.time", "src_fixed": true, "tgt": "rotate.initial.time", "tgt_fixed": true}, {"src": "v1_to_vr.final.states:r", "src_fixed": false, "tgt": "rotate.initial.states:r", "tgt_fixed": false}, {"src": "v1_to_vr.final.states:v", "src_fixed": false, "tgt": "rotate.initial.states:v", "tgt_fixed": false}, {"src": "v1_to_vr.params.alpha", "src_fixed": true, "tgt": "rotate.initial.polynomial_controls:alpha", "tgt_fixed": false}, {"src": "rotate.final.time", "src_fixed": true, "tgt": "climb.initial.time", "tgt_fixed": true}, {"src": "rotate.final.states:r", "src_fixed": false, "tgt": "climb.initial.states:r", "tgt_fixed": false}, {"src": "rotate.final.states:v", "src_fixed": false, "tgt": "climb.initial.states:v", "tgt_fixed": false}, {"src": "rotate.final.polynomial_controls:alpha", "src_fixed": false, "tgt": "climb.initial.controls:alpha", "tgt_fixed": false}, {"src": "br_to_v1.final.time", "src_fixed": true, "tgt": "rto.initial.time", "tgt_fixed": true}, {"src": "br_to_v1.final.states:r", "src_fixed": false, "tgt": "rto.initial.states:r", "tgt_fixed": false}, {"src": "br_to_v1.final.states:v", "src_fixed": false, "tgt": "rto.initial.states:v", "tgt_fixed": false}, {"src": "rto.final.states:r", "src_fixed": false, "tgt": "climb.final.states:r", "tgt_fixed": false}, {"src": "params.m", "src_fixed": false, "tgt": "br_to_v1.params.m", "tgt_fixed": true}, {"src": "params.m", "src_fixed": false, "tgt": "rto.params.m", "tgt_fixed": true}, {"src": "params.m", "src_fixed": false, "tgt": "v1_to_vr.params.m", "tgt_fixed": true}, {"src": "params.m", "src_fixed": false, "tgt": "rotate.params.m", "tgt_fixed": true}, {"src": "params.m", "src_fixed": false, "tgt": "climb.params.m", "tgt_fixed": true}, {"src": "params.T_nominal", "src_fixed": false, "tgt": "br_to_v1.params.T_nominal", "tgt_fixed": true}, {"src": "params.T_engine_out", "src_fixed": false, "tgt": "v1_to_vr.params.T_engine_out", "tgt_fixed": true}, {"src": "params.T_engine_out", "src_fixed": false, "tgt": "rotate.params.T_engine_out", "tgt_fixed": true}, {"src": "params.T_engine_out", "src_fixed": false, "tgt": "climb.params.T_engine_out", "tgt_fixed": true}, {"src": "params.T_shutdown", "src_fixed": false, "tgt": "rto.params.T_shutdown", "tgt_fixed": true}, {"src": "params.mu_r_nominal", "src_fixed": false, "tgt": "br_to_v1.params.mu_r_nominal", "tgt_fixed": true}, {"src": "params.mu_r_nominal", "src_fixed": false, "tgt": "v1_to_vr.params.mu_r_nominal", "tgt_fixed": true}, {"src": "params.mu_r_nominal", "src_fixed": false, "tgt": "rotate.params.mu_r_nominal", "tgt_fixed": true}, {"src": "params.mu_r_braking", "src_fixed": false, "tgt": "rto.params.mu_r_braking", "tgt_fixed": true}, {"src": "params.h_runway", "src_fixed": false, "tgt": "br_to_v1.params.h_runway", "tgt_fixed": true}, {"src": "params.h_runway", "src_fixed": false, "tgt": "rto.params.h_runway", "tgt_fixed": true}, {"src": "params.h_runway", "src_fixed": false, "tgt": "v1_to_vr.params.h_runway", "tgt_fixed": true}, {"src": "params.h_runway", "src_fixed": false, "tgt": "rotate.params.h_runway", "tgt_fixed": true}, {"src": "params.rho", "src_fixed": false, "tgt": "br_to_v1.params.rho", "tgt_fixed": true}, {"src": "params.rho", "src_fixed": false, "tgt": "rto.params.rho", "tgt_fixed": true}, {"src": "params.rho", "src_fixed": false, "tgt": "v1_to_vr.params.rho", "tgt_fixed": true}, {"src": "params.rho", "src_fixed": false, "tgt": "rotate.params.rho", "tgt_fixed": true}, {"src": "params.S", "src_fixed": false, "tgt": "br_to_v1.params.S", "tgt_fixed": true}, {"src": "params.S", "src_fixed": false, "tgt": "rto.params.S", "tgt_fixed": true}, {"src": "params.S", "src_fixed": false, "tgt": "v1_to_vr.params.S", "tgt_fixed": true}, {"src": "params.S", "src_fixed": false, "tgt": "rotate.params.S", "tgt_fixed": true}, {"src": "params.S", "src_fixed": false, "tgt": "climb.params.S", "tgt_fixed": true}, {"src": "params.CD0", "src_fixed": false, "tgt": "br_to_v1.params.CD0", "tgt_fixed": true}, {"src": "params.CD0", "src_fixed": false, "tgt": "rto.params.CD0", "tgt_fixed": true}, {"src": "params.CD0", "src_fixed": false, "tgt": "v1_to_vr.params.CD0", "tgt_fixed": true}, {"src": "params.CD0", "src_fixed": false, "tgt": "rotate.params.CD0", "tgt_fixed": true}, {"src": "params.CD0", "src_fixed": false, "tgt": "climb.params.CD0", "tgt_fixed": true}, {"src": "params.AR", "src_fixed": false, "tgt": "br_to_v1.params.AR", "tgt_fixed": true}, {"src": "params.AR", "src_fixed": false, "tgt": "rto.params.AR", "tgt_fixed": true}, {"src": "params.AR", "src_fixed": false, "tgt": "v1_to_vr.params.AR", "tgt_fixed": true}, {"src": "params.AR", "src_fixed": false, "tgt": "rotate.params.AR", "tgt_fixed": true}, {"src": "params.AR", "src_fixed": false, "tgt": "climb.params.AR", "tgt_fixed": true}, {"src": "params.e", "src_fixed": false, "tgt": "br_to_v1.params.e", "tgt_fixed": true}, {"src": "params.e", "src_fixed": false, "tgt": "rto.params.e", "tgt_fixed": true}, {"src": "params.e", "src_fixed": false, "tgt": "v1_to_vr.params.e", "tgt_fixed": true}, {"src": "params.e", "src_fixed": false, "tgt": "rotate.params.e", "tgt_fixed": true}, {"src": "params.e", "src_fixed": false, "tgt": "climb.params.e", "tgt_fixed": true}, {"src": "params.span", "src_fixed": false, "tgt": "br_to_v1.params.span", "tgt_fixed": true}, {"src": "params.span", "src_fixed": false, "tgt": "rto.params.span", "tgt_fixed": true}, {"src": "params.span", "src_fixed": false, "tgt": "v1_to_vr.params.span", "tgt_fixed": true}, {"src": "params.span", "src_fixed": false, "tgt": "rotate.params.span", "tgt_fixed": true}, {"src": "params.span", "src_fixed": false, "tgt": "climb.params.span", "tgt_fixed": true}, {"src": "params.h_w", "src_fixed": false, "tgt": "br_to_v1.params.h_w", "tgt_fixed": true}, {"src": "params.h_w", "src_fixed": false, "tgt": "rto.params.h_w", "tgt_fixed": true}, {"src": "params.h_w", "src_fixed": false, "tgt": "v1_to_vr.params.h_w", "tgt_fixed": true}, {"src": "params.h_w", "src_fixed": false, "tgt": "rotate.params.h_w", "tgt_fixed": true}, {"src": "params.h_w", "src_fixed": false, "tgt": "climb.params.h_w", "tgt_fixed": true}, {"src": "params.CL0", "src_fixed": false, "tgt": "br_to_v1.params.CL0", "tgt_fixed": true}, {"src": "params.CL0", "src_fixed": false, "tgt": "rto.params.CL0", "tgt_fixed": true}, {"src": "params.CL0", "src_fixed": false, "tgt": "v1_to_vr.params.CL0", "tgt_fixed": true}, {"src": "params.CL0", "src_fixed": false, "tgt": "rotate.params.CL0", "tgt_fixed": true}, {"src": "params.CL0", "src_fixed": false, "tgt": "climb.params.CL0", "tgt_fixed": true}, {"src": "params.CL_max", "src_fixed": false, "tgt": "br_to_v1.params.CL_max", "tgt_fixed": true}, {"src": "params.CL_max", "src_fixed": false, "tgt": "rto.params.CL_max", "tgt_fixed": true}, {"src": "params.CL_max", "src_fixed": false, "tgt": "v1_to_vr.params.CL_max", "tgt_fixed": true}, {"src": "params.CL_max", "src_fixed": false, "tgt": "rotate.params.CL_max", "tgt_fixed": true}, {"src": "params.CL_max", "src_fixed": false, "tgt": "climb.params.CL_max", "tgt_fixed": true}]}
\ No newline at end of file
diff --git a/dymos/visualization/linkage/test/test_linkage_report.py b/dymos/visualization/linkage/test/test_linkage_report.py
new file mode 100644
index 00000000..0f01b0bc
--- /dev/null
+++ b/dymos/visualization/linkage/test/test_linkage_report.py
@@ -0,0 +1,256 @@
+"""Test Dymos Linkage report GUI with using Playwright."""
+import json
+import pathlib
+import unittest
+
+import openmdao.api as om
+from openmdao.utils.testing_utils import use_tempdirs, require_pyoptsparse
+import dymos as dm
+from dymos.examples.balanced_field.balanced_field_ode import BalancedFieldODEComp
+
+from dymos.visualization.linkage.report import _create_model_data
+
+
+def _flatten_dict(d, parent_key='', sep='.'):
+    items = []
+    for k, v in d.items():
+        new_key = parent_key + sep + k if parent_key else k
+        if isinstance(v, dict):
+            items.extend(_flatten_dict(v, new_key, sep=sep).items())
+        else:
+            items.append((new_key, v))
+    return dict(items)
+
+
+@use_tempdirs
+class TestLinkageReport(unittest.TestCase):
+
+    @require_pyoptsparse(optimizer='IPOPT')
+    def test_model_data(self):
+        """
+        Test that model data for the linkage report contains the expected data.
+        """
+        p = om.Problem()
+
+        p.driver = om.pyOptSparseDriver()
+        p.driver.options['optimizer'] = 'IPOPT'
+        p.driver.opt_settings['print_level'] = 0
+        p.driver.opt_settings['derivative_test'] = 'first-order'
+
+        p.driver.declare_coloring()
+
+        # First Phase: Brake release to V1 - both engines operable
+        br_to_v1 = dm.Phase(ode_class=BalancedFieldODEComp, transcription=dm.Radau(num_segments=3),
+                            ode_init_kwargs={'mode': 'runway'})
+        br_to_v1.set_time_options(fix_initial=True, duration_bounds=(1, 1000), duration_ref=10.0)
+        br_to_v1.add_state('r', fix_initial=True, lower=0, ref=1000.0, defect_ref=1000.0)
+        br_to_v1.add_state('v', fix_initial=True, lower=0.0001, ref=100.0, defect_ref=100.0)
+        br_to_v1.add_parameter('alpha', val=0.0, opt=False, units='deg')
+        br_to_v1.add_timeseries_output('*')
+
+        # Second Phase: Rejected takeoff at V1 - no engines operable
+        rto = dm.Phase(ode_class=BalancedFieldODEComp, transcription=dm.Radau(num_segments=3),
+                       ode_init_kwargs={'mode': 'runway'})
+        rto.set_time_options(fix_initial=False, duration_bounds=(1, 1000), duration_ref=1.0)
+        rto.add_state('r', fix_initial=False, lower=0, ref=1000.0, defect_ref=1000.0)
+        rto.add_state('v', fix_initial=False, lower=0.0001, ref=100.0, defect_ref=100.0)
+        rto.add_parameter('alpha', val=0.0, opt=False, units='deg')
+        rto.add_timeseries_output('*')
+
+        # Third Phase: V1 to Vr - single engine operable
+        v1_to_vr = dm.Phase(ode_class=BalancedFieldODEComp, transcription=dm.Radau(num_segments=3),
+                            ode_init_kwargs={'mode': 'runway'})
+        v1_to_vr.set_time_options(fix_initial=False, duration_bounds=(1, 1000), duration_ref=1.0)
+        v1_to_vr.add_state('r', fix_initial=False, lower=0, ref=1000.0, defect_ref=1000.0)
+        v1_to_vr.add_state('v', fix_initial=False, lower=0.0001, ref=100.0, defect_ref=100.0)
+        v1_to_vr.add_parameter('alpha', val=0.0, opt=False, units='deg')
+        v1_to_vr.add_timeseries_output('*')
+
+        # Fourth Phase: Rotate - single engine operable
+        rotate = dm.Phase(ode_class=BalancedFieldODEComp, transcription=dm.Radau(num_segments=3),
+                          ode_init_kwargs={'mode': 'runway'})
+        rotate.set_time_options(fix_initial=False, duration_bounds=(1.0, 5), duration_ref=1.0)
+        rotate.add_state('r', fix_initial=False, lower=0, ref=1000.0, defect_ref=1000.0)
+        rotate.add_state('v', fix_initial=False, lower=0.0001, ref=100.0, defect_ref=100.0)
+        rotate.add_polynomial_control('alpha', order=1, opt=True, units='deg', lower=0, upper=10, ref=10, val=[0, 10])
+        rotate.add_timeseries_output('*')
+
+        # Fifth Phase: Climb to target speed and altitude at end of runway.
+        climb = dm.Phase(ode_class=BalancedFieldODEComp, transcription=dm.Radau(num_segments=5),
+                         ode_init_kwargs={'mode': 'climb'})
+        climb.set_time_options(fix_initial=False, duration_bounds=(1, 100), duration_ref=1.0)
+        climb.add_state('r', fix_initial=False, lower=0, ref=1000.0, defect_ref=1000.0)
+        climb.add_state('h', fix_initial=True, lower=0.0, ref=1.0, defect_ref=1.0)
+        climb.add_state('v', fix_initial=False, lower=0.0001, ref=100.0, defect_ref=100.0)
+        climb.add_state('gam', fix_initial=True, lower=0.0, ref=0.05, defect_ref=0.05)
+        climb.add_control('alpha', opt=True, units='deg', lower=-10, upper=15, ref=10)
+        climb.add_timeseries_output('*')
+
+        # Instantiate the trajectory and add phases
+        traj = dm.Trajectory()
+        p.model.add_subsystem('traj', traj)
+        traj.add_phase('br_to_v1', br_to_v1)
+        traj.add_phase('rto', rto)
+        traj.add_phase('v1_to_vr', v1_to_vr)
+        traj.add_phase('rotate', rotate)
+        traj.add_phase('climb', climb)
+
+        # Add parameters common to multiple phases to the trajectory
+        traj.add_parameter('m', val=174200., opt=False, units='lbm',
+                           desc='aircraft mass',
+                           targets={'br_to_v1': ['m'], 'v1_to_vr': ['m'], 'rto': ['m'],
+                                    'rotate': ['m'], 'climb': ['m']})
+
+        traj.add_parameter('T_nominal', val=27000 * 2, opt=False, units='lbf', static_target=True,
+                           desc='nominal aircraft thrust',
+                           targets={'br_to_v1': ['T']})
+
+        traj.add_parameter('T_engine_out', val=27000, opt=False, units='lbf', static_target=True,
+                           desc='thrust under a single engine',
+                           targets={'v1_to_vr': ['T'], 'rotate': ['T'], 'climb': ['T']})
+
+        traj.add_parameter('T_shutdown', val=0.0, opt=False, units='lbf', static_target=True,
+                           desc='thrust when engines are shut down for rejected takeoff',
+                           targets={'rto': ['T']})
+
+        traj.add_parameter('mu_r_nominal', val=0.03, opt=False, units=None, static_target=True,
+                           desc='nominal runway friction coeffcient',
+                           targets={'br_to_v1': ['mu_r'], 'v1_to_vr': ['mu_r'], 'rotate': ['mu_r']})
+
+        traj.add_parameter('mu_r_braking', val=0.3, opt=False, units=None, static_target=True,
+                           desc='runway friction coefficient under braking',
+                           targets={'rto': ['mu_r']})
+
+        traj.add_parameter('h_runway', val=0., opt=False, units='ft', static_target=False,
+                           desc='runway altitude',
+                           targets={'br_to_v1': ['h'], 'v1_to_vr': ['h'], 'rto': ['h'],
+                                    'rotate': ['h']})
+
+        traj.add_parameter('rho', val=1.225, opt=False, units='kg/m**3', static_target=True,
+                           desc='atmospheric density',
+                           targets={'br_to_v1': ['rho'], 'v1_to_vr': ['rho'], 'rto': ['rho'],
+                                    'rotate': ['rho']})
+
+        traj.add_parameter('S', val=124.7, opt=False, units='m**2', static_target=True,
+                           desc='aerodynamic reference area',
+                           targets={'br_to_v1': ['S'], 'v1_to_vr': ['S'], 'rto': ['S'],
+                                    'rotate': ['S'], 'climb': ['S']})
+
+        traj.add_parameter('CD0', val=0.03, opt=False, units=None, static_target=True,
+                           desc='zero-lift drag coefficient',
+                           targets={f'{phase}': ['CD0'] for phase in ['br_to_v1', 'v1_to_vr',
+                                                                      'rto', 'rotate', 'climb']})
+
+        traj.add_parameter('AR', val=9.45, opt=False, units=None, static_target=True,
+                           desc='wing aspect ratio',
+                           targets={f'{phase}': ['AR'] for phase in ['br_to_v1', 'v1_to_vr',
+                                                                     'rto', 'rotate', 'climb']})
+
+        traj.add_parameter('e', val=801, opt=False, units=None, static_target=True,
+                           desc='Oswald span efficiency factor',
+                           targets={f'{phase}': ['e'] for phase in ['br_to_v1', 'v1_to_vr',
+                                                                    'rto', 'rotate', 'climb']})
+
+        traj.add_parameter('span', val=35.7, opt=False, units='m', static_target=True,
+                           desc='wingspan',
+                           targets={f'{phase}': ['span'] for phase in ['br_to_v1', 'v1_to_vr',
+                                                                       'rto', 'rotate', 'climb']})
+
+        traj.add_parameter('h_w', val=1.0, opt=False, units='m', static_target=True,
+                           desc='height of wing above CG',
+                           targets={f'{phase}': ['h_w'] for phase in ['br_to_v1', 'v1_to_vr',
+                                                                      'rto', 'rotate', 'climb']})
+
+        traj.add_parameter('CL0', val=0.5, opt=False, units=None, static_target=True,
+                           desc='zero-alpha lift coefficient',
+                           targets={f'{phase}': ['CL0'] for phase in ['br_to_v1', 'v1_to_vr',
+                                                                      'rto', 'rotate', 'climb']})
+
+        traj.add_parameter('CL_max', val=2.0, opt=False, units=None, static_target=True,
+                           desc='maximum lift coefficient for linear fit',
+                           targets={f'{phase}': ['CL_max'] for phase in ['br_to_v1', 'v1_to_vr',
+                                                                         'rto', 'rotate', 'climb']})
+
+        # Standard "end of first phase to beginning of second phase" linkages
+        traj.link_phases(['br_to_v1', 'v1_to_vr'], vars=['time', 'r', 'v'])
+        traj.link_phases(['v1_to_vr', 'rotate'], vars=['time', 'r', 'v', 'alpha'])
+        traj.link_phases(['rotate', 'climb'], vars=['time', 'r', 'v', 'alpha'])
+        traj.link_phases(['br_to_v1', 'rto'], vars=['time', 'r', 'v'])
+
+        # Less common "final value of r must be the match at ends of two phases".
+        traj.add_linkage_constraint(phase_a='rto', var_a='r', loc_a='final',
+                                    phase_b='climb', var_b='r', loc_b='final',
+                                    ref=1000,
+                                    connected=False)
+
+        # Define the constraints and objective for the optimal control problem
+        rto.add_boundary_constraint('v', loc='final', upper=0.001, ref=100, linear=True)
+
+        rotate.add_boundary_constraint('F_r', loc='final', equals=0, ref=100000)
+
+        climb.add_boundary_constraint('h', loc='final', equals=35, ref=35, units='ft', linear=True)
+        climb.add_boundary_constraint('gam', loc='final', equals=5, ref=5, units='deg', linear=True)
+        climb.add_path_constraint('gam', lower=0, upper=5, ref=5, units='deg')
+        climb.add_boundary_constraint('v_over_v_stall', loc='final', lower=1.2, ref=1.2)
+
+        rto.add_objective('r', loc='final', ref=1000.0)
+
+        #
+        # Setup the problem and set the initial guess
+        #
+        p.setup(check=True)
+
+        p.set_val('traj.br_to_v1.t_initial', 0)
+        p.set_val('traj.br_to_v1.t_duration', 35)
+        p.set_val('traj.br_to_v1.states:r', br_to_v1.interp('r', [0, 2500.0]))
+        p.set_val('traj.br_to_v1.states:v', br_to_v1.interp('v', [0.0001, 100.0]))
+        p.set_val('traj.br_to_v1.parameters:alpha', 0, units='deg')
+
+        p.set_val('traj.v1_to_vr.t_initial', 35)
+        p.set_val('traj.v1_to_vr.t_duration', 35)
+        p.set_val('traj.v1_to_vr.states:r', v1_to_vr.interp('r', [2500, 300.0]))
+        p.set_val('traj.v1_to_vr.states:v', v1_to_vr.interp('v', [100, 110.0]))
+        p.set_val('traj.v1_to_vr.parameters:alpha', 0.0, units='deg')
+
+        p.set_val('traj.rto.t_initial', 35)
+        p.set_val('traj.rto.t_duration', 1)
+        p.set_val('traj.rto.states:r', rto.interp('r', [2500, 5000.0]))
+        p.set_val('traj.rto.states:v', rto.interp('v', [110, 0.0001]))
+        p.set_val('traj.rto.parameters:alpha', 0.0, units='deg')
+
+        p.set_val('traj.rotate.t_initial', 35)
+        p.set_val('traj.rotate.t_duration', 5)
+        p.set_val('traj.rotate.states:r', rotate.interp('r', [1750, 1800.0]))
+        p.set_val('traj.rotate.states:v', rotate.interp('v', [80, 85.0]))
+        p.set_val('traj.rotate.polynomial_controls:alpha', 0.0, units='deg')
+
+        p.set_val('traj.climb.t_initial', 30)
+        p.set_val('traj.climb.t_duration', 20)
+        p.set_val('traj.climb.states:r', climb.interp('r', [5000, 5500.0]), units='ft')
+        p.set_val('traj.climb.states:v', climb.interp('v', [160, 170.0]), units='kn')
+        p.set_val('traj.climb.states:h', climb.interp('h', [0, 35.0]), units='ft')
+        p.set_val('traj.climb.states:gam', climb.interp('gam', [0, 5.0]), units='deg')
+        p.set_val('traj.climb.controls:alpha', 5.0, units='deg')
+
+        p.run_model()
+
+        model_data = _create_model_data(traj)
+
+        test_dir = pathlib.Path(__file__).parents[0]
+
+        # Uncomment to regenerate baseline data
+        # with open(test_dir / 'model_data.dat', 'w') as f:
+        #     json.dump(model_data, f)
+
+        with open(test_dir / 'model_data.dat', 'r') as f:
+            reference_data = json.load(f)
+
+        flat_model_data = _flatten_dict(model_data)
+        flat_ref_data = _flatten_dict(reference_data)
+
+        self.maxDiff = None
+        self.assertDictEqual(flat_model_data, flat_ref_data)
+
+
+if __name__ == "__main__":
+    unittest.main()