2

I want to use Dymos to solve the optimal control problem:

enter image description here

subject to the dynamic system:

enter image description here

I have two questions:

(1) How to set the object function (object1) of V(X(T)), where the value of state variables at final time point are the inputs of the function V.

I think the setting

phase.add_objective('V(X)', loc='final', scale=1)

will minimize the integral from t=0 to t=T of a variable whose rate is V(X), is that right?

(2) I think I can setup an output objectval2 with time change rate L(X, u) then set

phase.add_objective('objectval2', loc='final', scale=1)

then how do I let the optimizer know that I want to minimize the sum of objectval1 + objectval2, instead of objectval1*objectval2 or any other function of objectval1 and objectval2.

I read the tutorial and I didn't find an example similar to my case.

#################

Comments: Thanks. I cannot add the following clarifying questions at the comments to the answer, it said these are too long. So I added them here.

I thinks the key code here is:

p.model.add_subsystem('other_calcs', om.ExecComp("f=a+b"))
p.model.connect('traj.phase0.timeseries.states:v', 'other_calcs.a', src_indices=[-1])
p.model.connect('traj.pahse0.timeseries.states:v', 'other_calcs.b', src_indices=[0])

I saw this in the tutorial and I have 3 questions:

(1) For the keyword input src_indices, I think src_indices=[-1] passes the last value of v(v[-1]) to a and src_indices=[0] passes the first value of v(v[0]) to b. I think the time point at v[-1] depends on the number of nodes, which decided by parameter num_segments and order. So does transcription function always set the last node position to the last time point? For example, if I solve a control problem from t=0 to t=2000, no matter how I set the num_segments and order, e.g. num_segments=2 and order=3 or num_segments=20 and order=5, v[-1] will always be the value of v at t=2000 and v[0] will always be the value of v at t=0?

(2) Can I add one or more specific time points to the nodes? For example, if I solve a control problem from t=0 to t=2000, I want to minimize the sum of the value of v at t=500, t=1000 and t=2000, can I required the nodes must includes the time points at t=500, t=1000 and t=2000, no matter how I set the num_segments and order?

(3) Last question is about the objective function setting I asked first time. In the example, you commented out # phase.add_objective('time', loc='final'). What will the optimizer do if I set the code:

phase.add_objective('x', loc='final')
p.model.add_objective('other_calcs.f')

Does the optimizer minimizes the sum of x integral from t=0 to t=T and v(T)? If not, how can I set the optimizer to minimizes the sum of x integral from t=0 to t=T and v(T)?

Wei
  • 23
  • 5
  • Justin's example is pretty thorough, and there is some description in how to do this here:. https://openmdao.github.io/dymos/faq/downstream_analysis.html – Rob Falck Nov 19 '22 at 22:48
  • Thanks. I saw example in the tutorial using `om.ExecComp` and `src_indices`, but I am still not clear and I added my clarifying questions to my original questions. – Wei Nov 20 '22 at 00:45

2 Answers2

3

Using the standard Brachistochrone problem, I made a toy modification to the objective function to show you how to achieve what you are asking about. The new objective function I defined is not a super meaningful one, but it gets the point across.

The key idea here is that you are allowed to add extra components to the model in addition to the traj Group. These could come either before or after the traj group, but in your case you want them after. Then you can connect things to/from your other components and the traj group.

Here I made a very simple ExecComp that summed the initial and final conditions of the state variable V and set that as the objective. To do this I connected with the timeseries outputs of the phase. Obviously you could construct a different equation that operates on the final time state value. The connect statements in this example show you how to do that.

import numpy as np
import openmdao.api as om
import dymos as dm
import matplotlib.pyplot as plt

