Repository: OpenMDAO/dymos (python)

Issue:
Timeseries report fails under MPI in some situations.
### Description

The timeseries report performs a gather when running under MPI to collection options from phases on other procs.
However, it is gathering ALL options and when users add their own non-picklable options to phases, this causes an exception.

The fix in this case is to only gather the necessary options (parameter_options, time_options, state_options, and control_options).

### Example

N/A

### Dymos Version

1.12.1-dev

### Relevant environment information

_No response_

Pull request description:
Fixed bug in timeseries plots under MPI.
### Summary

The timeseries plots currently gather all phase options and trajectory options onto the root pro non-picklable options have been added to phases and trajectories.

This PR addresses that by limiting the MPI gather to only the variable-related options upon which the timeseries plots depend.

### Related Issues

- Resolves #1115

### Backwards incompatibilities

None

### New Dependencies

None


Patch:
diff --git a/dymos/phase/phase.py b/dymos/phase/phase.py
index 6215db7b..e0abbbae 100644
--- a/dymos/phase/phase.py
+++ b/dymos/phase/phase.py
@@ -2962,9 +2962,10 @@ class Phase(om.Group):
             duplicate_idxs = all_flat_idxs.intersection(flat_idxs)
             if duplicate_idxs:
                 s = {'initial': 'initial boundary', 'final': 'final boundary', 'path': 'path'}
-                raise ValueError(f'Duplicate constraint in phase {self.pathname}. '
-                                 f'The following indices of `{name}` are used in '
-                                 f'multiple {s[loc]} constraints:\n{duplicate_idxs}')
+                with np.printoptions(legacy='1.13'):
+                    raise ValueError(f'Duplicate constraint in phase {self.pathname}. '
+                                     f'The following indices of `{name}` are used in '
+                                     f'multiple {s[loc]} constraints:\n{duplicate_idxs}')
 
             all_flat_idxs.update(flat_idxs)
 
diff --git a/dymos/visualization/timeseries/bokeh_timeseries_report.py b/dymos/visualization/timeseries/bokeh_timeseries_report.py
index 7e43d72a..28230509 100644
--- a/dymos/visualization/timeseries/bokeh_timeseries_report.py
+++ b/dymos/visualization/timeseries/bokeh_timeseries_report.py
@@ -149,8 +149,12 @@ def _get_traj_and_phases_from_problem(problem, rank: int = 0):
         a subdictionary of phases and their associated options. Under MPI, this dictionary
     """
     comm_rank = 0 if MPI is None else MPI.COMM_WORLD.rank
-    traj_options = _gather_system_options(problem.model, sys_cls=Trajectory, rank=rank)
-    phase_options = _gather_system_options(problem.model, sys_cls=Phase, rank=rank)
+    traj_options = _gather_system_options(problem.model, ['parameter_options'],
+                                          sys_cls=Trajectory, rank=rank)
+    phase_options = _gather_system_options(problem.model,
+                                           ['time_options', 'parameter_options',
+                                            'state_options', 'control_options'],
+                                           sys_cls=Phase, rank=rank)
 
     trajs = {}
 
@@ -344,13 +348,16 @@ def _load_data_sources(traj_and_phase_meta=None, solution_record_file=None, simu
     return data_dict
 
 
-def _gather_system_options(model, sys_cls=None, rank=0):
+def _gather_system_options(model, options, sys_cls=None, rank=0,):
     """Retreive system options for systems of the given class and/or pathname.
 
     Parameters
     ----------
     model : System
         The root system from which model options are being gathered.
+    options : Sequence of str
+        The names of the options to be gathered from systems.
+        Should be one of Trajectory or Phase.
     sys_cls : class, optional
         The class of system for which we want to retrieve options, or None if we
         should look for all systems regardless of class.
@@ -363,7 +370,8 @@ def _gather_system_options(model, sys_cls=None, rank=0):
 
     system_options = {}
     for subsys in model.system_iter(include_self=True, recurse=True, typ=sys_cls):
-        system_options[subsys.pathname] = subsys.options
+        system_options[subsys.pathname] = {k: v['val'] for k, v in subsys.options._dict.items()
+                                           if k in options}
 
     if comm_size > 1:
         gathered = MPI.COMM_WORLD.gather(system_options, rank)


Test patch:
diff --git a/.github/workflows/dymos_tests_workflow.yml b/.github/workflows/dymos_tests_workflow.yml
index 7b5b0f9f..3a1b9cbd 100644
--- a/.github/workflows/dymos_tests_workflow.yml
+++ b/.github/workflows/dymos_tests_workflow.yml
@@ -36,6 +36,12 @@ on:
         required: false
         default: false
 
+      numpy2_no_pyoptsparse:
+        type: boolean
+        description: "Include 'numpy2_no_pyoptsparse' in test matrix"
+        required: false
+        default: false
+
       no_snopt:
         type: boolean
         description: "Include 'no_snopt' in test matrix"
@@ -97,6 +103,15 @@ jobs:
             OPTIONAL: '[test]'
             EXCLUDE: ${{ github.event_name == 'workflow_dispatch'  && ! inputs.no_pyoptsparse }}
 
+          # numpy 2.x but no PETSc, pyoptsparse or SNOPT
+          - NAME: numpy2_no_pyoptsparse
+            PY: '3.12'
+            NUMPY: 2
+            SCIPY: 1.14
+            OPENMDAO: 'latest'
+            OPTIONAL: '[all]'
+            EXCLUDE: ${{ github.event_name == 'workflow_dispatch'  && ! inputs.numpy2_no_pyoptsparse }}
+
           # baseline versions except with pyoptsparse but no SNOPT
           - NAME: no_snopt
             PY: '3.12'