Optimization failing when trying to enforce periodicity in Dymos
14:06 25 Feb 2026

I am trying to model a simplified wave energy converter with optimal control using Dymos. The linear model of the wave energy converter is characterized by mass, damping, and stiffness terms. There is also a sine-wave excitation term. The control term is an arbitrary force at each timestep and the objective is to minimize the energy (maximize energy harvested) which is the product of control force and velocity. The system itself should be periodic and if I fix the initial and final values of x and v, it optimizes successfully. However, if I try to enforce periodicity using traj.link_phases() from the racecar lap example, I get optimization failed. I'd like to enforce periodicity because I don't necessarily know the initial x and v and it should have a specific phase offset based on the parameters and excitation. Is there anything I'm missing in terms of enforcing periodicity? Or something that is over-constraining this problem?

import numpy as np
import openmdao.api as om
import dymos as dm
import matplotlib.pyplot as plt
class OscillatorODE(om.ExplicitComponent):
    """
    A Dymos ODE for a damped harmonic oscillator with wave excitation and optimal 
    controls.
    """

    def initialize(self):
        self.options.declare('num_nodes', types=int)

    def setup(self):
        nn = self.options['num_nodes']
        ar = np.arange(nn)

        # Inputs
        self.add_input('x', shape=(nn,), desc='displacement', units='m')
        self.add_input('v', shape=(nn,), desc='velocity', units='m/s')
        self.add_input('k', desc='spring constant', units='N/m')
        self.add_input('c', desc='damping coefficient', units='N*s/m')
        self.add_input('m', desc='mass', units='kg') 
        self.add_input('exc_amp', desc='exc force amplitude', units='N')  
        self.add_input('time',shape=(nn,),desc='time',units='s') # New input for time
        self.add_input("u", shape=(nn,), units="N", desc="control force applied") # control force

        # frequency inputs
        self.add_input('wave_freq', desc='fundamental frequency', units='Hz') 
        #self.add_input('phi', desc='excitation phase offset', units='rad') # add phase offset to optimize

        self.add_output('v_dot', val=np.zeros(nn), desc='rate of change of velocity', units='m/s**2')
        self.add_output("power", shape=(nn,), units="W", desc="power to be integrated")
        self.add_output("x_dot", shape=(nn,), desc="rate of change of displacement", units="m/s") # for integration

        # v_dot wrt vector inputs (diagonal)
        self.declare_partials('v_dot', 'x', rows=ar, cols=ar)
        self.declare_partials('v_dot', 'v', rows=ar, cols=ar)
        self.declare_partials('v_dot', 'u', rows=ar, cols=ar)
        self.declare_partials('v_dot', 'time', rows=ar, cols=ar)

        # power wrt vector inputs (diagonal)
        self.declare_partials('power', 'v', rows=ar, cols=ar)
        self.declare_partials('power', 'u', rows=ar, cols=ar)

        # v_dot wrt scalar-ish inputs (dense (nn x 1) column)
        self.declare_partials('v_dot', 'k')
        self.declare_partials('v_dot', 'c')
        self.declare_partials('v_dot', 'm')
        self.declare_partials('v_dot', 'exc_amp')
        self.declare_partials('v_dot', 'wave_freq')

        self.declare_partials('x_dot', 'v', rows=ar, cols=ar)

        #self.declare_partials('v_dot', 'phi')

    def compute(self, inputs, outputs):
        x = inputs['x']
        v = inputs['v']
        k = inputs['k']
        c = inputs['c']
        m = inputs['m']
        exc_amp = inputs['exc_amp']
        wave_freq = inputs['wave_freq']
        t = inputs['time']
        u = inputs['u']

        f_spring = -k * x
        f_damper = -c * v
        f_control = u

        #phi = inputs['phi']
        w = 2*np.pi*wave_freq
        f_ext = exc_amp*np.cos(w*t)   # or sin, as you intend
        
        #outputs['f_dot'] = -wave_freq*2*np.pi*exc_amp*np.sin(wave_freq*2*np.pi*t) # force is steadily increasing
        outputs['v_dot'] = (f_spring + f_damper + f_ext + f_control) / m # this is the residual
        outputs['power'] = f_control*v # power is control force*velocity
        outputs['x_dot'] = v # for integration
        #outputs['t_check'] = 1
 
    def compute_partials(self, inputs, partials):
        nn = self.options['num_nodes']

        x = inputs['x']
        v = inputs['v']
        k = inputs['k']
        c = inputs['c']
        m = inputs['m']
        exc_amp = inputs['exc_amp']
        wave_freq = inputs['wave_freq']
        t = inputs['time']
        u = inputs['u']

        partials['v_dot', 'x'] = (-k / m) * np.ones(nn)
        partials['v_dot', 'v'] = (-c / m) * np.ones(nn)
        partials['v_dot', 'k'] = -x / m
        partials['v_dot', 'c'] = -v / m
        
        #phi = inputs['phi']
        w = 2*np.pi*wave_freq
        partials['v_dot', 'm'] = -( -k * x - c * v + exc_amp*np.cos(w*t) + u ) / m**2
        partials['v_dot', 'exc_amp'] = np.cos(w*t) / m
        partials['v_dot', 'wave_freq'] = -exc_amp*2*np.pi*t*np.sin(w*t) / m
        partials['v_dot', 'time'] = -exc_amp*w*np.sin(w*t) / m
        partials['v_dot', 'u'] = (1 / m) * np.ones(nn)
        
        partials['power', 'v'] = u
        partials['power', 'u'] = v

        partials['x_dot', 'v'] = np.ones(nn)