# First define a system which computes the equations of motion
class BrachistochroneEOM(om.ExplicitComponent):
    def initialize(self):
        self.options.declare('num_nodes', types=int)

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

        # Inputs
        self.add_input('v', val=np.zeros(nn), units='m/s', desc='velocity')
        self.add_input('theta', val=np.zeros(nn), units='rad', desc='angle of wire')
        self.add_output('xdot', val=np.zeros(nn), units='m/s', desc='x rate of change')
        self.add_output('ydot', val=np.zeros(nn), units='m/s', desc='y rate of change')
        self.add_output('vdot', val=np.zeros(nn), units='m/s**2', desc='v rate of change')

        # Ask OpenMDAO to compute the partial derivatives using complex-step
        # with a partial coloring algorithm for improved performance
        self.declare_partials(of='*', wrt='*', method='cs')
        self.declare_coloring(wrt='*', method='cs', show_summary=True)

    def compute(self, inputs, outputs):
        v, theta = inputs.values()
        outputs['vdot'] = 9.80665 * np.cos(theta)
        outputs['xdot'] = v * np.sin(theta)
        outputs['ydot'] = -v * np.cos(theta)

p = om.Problem()

# Define a Trajectory object
traj = p.model.add_subsystem('traj', dm.Trajectory())

# Define a Dymos Phase object with GaussLobatto Transcription
tx = dm.GaussLobatto(num_segments=10, order=3)
phase = dm.Phase(ode_class=BrachistochroneEOM, transcription=tx)

traj.add_phase(name='phase0', phase=phase)

# Set the time options
phase.set_time_options(fix_initial=True,
                       duration_bounds=(0.5, 10.0))
# Set the state options
phase.add_state('x', rate_source='xdot',
                fix_initial=True, fix_final=True)
phase.add_state('y', rate_source='ydot',
                fix_initial=True, fix_final=True)
phase.add_state('v', rate_source='vdot',
                fix_initial=True, fix_final=False)
# Define theta as a control.
phase.add_control(name='theta', units='rad',
                  lower=0, upper=np.pi)


######################################################
# Post trajectory calculations for composite objective
######################################################

p.model.add_subsystem('other_calcs', om.ExecComp("f=v[0]+v[1]", v={"shape":(2,)}))

p.model.connect('traj.phase0.timeseries.states:v', 'other_calcs.v', src_indices=[0,-1])

###################################################################
# Standard Dymos API for objective function - minimize final time.
###################################################################

# phase.add_objective('time', loc='final')

###################################################################
# OpenMDAO API for objective functions that require post-dymos calculations
###################################################################

p.model.add_objective('other_calcs.f')

# Set the driver.
p.driver = om.ScipyOptimizeDriver()

# Allow OpenMDAO to automatically determine total
# derivative sparsity pattern.
# This works in conjunction with partial derivative
# coloring to give a large speedup
p.driver.declare_coloring()

# Setup the problem
p.setup()

# Now that the OpenMDAO problem is setup, we can guess the
# values of time, states, and controls.
p.set_val('traj.phase0.t_duration', 2.0)

# States and controls here use a linearly interpolated
# initial guess along the trajectory.
p.set_val('traj.phase0.states:x',
          phase.interp('x', ys=[0, 10]),
          units='m')
p.set_val('traj.phase0.states:y',
          phase.interp('y', ys=[10, 5]),
          units='m')
p.set_val('traj.phase0.states:v',
          phase.interp('v', ys=[0, 5]),
          units='m/s')
# constant initial guess for control
p.set_val('traj.phase0.controls:theta', 90, units='deg')

# Run the driver to solve the problem and generate default plots of
# state and control values vs time
dm.run_problem(p, make_plots=True, simulate=True)
Justin Gray
  • 5,605
  • 1
  • 11
  • 16
  • Thanks. I saw example in the tutorial using `om.ExecComp` and `src_indices`, but I am still not clear and I added my clarifying questions to my original questions. – Wei Nov 20 '22 at 00:45
2

(1) is the [-1] index of the timeseries variables always the last time point in the transcription?

Yes, it is.

(2) Can I add one or more specific time points to the nodes? Is it possible to have nodes guaranteed to be at specific fixed time points that might not match up with collocation points?

Yes...