wavefreq = 0.1 # Hz
f1 = wavefreq
nfreq = 10
t_vec = np.linspace(0, 1/f1, 2*nfreq, endpoint=True)

# Instantiate an OpenMDAO Problem instance.
prob = om.Problem()

# We need an optimization driver.  To solve this simple problem ScipyOptimizerDriver will work.
prob.driver = om.ScipyOptimizeDriver(maxiter=500)
prob.driver.options['optimizer'] = 'SLSQP'
prob.driver.declare_coloring()

# Instantiate a Dymos Trajectory and add it to the Problem model.
traj = dm.Trajectory()
prob.model.add_subsystem('traj', traj)

# Instantiate a Phase and add it to the Trajectory.
phase = dm.Phase(ode_class=OscillatorODE, transcription=dm.GaussLobatto(num_segments=24,order=3))
traj.add_phase('phase0', phase)

# Tell Dymos that the duration of the phase is bounded.
phase.set_time_options(fix_initial=True, fix_duration=True, targets=['time'])

# Tell Dymos the states to be propagated using the given ODE.
phase.add_state('x', fix_initial=False, fix_final=False, rate_source='x_dot', scaler=1, targets=['x'], units='m')
phase.add_state('v', fix_initial=False, fix_final=False, rate_source='v_dot', targets=['v'], units='m/s')
phase.add_state('energy', fix_initial=True, rate_source='power', ref=10, defect_ref=1, units='J')  # integration of power

phase.add_control('u', fix_initial=False, rate_continuity=False, opt=True, continuity=True, targets=['u'], val=0, lower=-30, upper=30, scaler=1e-1, units='N')

# Enforce periodic states: final = initial
traj.link_phases(phases=['phase0', 'phase0'], vars=['x','v'], locs=('final', 'initial'))

# The spring constant, damping coefficient, and mass are inputs to the system that are
# constant throughout the phase.
phase.add_parameter('k', units='N/m', targets=['k'], static_target=True)
phase.add_parameter('c', units='N*s/m', targets=['c'], static_target=True)
phase.add_parameter('m', units='kg', targets=['m'], static_target=True)
phase.add_parameter('exc_amp', units='N', targets=['exc_amp'], static_target=True)
phase.add_parameter('wave_freq', units='Hz', targets=['wave_freq'], static_target=True)

phase.add_timeseries_output('time', output_name='time')

# Since we're using an optimization driver, an objective is required.  We'll minimize
# the final time in this case.
phase.add_objective('energy', loc='final',scaler=1e-2)
prob.model.linear_solver = om.DirectSolver()

# Setup the OpenMDAO problem
prob.setup()

# Assign values to the times and states
t_final = t_vec[-1]
phase.set_time_val(0.0, t_final) # 1 period
phase.set_state_val('x', vals=[1.0, 1.0], time_vals=[0.0, t_final])
phase.set_state_val('v', vals=[0.0,0.0], time_vals=[0.0, t_final])
phase.set_control_val('u', vals=[10, 10], time_vals=[0.0, t_final])
phase.set_state_val('energy', vals=[0.0,-1000], time_vals=[0.0, t_final]) # the final value is a guess

phase.set_parameter_val('k', 24452)
phase.set_parameter_val('c', 71)
phase.set_parameter_val('m', 876 + 1492)
phase.set_parameter_val('exc_amp', np.abs(23518+45*1j))
phase.set_parameter_val('wave_freq', 0.1)
#phase.set_parameter_val('u', 1.0)
#phase.set_parameter_val('f', 1.0)

# Now we're using the optimization driver to iteratively run the model and vary the
dm.run_problem(prob)

# Perform an explicit simulation of our ODE from the initial conditions.
sim_out = traj.simulate(times_per_seg=10)

# Plot the state values obtained from the phase timeseries objects in the simulation output.
t_sol = prob.get_val('traj.phase0.timeseries.time')
t_sim = sim_out.get_val('traj.phase0.timeseries.time')
openmdao