There is a "right" way to do this which you should most certainly try first. Then there is a "wrong" way which I'll mention for completeness, and might work for you but also likely has some numerical challenges that I won't go into detail on but are the reason its not the right way.

--- The Right Way ---

If you have some intermediate times where you need to measure/constrain things, then you should break your trajectory up into multiple phases, and you can set the breakpoints to be at the key times you need to consider. So if you have a trajectory where the duration is uncertain but will be at least 10 seconds long, and you know you will want to do some post-processing on the performance at t=10, then make it have two phases. One phase from t=0 to t=10, and a second phase from t=10 to t=t_final. Even if t_final happens to equal 10, you're still ok. Dymos allows for 0 duration phases.

--- The other way ---

If you set up your problem with a fixed duration, then this is definitely possible. Its possible that you get lucky and collocation points line up with the times you want. If you don't get lucky though, you can use another technique to get Dymos to interpolate the time history onto a secondary grid for you. That grid can have a different transcription and number of segments than the primary collocation one (but will have the same start and end times). You can choose a Radau transcription with an order of 1, which gives you a simple fixed time-step. Then the number of segments basically equals the number of time-steps, and you could then in theory ensure you have an interpolated value at any time that you can reach with an integer division of your total duration. If you needed two times, that didn't line up on the same integer division then you could add two additional timeseries with different numbers of segments.

I mention this method, only because there are some problems where you don't just want to take a measurement at one time, but you want it at some evenly spaced distribution of times (maybe you want to take a FFT of the data...). In these cases, the secondary time-series is a good option.

If your desired time doesn't line up on an integer division, then definitely don't use this approach. Also, if your phase has a non-fixed duration, then REALLY definitely don't use this approach. It simply won't work, since you can't hold the time points fixed when the duration is changing.

(3.1) Can you have multiple objectives?

No. Dymos uses gradient based optimization, and optimal control problems are not well posed for generic multi-objective problems. You need to compose a composite objective via some sort of explicit combinations of outputs.

One objective only. Call EITHER the dymos objective api, or the OpenMDAO one. Not both.

(3.2), How can you set up postprocessing with an integrated value?

Dymos is fundamentally an integrator. If you need a value integrated, you simply add it as a new state to the phase. You don't need to edit the ODE at all. Dymos understands what you are trying to do if you pass it an existing state as the rate source for a new state. So for the brachistochrone problem if you wanted to track total integrated y motion you would add a new state (we'll call it y_tot, with the state_rate being y itself). Then the last time point in the timeseries for y_tot will have the integrated value.

Here is a script that shows the techniques I described for (2) and (3). Adding more phases with breakpoints at specific times is covered in this Dymos docs example.

import numpy as np
import openmdao.api as om
import dymos as dm
import matplotlib.pyplot as plt

# First define a system which computes the equations of motion
class BrachistochroneEOM(om.ExplicitComponent):
    def initialize(self):
        self.options.declare('num_nodes', types=int)

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

        # Inputs
        self.add_input('v', val=np.zeros(nn), units='m/s', desc='velocity')
        self.add_input('theta', val=np.zeros(nn), units='rad', desc='angle of wire')
        self.add_output('xdot', val=np.zeros(nn), units='m/s', desc='x rate of change')
        self.add_output('ydot', val=np.zeros(nn), units='m/s', desc='y rate of change')
        self.add_output('vdot', val=np.zeros(nn), units='m/s**2', desc='v rate of change')

        # Ask OpenMDAO to compute the partial derivatives using complex-step
        # with a partial coloring algorithm for improved performance
        self.declare_partials(of='*', wrt='*', method='cs')
        self.declare_coloring(wrt='*', method='cs', show_summary=True)

    def compute(self, inputs, outputs):
        v, theta = inputs.values()
        outputs['vdot'] = 9.80665 * np.cos(theta)
        outputs['xdot'] = v * np.sin(theta)
        outputs['ydot'] = -v * np.cos(theta)

p = om.Problem()

# Define a Trajectory object
traj = p.model.add_subsystem('traj', dm.Trajectory())

# Define a Dymos Phase object with GaussLobatto Transcription
tx = dm.GaussLobatto(num_segments=10, order=3)
phase = dm.Phase(ode_class=BrachistochroneEOM, transcription=tx)

traj.add_phase(name='phase0', phase=phase)

# Set the time options
phase.set_time_options(fix_initial=True,
                       duration_bounds=(0.5, 10.0))
# Set the state options
phase.add_state('x', rate_source='xdot',
                fix_initial=True, fix_final=True)
phase.add_state('y', rate_source='ydot',
                fix_initial=True, fix_final=True)
phase.add_state('y_tot', rate_source='y',
                fix_initial=True, fix_final=True)
phase.add_state('v', rate_source='vdot',
                fix_initial=True, fix_final=False)
# Define theta as a control.
phase.add_control(name='theta', units='rad',
                  lower=0, upper=np.pi)


# compressed transcription so we don't get duplicate nodes between each segment
phase.add_timeseries('fixed_step', dm.Radau(num_segments=10, order=1))
phase.add_timeseries_output('v', timeseries="fixed_step")

######################################################
# Post trajectory calculations for composite objective
######################################################

p.model.add_subsystem('other_calcs', om.ExecComp("f=v[0]+v[1]", v={"shape":(2,)}))
p.model.connect('traj.phase0.timeseries.states:v', 'other_calcs.v', src_indices=[0,-1])

###################################################################
# Standard Dymos API for objective function - minimize final time.
###################################################################

phase.add_objective('time', loc='final')

###################################################################
# OpenMDAO API for objective functions that require post-dymos calculations
###################################################################

# p.model.add_objective('other_calcs.f')

# Set the driver.
p.driver = om.ScipyOptimizeDriver()

# Allow OpenMDAO to automatically determine total
# derivative sparsity pattern.
# This works in conjunction with partial derivative
# coloring to give a large speedup
p.driver.declare_coloring()

# Setup the problem
p.setup()

# Now that the OpenMDAO problem is setup, we can guess the
# values of time, states, and controls.
p.set_val('traj.phase0.t_duration', 2.0)

# States and controls here use a linearly interpolated
# initial guess along the trajectory.
p.set_val('traj.phase0.states:x',
          phase.interp('x', ys=[0, 10]),
          units='m')
p.set_val('traj.phase0.states:y',
          phase.interp('y', ys=[10, 5]),
          units='m')
p.set_val('traj.phase0.states:y_tot',
          phase.interp('y', ys=[0, 0]),
          units='m*s')
p.set_val('traj.phase0.states:v',
          phase.interp('v', ys=[0, 5]),
          units='m/s')
# constant initial guess for control
p.set_val('traj.phase0.controls:theta', 90, units='deg')

# Run the driver to solve the problem and generate default plots of
# state and control values vs time
dm.run_problem(p, make_plots=True, simulate=True)


print(p.get_val("traj.phase0.fixed_step.states:v"))
time = p.get_val("traj.phase0.fixed_step.time")[:,0]
dt = time[1:] - time[0:-1]
print("time", time)
# note that dymos keeps duplicates of values between each segment, so you see double values
# which gives 0 time steps between each segment
print("dt: ", dt)
print(p.get_val("traj.phase0.timeseries.states:y_tot"))
Justin Gray
  • 5,605
  • 1
  • 11
  • 16
  • Thanks so much. I set the duration of `t` from 0 to 10 and used `dm.Radau(num_segments=10, order=1)`, I saw at each iteration the 'time=[ 0. 1. 1. 2. 2. 3. 3. 4. 4. 5. 5. 6. 6. 7. 7. 8. 8. 9. 9. 10.]' with duplication of time points between each segment in the subsystem. I can adjust the `num_segments` to get the time points I want or interpolate them at the subsystem. Thus, after I set the `num_segments` and `order` to the 'transcription', the time points are not changed by the algorithm at each iteration? Or only in the time points in `phase.add_timeseries` will not change? – Wei Nov 20 '22 at 21:00
  • 1
    Using order=1 in the transcriptions may cause unexpected behavior. My suggestion would be to use a secondary timeseries that uses a GaussLobatto transcription of order 3. If we set the timeseries to output at the state input nodes, that should capture only the time points of the segment ends. See [this example](https://openmdao.github.io/dymos/faq/tandem_phases.html) for setting secondary timeseries outputs. After `tx1 = dm.GaussLobatto(num_segments=10, order=3, compressed=False)` add a timeseries as `phase0.add_timeseries('timeseries2', transcription=tx1, subset='state_input')` – Rob Falck Nov 20 '22 at 21:58
  • Thanks. I am not quite understand your saying "If we set the timeseries to output at the state input nodes, that should capture only the time points of the segment ends". First set tx1 = dm.GaussLobatto(num_segments=10, order=3). When I use 'phase0.add_timeseries('timeseries2', transcription=tx1)', the time points inputted to subsystem are [0. , 0.5, 1. , 1. , 1.5, 2. , 2. , 2.5, 3. , 3. , 3.5, 4. , 4. , 4.5, 5. , 5. , 5.5, 6. , 6. , 6.5, 7. , 7. , 7.5, 8. , 8. , 8.5, 9. , 9. , 9.5, 10. ]. – Wei Nov 21 '22 at 02:49
  • When I use 'phase0.add_timeseries('timeseries2', transcription=tx1, subset='state_input')', the time points inputed to subsystem are [ 0., 1., 1., 2., 2., 3., 3., 4., 4., 5., 5., 6., 6., 7., 7., 8., 8., 9., 9., 10.]. There are 10 time points less compared to not using `subset='state_input'`. What happens when I set the keywords arguments `subset='state_input`? Is there an equation to calculate the difference of the number of inputted time points between setting and not setting `subset='state_input` from parameter `num_segments` and `order`? – Wei Nov 21 '22 at 02:58
  • 1
    A 3rd order polynomial needs 4 pieces of information to definte it. In 3rd-order GaussLobatto, that's the state values and state rates at the endpoints of each segment. The states are input here (the state input nodes). The "defect" constraints are evaluated at the segment midpoints. Those are the nodes that are not shown when you specific that you want the state input nodes. Theres a bit more of a description here: https://openmdao.github.io/dymos/getting_started/collocation.html – Rob Falck Nov 21 '22 at 19:46
  • Got it. Thanks. I have a simple question about the algorithm. After the finishing the problem, is there an info from `dm` or `p` to check whether the optimization terminated successfully or not? And report the final `collocation defect`? Sometimes the solution and simulation are in good fit (I think the `collocation defect` are small in this case) and sometimes are not. But they print the same info `EXIT: Maximum Number of Iterations Exceeded` or `EXIT: Restoration Failed!`. Even I set `p.driver.opt_settings['max_iter']=int(1e5)`, it also says `EXIT:Maximum Number of Iterations Exceeded`. – Wei Nov 22 '22 at 00:21
  • 1
    It sounds like you are using SLSQP then. I highly recommend you switch to IPOPT instead. You can get it as part of pyoptsparse (installation helper for linux: https://github.com/OpenMDAO/build_pyoptsparse) – Justin Gray Nov 22 '22 at 14:42
  • I do use IPOPT. IPOPT is the only optimizer works to me. Other optimizers give me error. My setting `optimizer = 'IPOPT', p.driver.options['optimizer'] = optimizer, p.driver.opt_settings['max_iter'] = int(5e4)`. Is there an info in the `dm` or `p` tells me final optimization success or not, or some final evaluation of `collocation defect`. It prints `EXIT: Maximum Number of Iterations Exceeded` or EXIT: Restoration Failed!`. When I plot, sometimes the collocations are good sometimes are bad. But the printed info are the same, `EXIT: Maximum Number of Iterations Exceeded`. – Wei Nov 22 '22 at 17:53