From d9ec3ec5bb4d3963bd8b6c502ce36baed7cd1760 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 24 Nov 2022 11:37:03 -0800 Subject: [PATCH 1/6] add trajectory_method='shooting' --- control/optimal.py | 221 ++++++++++++++++++++++++--------------------- 1 file changed, 116 insertions(+), 105 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index 2842aa0ad..c63f1523e 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -65,6 +65,9 @@ class OptimalControlProblem(): inputs should either be a 2D vector of shape (ninputs, horizon) or a 1D input of shape (ninputs,) that will be broadcast by extension of the time axis. + method : string, optional + Method to use for carrying out the optimization. Currently only + 'shooting' is supported. log : bool, optional If `True`, turn on logging messages (using Python logging module). Use ``logging.basicConfig`` to enable logging output (e.g., to a file). @@ -79,6 +82,9 @@ class OptimalControlProblem(): Additional parameters --------------------- + basis : BasisFamily, optional + Use the given set of basis functions for the inputs instead of + setting the value of the input at each point in the timepts vector. solve_ivp_method : str, optional Set the method used by :func:`scipy.integrate.solve_ivp`. solve_ivp_kwargs : str, optional @@ -127,7 +133,7 @@ class OptimalControlProblem(): def __init__( self, sys, timepts, integral_cost, trajectory_constraints=[], terminal_cost=None, terminal_constraints=[], initial_guess=None, - basis=None, log=False, **kwargs): + method='shooting', basis=None, log=False, **kwargs): """Set up an optimal control problem.""" # Save the basic information for use later self.system = sys @@ -137,6 +143,13 @@ def __init__( self.terminal_constraints = terminal_constraints self.basis = basis + # Keep track of what type of method we are using + if method not in {'shooting', 'collocation'}: + raise NotImplementedError(f"Unkown method {method}") + else: + self.shooting = method in {'shooting'} + self.collocation = method in {'collocation'} + # Process keyword arguments self.solve_ivp_kwargs = {} self.solve_ivp_kwargs['method'] = kwargs.pop( @@ -206,6 +219,7 @@ def __init__( constraint_lb, constraint_ub, eqconst_value = [], [], [] # Go through each time point and stack the bounds + # TODO: for collocation method, keep track of linear vs nonlinear for t in self.timepts: for type, fun, lb, ub in self.trajectory_constraints: if np.all(lb == ub): @@ -241,6 +255,11 @@ def __init__( self.constraints.append(sp.optimize.NonlinearConstraint( self._eqconst_function, self.eqconst_value, self.eqconst_value)) + if self.collocation: + # Add the collocation constraints + colloc_zeros = np.zeros((self.timepts.size - 1) * sys.nstates) + self.constraints.append(sp.optimize.NonlinearConstraint( + self._collocation_constraint, colloc_zeros, colloc_zeros)) # Process the initial guess self.initial_guess = self._process_initial_guess(initial_guess) @@ -274,40 +293,8 @@ def _cost_function(self, coeffs): start_time = time.process_time() logging.info("_cost_function called at: %g", start_time) - # Retrieve the saved initial state - x = self.x - - # Compute inputs - if self.basis: - if self.log: - logging.debug("coefficients = " + str(coeffs)) - inputs = self._coeffs_to_inputs(coeffs) - else: - inputs = coeffs.reshape((self.system.ninputs, -1)) - - # See if we already have a simulation for this condition - if np.array_equal(coeffs, self.last_coeffs) and \ - np.array_equal(x, self.last_x): - states = self.last_states - else: - if self.log: - logging.debug("calling input_output_response from state\n" - + str(x)) - logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) - - # Simulate the system to get the state - # TODO: try calling solve_ivp directly for better speed? - _, _, states = ct.input_output_response( - self.system, self.timepts, inputs, x, return_x=True, - solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) - self.system_simulations += 1 - self.last_x = x - self.last_coeffs = coeffs - self.last_states = states - - if self.log: - logging.debug("input_output_response returned states\n" - + str(states)) + # Compute the states and inputs + states, inputs = self._compute_states_inputs(coeffs) # Trajectory cost if ct.isctime(self.system): @@ -380,12 +367,15 @@ def _cost_function(self, coeffs): # holds at a specific point in time, and implements the original # constraint. # - # To do this, we basically create a function that simulates the system - # dynamics and returns a vector of values corresponding to the value of - # the function at each time. The class initialization methods takes - # care of replicating the upper and lower bounds for each point in time - # so that the SciPy optimization algorithm can do the proper - # evaluation. + # For collocation methods, we can directly evaluate the constraints at + # the collocation points. + # + # For shooting methods, we do this by creating a function that + # simulates the system dynamics and returns a vector of values + # corresponding to the value of the function at each time. The + # class initialization methods takes care of replicating the upper + # and lower bounds for each point in time so that the SciPy + # optimization algorithm can do the proper evaluation. # # In addition, since SciPy's optimization function does not allow us to # pass arguments to the constraint function, we have to store the initial @@ -396,35 +386,12 @@ def _constraint_function(self, coeffs): start_time = time.process_time() logging.info("_constraint_function called at: %g", start_time) - # Retrieve the initial state - x = self.x - - # Compute input at time points - if self.basis: - inputs = self._coeffs_to_inputs(coeffs) - else: - inputs = coeffs.reshape((self.system.ninputs, -1)) - - # See if we already have a simulation for this condition - if np.array_equal(coeffs, self.last_coeffs) \ - and np.array_equal(x, self.last_x): - states = self.last_states - else: - if self.log: - logging.debug("calling input_output_response from state\n" - + str(x)) - logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) - - # Simulate the system to get the state - _, _, states = ct.input_output_response( - self.system, self.timepts, inputs, x, return_x=True, - solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) - self.system_simulations += 1 - self.last_x = x - self.last_coeffs = coeffs - self.last_states = states + # Compute the states and inputs + states, inputs = self._compute_states_inputs(coeffs) + # # Evaluate the constraint function along the trajectory + # value = [] for i, t in enumerate(self.timepts): for ctype, fun, lb, ub in self.trajectory_constraints: @@ -477,37 +444,8 @@ def _eqconst_function(self, coeffs): start_time = time.process_time() logging.info("_eqconst_function called at: %g", start_time) - # Retrieve the initial state - x = self.x - - # Compute input at time points - if self.basis: - inputs = self._coeffs_to_inputs(coeffs) - else: - inputs = coeffs.reshape((self.system.ninputs, -1)) - - # See if we already have a simulation for this condition - if np.array_equal(coeffs, self.last_coeffs) and \ - np.array_equal(x, self.last_x): - states = self.last_states - else: - if self.log: - logging.debug("calling input_output_response from state\n" - + str(x)) - logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) - - # Simulate the system to get the state - _, _, states = ct.input_output_response( - self.system, self.timepts, inputs, x, return_x=True, - solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) - self.system_simulations += 1 - self.last_x = x - self.last_coeffs = coeffs - self.last_states = states - - if self.log: - logging.debug("input_output_response returned states\n" - + str(states)) + # Compute the states and inputs + states, inputs = self._compute_states_inputs(coeffs) # Evaluate the constraint function along the trajectory value = [] @@ -538,6 +476,10 @@ def _eqconst_function(self, coeffs): # Checked above => we should never get here raise TypeError("unknown constraint type {ctype}") + # Add the collocation constraints + if self.collocation: + raise NotImplementedError("collocation not yet implemented") + # Update statistics self.eqconst_evaluations += 1 if self.log: @@ -555,6 +497,12 @@ def _eqconst_function(self, coeffs): # Return the value of the constraint function return np.hstack(value) + def _collocation_constraints(self, coeffs): + # Compute the states and inputs + states, inputs = self._compute_states_inputs(coeffs) + + raise NotImplementedError("collocation not yet implemented") + # # Initial guess # @@ -566,8 +514,10 @@ def _eqconst_function(self, coeffs): # vector. If a basis is specified, this is converted to coefficient # values (which are generally of smaller dimension). # + # TODO: figure out how to modify this for collocation + # def _process_initial_guess(self, initial_guess): - if initial_guess is not None: + if self.shooting and initial_guess is not None: # Convert to a 1D array (or higher) initial_guess = np.atleast_1d(initial_guess) @@ -592,10 +542,15 @@ def _process_initial_guess(self, initial_guess): # Reshape for use by scipy.optimize.minimize() return initial_guess.reshape(-1) - # Default is zero - return np.zeros( - self.system.ninputs * - (self.timepts.size if self.basis is None else self.basis.N)) + elif self.collocation and initial_guess is not None: + raise NotImplementedError("collocation not yet implemented") + + # Default is no initial guess + input_size = self.system.ninputs * \ + (self.timepts.size if self.basis is None else self.basis.N) + state_size = 0 if not self.collocation else \ + (self.timepts.size - 1) * self.sys.nstates + return np.zeros(input_size + state_size) # # Utility function to convert input vector to coefficient vector @@ -679,6 +634,62 @@ def _print_statistics(self, reset=True): if reset: self._reset_statistics(self.log) + # + # Compute the states and inputs from the coefficient vector + # + # These internal functions return the states and inputs at the + # collocation points given the ceofficient (optimizer state) vector. + # They keep track of whether a shooting method is being used or not and + # simulate the dynamis of needed. + # + + # Compute the states and inputs from the coefficients + def _compute_states_inputs(self, coeffs): + # + # Compute out the states and inputs + # + if self.collocation: + # States are appended to end of (input) coefficients + states = coeffs[-self.sys.nstates * self.timepts.size:0] + coeffs = coeffs[0:-self.sys.nstates * self.timepts.size] + + # Compute input at time points + if self.basis: + inputs = self._coeffs_to_inputs(coeffs) + else: + inputs = coeffs.reshape((self.system.ninputs, -1)) + + if self.shooting: + # See if we already have a simulation for this condition + if np.array_equal(coeffs, self.last_coeffs) \ + and np.array_equal(self.x, self.last_x): + states = self.last_states + else: + states = self._simulate_states(self.x, inputs) + self.last_x = self.x + self.last_states = states + self.last_coeffs = coeffs + + return states, inputs + + # Simulate the system dynamis to retrieve the state + def _simulate_states(self, x0, inputs): + if self.log: + logging.debug( + "calling input_output_response from state\n" + str(x0)) + logging.debug("input =\n" + str(inputs)) + + # Simulate the system to get the state + _, _, states = ct.input_output_response( + self.system, self.timepts, inputs, x0, return_x=True, + solve_ivp_kwargs=self.solve_ivp_kwargs, t_eval=self.timepts) + self.system_simulations += 1 + + if self.log: + logging.debug( + "input_output_response returned states\n" + str(states)) + return states + # # Optimal control computations # @@ -992,7 +1003,7 @@ def solve_ocp( Notes ----- Additional keyword parameters can be used to fine tune the behavior of - the underlying optimization and integrations functions. See + the underlying optimization and integration functions. See :func:`OptimalControlProblem` for more information. """ From ce5e67ac10c9363fb8674258dddc2c9120f17fed Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 24 Nov 2022 15:06:58 -0800 Subject: [PATCH 2/6] add trajectory_method='collocation' --- control/optimal.py | 154 ++++++++++++++++++++++------------ control/tests/optimal_test.py | 115 +++++++++++++++++++++---- 2 files changed, 200 insertions(+), 69 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index c63f1523e..a7354a100 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -21,7 +21,9 @@ from .timeresp import TimeResponseData # Define module default parameter values +_optimal_trajectory_methods = {'shooting', 'collocation'} _optimal_defaults = { + 'optimal.trajectory_method': 'shooting', 'optimal.minimize_method': None, 'optimal.minimize_options': {}, 'optimal.minimize_kwargs': {}, @@ -65,9 +67,11 @@ class OptimalControlProblem(): inputs should either be a 2D vector of shape (ninputs, horizon) or a 1D input of shape (ninputs,) that will be broadcast by extension of the time axis. - method : string, optional - Method to use for carrying out the optimization. Currently only - 'shooting' is supported. + trajectory_method : string, optional + Method to use for carrying out the optimization. Currently supported + methods are 'shooting' and 'collocation' (continuous time only). The + default value is 'shooting' for discrete time systems and + 'collocation' for continuous time systems log : bool, optional If `True`, turn on logging messages (using Python logging module). Use ``logging.basicConfig`` to enable logging output (e.g., to a file). @@ -133,7 +137,7 @@ class OptimalControlProblem(): def __init__( self, sys, timepts, integral_cost, trajectory_constraints=[], terminal_cost=None, terminal_constraints=[], initial_guess=None, - method='shooting', basis=None, log=False, **kwargs): + trajectory_method=None, basis=None, log=False, **kwargs): """Set up an optimal control problem.""" # Save the basic information for use later self.system = sys @@ -144,11 +148,15 @@ def __init__( self.basis = basis # Keep track of what type of method we are using - if method not in {'shooting', 'collocation'}: + if trajectory_method is None: + # TODO: change default + # trajectory_method = 'collocation' if sys.isctime() else 'shooting' + trajectory_method = 'shooting' if sys.isctime() else 'shooting' + elif trajectory_method not in _optimal_trajectory_methods: raise NotImplementedError(f"Unkown method {method}") - else: - self.shooting = method in {'shooting'} - self.collocation = method in {'collocation'} + + self.shooting = trajectory_method in {'shooting'} + self.collocation = trajectory_method in {'collocation'} # Process keyword arguments self.solve_ivp_kwargs = {} @@ -257,10 +265,14 @@ def __init__( self.eqconst_value)) if self.collocation: # Add the collocation constraints - colloc_zeros = np.zeros((self.timepts.size - 1) * sys.nstates) + colloc_zeros = np.zeros(sys.nstates * self.timepts.size) + self.colloc_vals = np.zeros((sys.nstates, self.timepts.size)) self.constraints.append(sp.optimize.NonlinearConstraint( self._collocation_constraint, colloc_zeros, colloc_zeros)) + # Initialize run-time statistics + self._reset_statistics(log) + # Process the initial guess self.initial_guess = self._process_initial_guess(initial_guess) @@ -268,9 +280,6 @@ def __init__( self.last_x = np.full(self.system.nstates, np.nan) self.last_coeffs = np.full(self.initial_guess.shape, np.nan) - # Reset run-time statistics - self._reset_statistics(log) - # Log information if log: logging.info("New optimal control problem initailized") @@ -476,10 +485,6 @@ def _eqconst_function(self, coeffs): # Checked above => we should never get here raise TypeError("unknown constraint type {ctype}") - # Add the collocation constraints - if self.collocation: - raise NotImplementedError("collocation not yet implemented") - # Update statistics self.eqconst_evaluations += 1 if self.log: @@ -497,11 +502,32 @@ def _eqconst_function(self, coeffs): # Return the value of the constraint function return np.hstack(value) - def _collocation_constraints(self, coeffs): + def _collocation_constraint(self, coeffs): # Compute the states and inputs states, inputs = self._compute_states_inputs(coeffs) - raise NotImplementedError("collocation not yet implemented") + if self.system.isctime(): + # Compute the collocation constraints + for i, t in enumerate(self.timepts): + if i == 0: + # Initial condition constraint (self.x = initial point) + self.colloc_vals[:, 0] = states[:, 0] - self.x + fk = self.system._rhs( + self.timepts[0], states[:, 0], inputs[:, 0]) + continue + + # M. Kelly, SIAM Review (2017), equation (3.2), i = k+1 + # x[k+1] - x[k] = 0.5 hk (f(x[k+1], u[k+1] + f(x[k], u[k])) + fkp1 = self.system._rhs(t, states[:, i], inputs[:, i]) + self.colloc_vals[:, i] = states[:, i] - states[:, i-1] - \ + 0.5 * (self.timepts[i] - self.timepts[i-1]) * (fkp1 + fk) + fk = fkp1 + else: + raise NotImplementedError( + "collocation not yet implemented for discrete time systems") + + # Return the value of the constraint function + return self.colloc_vals.reshape(-1) # # Initial guess @@ -517,40 +543,61 @@ def _collocation_constraints(self, coeffs): # TODO: figure out how to modify this for collocation # def _process_initial_guess(self, initial_guess): - if self.shooting and initial_guess is not None: - # Convert to a 1D array (or higher) - initial_guess = np.atleast_1d(initial_guess) - - # See whether we got entire guess or just first time point - if initial_guess.ndim == 1: - # Broadcast inputs to entire time vector - try: - initial_guess = np.broadcast_to( - initial_guess.reshape(-1, 1), - (self.system.ninputs, self.timepts.size)) - except ValueError: - raise ValueError("initial guess is the wrong shape") - - elif initial_guess.shape != \ - (self.system.ninputs, self.timepts.size): - raise ValueError("initial guess is the wrong shape") + # Sort out the input guess and the state guess + if self.collocation and initial_guess is not None and \ + isinstance(initial_guess, tuple): + state_guess, input_guess = initial_guess + else: + state_guess, input_guess = None, initial_guess + + # Process the input guess + if input_guess is not None: + input_guess = self._broadcast_initial_guess( + input_guess, (self.system.ninputs, self.timepts.size)) # If we were given a basis, project onto the basis elements if self.basis is not None: - initial_guess = self._inputs_to_coeffs(initial_guess) + input_guess = self._inputs_to_coeffs(input_guess) + else: + input_guess = np.zeros( + self.system.ninputs * + (self.timepts.size if self.basis is None else self.basis.N)) + + # Process the state guess + if self.collocation: + if state_guess is None: + # Run a simulation to get the initial guess + inputs = input_guess.reshape(self.system.ninputs, -1) + state_guess = self._simulate_states( + np.zeros(self.system.nstates), inputs) + else: + state_guess = self._broadcast_initial_guess( + state_guess, (self.system.nstates, self.timepts.size)) # Reshape for use by scipy.optimize.minimize() - return initial_guess.reshape(-1) + return np.hstack([ + input_guess.reshape(-1), state_guess.reshape(-1)]) + else: + # Reshape for use by scipy.optimize.minimize() + return input_guess.reshape(-1) + + def _broadcast_initial_guess(self, initial_guess, shape): + # Convert to a 1D array (or higher) + initial_guess = np.atleast_1d(initial_guess) + + # See whether we got entire guess or just first time point + if initial_guess.ndim == 1: + # Broadcast inputs to entire time vector + try: + initial_guess = np.broadcast_to( + initial_guess.reshape(-1, 1), shape) + except ValueError: + raise ValueError("initial guess is the wrong shape") - elif self.collocation and initial_guess is not None: - raise NotImplementedError("collocation not yet implemented") + elif initial_guess.shape != shape: + raise ValueError("initial guess is the wrong shape") - # Default is no initial guess - input_size = self.system.ninputs * \ - (self.timepts.size if self.basis is None else self.basis.N) - state_size = 0 if not self.collocation else \ - (self.timepts.size - 1) * self.sys.nstates - return np.zeros(input_size + state_size) + return initial_guess # # Utility function to convert input vector to coefficient vector @@ -650,9 +697,10 @@ def _compute_states_inputs(self, coeffs): # if self.collocation: # States are appended to end of (input) coefficients - states = coeffs[-self.sys.nstates * self.timepts.size:0] - coeffs = coeffs[0:-self.sys.nstates * self.timepts.size] - + states = coeffs[-self.system.nstates * self.timepts.size:].reshape( + self.system.nstates, -1) + coeffs = coeffs[:-self.system.nstates * self.timepts.size] + # Compute input at time points if self.basis: inputs = self._coeffs_to_inputs(coeffs) @@ -863,11 +911,8 @@ def __init__( # Remember the optimal control problem that we solved self.problem = ocp - # Compute input at time points - if ocp.basis: - inputs = ocp._coeffs_to_inputs(res.x) - else: - inputs = res.x.reshape((ocp.system.ninputs, -1)) + # Parse the optimization variables into states and inputs + states, inputs = ocp._compute_states_inputs(res.x) # See if we got an answer if not res.success: @@ -885,6 +930,7 @@ def __init__( if return_states and inputs.shape[1] == ocp.timepts.shape[0]: # Simulate the system if we need the state back + # TODO: this is probably not needed due to compute_states_inputs() _, _, states = ct.input_output_response( ocp.system, ocp.timepts, inputs, ocp.x, return_x=True, solve_ivp_kwargs=ocp.solve_ivp_kwargs, t_eval=ocp.timepts) @@ -1017,7 +1063,7 @@ def solve_ocp( 'optimal', 'return_x', kwargs, return_states, pop=True) # Process (legacy) method keyword - if kwargs.get('method'): + if kwargs.get('method') and method not in optimal_methods: if kwargs.get('minimize_method'): raise ValueError("'minimize_method' specified more than once") kwargs['minimize_method'] = kwargs.pop('method') diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 4359b693c..48d11fc82 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -16,11 +16,50 @@ from numpy.lib import NumpyVersion -def test_finite_horizon_simple(): - # Define a linear system with constraints +@pytest.mark.parametrize("method, npts", [ + ('shooting', 5), + ('collocation', 20), +]) +def test_continuous_lqr(method, npts): + # Create a lightly dampled, second order system + sys = ct.ss([[0, 1], [-0.1, -0.01]], [[0], [1]], [[1, 0]], 0) + + # Define cost functions + Q = np.eye(sys.nstates) + R = np.eye(sys.ninputs) * 10 + + # Figure out the LQR solution (for terminal cost) + K, S, E = ct.lqr(sys, Q, R) + + # Define the cost functions + traj_cost = opt.quadratic_cost(sys, Q, R) + term_cost = opt.quadratic_cost(sys, S, None) + constraints = opt.input_range_constraint( + sys, -np.ones(sys.ninputs), np.ones(sys.ninputs)) + + # Define the initial condition, time horizon, and time points + x0 = np.ones(sys.nstates) + Tf = 10 + timepts = np.linspace(0, Tf, npts) + + res = opt.solve_ocp( + sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + trajectory_method=method + ) + + # Make sure the optimization was successful + assert res.success + + # Make sure we were reasonable close to the optimal cost + assert res.cost / (x0 @ S @ x0) < 1.2 # shouldn't be too far off + + +@pytest.mark.parametrize("method", ['shooting']) # TODO: add 'collocation' +def test_finite_horizon_simple(method): + # Define a (discrete time) linear system with constraints # Source: https://www.mpt3.org/UI/RegulationProblem - # LTI prediction model + # LTI prediction model (discrete time) sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) # State and input constraints @@ -40,6 +79,7 @@ def test_finite_horizon_simple(): # Retrieve the full open-loop predictions res = opt.solve_ocp( sys, time, x0, cost, constraints, squeeze=True, + trajectory_method=method, terminal_cost=cost) # include to match MPT3 formulation t, u_openloop = res.time, res.inputs np.testing.assert_almost_equal( @@ -297,7 +337,8 @@ def test_terminal_constraints(sys_args): # Re-run using initial guess = optional and make sure nothing changes res = optctrl.compute_trajectory(x0, initial_guess=u1) - np.testing.assert_almost_equal(res.inputs, u1) + np.testing.assert_almost_equal(res.inputs, u1, decimal=2) + np.testing.assert_almost_equal(res.states, x1, decimal=4) # Re-run using a basis function and see if we get the same answer res = opt.solve_ocp( @@ -574,7 +615,18 @@ def final_point_eval(x, u): res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) -def test_optimal_doc(): +@pytest.mark.parametrize( + "method, npts, initial_guess", [ + # ('shooting', 3, None), # doesn't converge + # ('shooting', 3, 'zero'), # doesn't converge + ('shooting', 3, 'input'), # github issue #782 + ('shooting', 5, 'input'), + # ('collocation', 5, 'zero'), # doesn't converge + ('collocation', 5, 'input'), + ('collocation', 10, 'input'), + ('collocation', 10, 'state'), + ]) +def test_optimal_doc(method, npts, initial_guess): """Test optimal control problem from documentation""" def vehicle_update(t, x, u, params): # Get the parameters for the model @@ -600,8 +652,8 @@ def vehicle_output(t, x, u, params): inputs=('v', 'phi'), outputs=('x', 'y', 'theta')) # Define the initial and final points and time interval - x0 = [0., -2., 0.]; u0 = [10., 0.] - xf = [100., 2., 0.]; uf = [10., 0.] + x0 = np.array([0., -2., 0.]); u0 = np.array([10., 0.]) + xf = np.array([100., 2., 0.]); uf = np.array([10., 0.]) Tf = 10 # Define the cost functions @@ -614,17 +666,50 @@ def vehicle_output(t, x, u, params): # Define the constraints constraints = [ opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + # Define an initial guess at the trajectory + timepts = np.linspace(0, Tf, npts, endpoint=True) + if initial_guess == 'zero': + initial_guess = 0 + + elif initial_guess == 'input': + # Velocity = constant that gets us from start to end + initial_guess = np.zeros((vehicle.ninputs, timepts.size)) + initial_guess[0, :] = (xf[0] - x0[0]) / Tf + + # Steering = rate required to turn to proper slope in first segment + straight_seg_length = timepts[-2] - timepts[1] + curved_seg_length = (Tf - straight_seg_length)/2 + approximate_angle = math.atan2(xf[1] - x0[1], xf[0] - x0[0]) + initial_guess[1, 0] = approximate_angle / (timepts[1] - timepts[0]) + initial_guess[1, -1] = -approximate_angle / (timepts[-1] - timepts[-2]) + + elif initial_guess == 'state': + input_guess = np.outer(u0, np.ones((1, npts))) + state_guess = np.array([ + x0 + (xf - x0) * time/Tf for time in timepts]).transpose() + initial_guess = (state_guess, input_guess) + # Solve the optimal control problem - horizon = np.linspace(0, Tf, 3, endpoint=True) result = opt.solve_ocp( - vehicle, horizon, x0, traj_cost, constraints, - terminal_cost= term_cost, initial_guess=u0) + vehicle, timepts, x0, traj_cost, trajectory_method=method, + # minimize_method='COBYLA', # SLSQP', + # constraints, + terminal_cost=term_cost, initial_guess=initial_guess, + ) + + # Make sure we got a successful result (or precision loss error) + assert result.success or result.status == 2 - # Make sure the resulting trajectory generate a good solution + # Make sure the resulting trajectory generated a good solution resp = ct.input_output_response( - vehicle, horizon, result.inputs, x0, + vehicle, timepts, result.inputs, x0, t_eval=np.linspace(0, Tf, 10)) t, y = resp - assert (y[0, -1] - xf[0]) / xf[0] < 0.01 - assert (y[1, -1] - xf[1]) / xf[1] < 0.01 - assert y[2, -1] < 0.1 + + assert abs(y[0, 0] - x0[0]) < 0.01 + assert abs((y[1, 0] - x0[1]) / x0[1]) < 0.01 + assert abs(y[2, 0]) < 0.01 + + assert abs((y[0, -1] - xf[0]) / xf[0]) < 0.2 # TODO: reset to 0.1 + assert abs((y[1, -1] - xf[1]) / xf[1]) < 0.2 # TODO: reset to 0.1 + assert abs(y[2, -1]) < 0.1 From 8256fa05eca508aa75b346c7bb53b91472549c3c Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 24 Nov 2022 23:13:38 -0800 Subject: [PATCH 3/6] set default trajectory_methood for ctime systems to collocation --- control/optimal.py | 18 ++++++++++-------- control/tests/optimal_test.py | 19 +++++++++++++------ doc/optimal.rst | 21 +++++++++++++-------- 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index a7354a100..e0cf48f0a 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -147,11 +147,10 @@ def __init__( self.terminal_constraints = terminal_constraints self.basis = basis - # Keep track of what type of method we are using + # Keep track of what type of trajector method we are using if trajectory_method is None: # TODO: change default - # trajectory_method = 'collocation' if sys.isctime() else 'shooting' - trajectory_method = 'shooting' if sys.isctime() else 'shooting' + trajectory_method = 'collocation' if sys.isctime() else 'shooting' elif trajectory_method not in _optimal_trajectory_methods: raise NotImplementedError(f"Unkown method {method}") @@ -422,9 +421,9 @@ def _constraint_function(self, coeffs): # Skip equality constraints continue elif ctype == opt.LinearConstraint: - value.append(fun @ np.hstack([states[:, i], inputs[:, i]])) + value.append(fun @ np.hstack([states[:, -1], inputs[:, -1]])) elif ctype == opt.NonlinearConstraint: - value.append(fun(states[:, i], inputs[:, i])) + value.append(fun(states[:, -1], inputs[:, -1])) else: # pragma: no cover # Checked above => we should never get here raise TypeError(f"unknown constraint type {ctype}") @@ -478,9 +477,9 @@ def _eqconst_function(self, coeffs): # Skip inequality constraints continue elif ctype == opt.LinearConstraint: - value.append(fun @ np.hstack([states[:, i], inputs[:, i]])) + value.append(fun @ np.hstack([states[:, -1], inputs[:, -1]])) elif ctype == opt.NonlinearConstraint: - value.append(fun(states[:, i], inputs[:, i])) + value.append(fun(states[:, -1], inputs[:, -1])) else: # pragma: no cover # Checked above => we should never get here raise TypeError("unknown constraint type {ctype}") @@ -567,7 +566,10 @@ def _process_initial_guess(self, initial_guess): if self.collocation: if state_guess is None: # Run a simulation to get the initial guess - inputs = input_guess.reshape(self.system.ninputs, -1) + if self.basis: + inputs = self._coeffs_to_inputs(input_guess) + else: + inputs = input_guess.reshape(self.system.ninputs, -1) state_guess = self._simulate_states( np.zeros(self.system.nstates), inputs) else: diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 48d11fc82..0aded3531 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -367,7 +367,7 @@ def test_terminal_constraints(sys_args): # Not all configurations are able to converge (?) if res.success: - np.testing.assert_almost_equal(x2[:,-1], 0) + np.testing.assert_almost_equal(x2[:,-1], 0, decimal=5) # Make sure that it is *not* a straight line path assert np.any(np.abs(x2 - x1) > 0.1) @@ -619,12 +619,16 @@ def final_point_eval(x, u): "method, npts, initial_guess", [ # ('shooting', 3, None), # doesn't converge # ('shooting', 3, 'zero'), # doesn't converge - ('shooting', 3, 'input'), # github issue #782 - ('shooting', 5, 'input'), - # ('collocation', 5, 'zero'), # doesn't converge + ('shooting', 3, 'u0'), # github issue #782 + # ('shooting', 3, 'input'), # precision loss + # ('shooting', 5, 'input'), # precision loss + # ('collocation', 3, 'u0'), # doesn't converge + ('collocation', 5, 'u0'), # from documenentation ('collocation', 5, 'input'), ('collocation', 10, 'input'), + ('collocation', 10, 'u0'), ('collocation', 10, 'state'), + ('collocation', 20, 'state'), ]) def test_optimal_doc(method, npts, initial_guess): """Test optimal control problem from documentation""" @@ -671,6 +675,9 @@ def vehicle_output(t, x, u, params): if initial_guess == 'zero': initial_guess = 0 + elif initial_guess == 'u0': + initial_guess = u0 + elif initial_guess == 'input': # Velocity = constant that gets us from start to end initial_guess = np.zeros((vehicle.ninputs, timepts.size)) @@ -710,6 +717,6 @@ def vehicle_output(t, x, u, params): assert abs((y[1, 0] - x0[1]) / x0[1]) < 0.01 assert abs(y[2, 0]) < 0.01 - assert abs((y[0, -1] - xf[0]) / xf[0]) < 0.2 # TODO: reset to 0.1 - assert abs((y[1, -1] - xf[1]) / xf[1]) < 0.2 # TODO: reset to 0.1 + assert abs((y[0, -1] - xf[0]) / xf[0]) < 0.12 + assert abs((y[1, -1] - xf[1]) / xf[1]) < 0.12 assert abs(y[2, -1]) < 0.1 diff --git a/doc/optimal.rst b/doc/optimal.rst index bb952e9cc..107373915 100644 --- a/doc/optimal.rst +++ b/doc/optimal.rst @@ -112,15 +112,15 @@ problem into a standard optimization problem that can be solved by :func:`scipy.optimize.minimize`. The optimal control problem can be solved by using the :func:`~control.obc.solve_ocp` function:: - res = obc.solve_ocp(sys, horizon, X0, cost, constraints) + res = obc.solve_ocp(sys, timepts, X0, cost, constraints) The `sys` parameter should be an :class:`~control.InputOutputSystem` and the -`horizon` parameter should represent a time vector that gives the list of +`timepts` parameter should represent a time vector that gives the list of times at which the cost and constraints should be evaluated. The `cost` function has call signature `cost(t, x, u)` and should return the (incremental) cost at the given time, state, and input. It will be -evaluated at each point in the `horizon` vector. The `terminal_cost` +evaluated at each point in the `timepts` vector. The `terminal_cost` parameter can be used to specify a cost function for the final point in the trajectory. @@ -157,7 +157,7 @@ that has the following elements: * `res.success`: `True` if the optimization was successfully solved * `res.inputs`: optimal input * `res.states`: state trajectory (if `return_x` was `True`) - * `res.time`: copy of the time horizon vector + * `res.time`: copy of the time timepts vector In addition, the results from :func:`scipy.optimize.minimize` are also available. @@ -235,16 +235,16 @@ and constrain the velocity to be in the range of 9 m/s to 11 m/s:: Finally, we solve for the optimal inputs:: - horizon = np.linspace(0, Tf, 3, endpoint=True) + timepts = np.linspace(0, Tf, 10, endpoint=True) result = opt.solve_ocp( - vehicle, horizon, x0, traj_cost, constraints, + vehicle, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, initial_guess=u0) Plotting the results:: # Simulate the system dynamics (open loop) resp = ct.input_output_response( - vehicle, horizon, result.inputs, x0, + vehicle, timepts, result.inputs, x0, t_eval=np.linspace(0, Tf, 100)) t, y, u = resp.time, resp.outputs, resp.inputs @@ -262,7 +262,7 @@ Plotting the results:: plt.subplot(3, 1, 3) plt.plot(t, u[1]) - plt.axis([0, 10, -0.01, 0.01]) + plt.axis([0, 10, -0.015, 0.015]) plt.xlabel("t [sec]") plt.ylabel("u2 [rad/s]") @@ -282,6 +282,11 @@ toolbox and it can sometimes be tricky to get the optimization to converge. If you are getting errors when solving optimal control problems or your solutions do not seem close to optimal, here are a few things to try: +* The initial guess matters: providing a reasonable initial guess is often + needed in order for the optimizer to find a good answer. For an optimal + control problem that uses a larger terminal cost to get to a neighborhood + of a final point, a straight line in the state space often works well. + * Less is more: try using a smaller number of time points in your optimiation. The default optimal control problem formulation uses the value of the inputs at each time point as a free variable and this can From d121102cef6a9a8f44a85151ff852fa4f08fe83d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 25 Nov 2022 13:29:28 -0800 Subject: [PATCH 4/6] updated examples + code cleanup --- control/optimal.py | 148 +++++++++++++++++----------------- control/tests/optimal_test.py | 80 ++++++++++-------- doc/optimal.rst | 4 +- doc/steering-optimal.png | Bin 29001 -> 28691 bytes examples/steering-optimal.py | 104 +++++++++++++++--------- 5 files changed, 190 insertions(+), 146 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index e0cf48f0a..0478505bc 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -23,7 +23,6 @@ # Define module default parameter values _optimal_trajectory_methods = {'shooting', 'collocation'} _optimal_defaults = { - 'optimal.trajectory_method': 'shooting', 'optimal.minimize_method': None, 'optimal.minimize_options': {}, 'optimal.minimize_kwargs': {}, @@ -147,9 +146,8 @@ def __init__( self.terminal_constraints = terminal_constraints self.basis = basis - # Keep track of what type of trajector method we are using + # Keep track of what type of trajectory method we are using if trajectory_method is None: - # TODO: change default trajectory_method = 'collocation' if sys.isctime() else 'shooting' elif trajectory_method not in _optimal_trajectory_methods: raise NotImplementedError(f"Unkown method {method}") @@ -185,30 +183,23 @@ def __init__( raise TypeError("unrecognized keyword(s): ", str(kwargs)) # Process trajectory constraints - if isinstance(trajectory_constraints, tuple): - self.trajectory_constraints = [trajectory_constraints] - elif not isinstance(trajectory_constraints, list): - raise TypeError("trajectory constraints must be a list") - else: - self.trajectory_constraints = trajectory_constraints + def _process_constraints(constraint_list, name): + if isinstance(constraint_list, tuple): + constraint_list = [constraint_list] + elif not isinstance(constraint_list, list): + raise TypeError(f"{name} constraints must be a list") - # Make sure that we recognize all of the constraint types - for ctype, fun, lb, ub in self.trajectory_constraints: - if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: - raise TypeError(f"unknown constraint type {ctype}") + # Make sure that we recognize all of the constraint types + for ctype, fun, lb, ub in constraint_list: + if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: + raise TypeError(f"unknown {name} constraint type {ctype}") - # Process terminal constraints - if isinstance(terminal_constraints, tuple): - self.terminal_constraints = [terminal_constraints] - elif not isinstance(terminal_constraints, list): - raise TypeError("terminal constraints must be a list") - else: - self.terminal_constraints = terminal_constraints + return constraint_list - # Make sure that we recognize all of the constraint types - for ctype, fun, lb, ub in self.terminal_constraints: - if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: - raise TypeError(f"unknown constraint type {ctype}") + self.trajectory_constraints = _process_constraints( + trajectory_constraints, "trajectory") + self.terminal_constraints = _process_constraints( + terminal_constraints, "terminal") # # Compute and store constraints @@ -223,10 +214,13 @@ def __init__( # is consistent with the `_constraint_function` that is used at # evaluation time. # + # TODO: when using the collocation method, linear constraints on the + # states and inputs can potentially maintain their linear structure + # rather than being converted to nonlinear constraints. + # constraint_lb, constraint_ub, eqconst_value = [], [], [] # Go through each time point and stack the bounds - # TODO: for collocation method, keep track of linear vs nonlinear for t in self.timepts: for type, fun, lb, ub in self.trajectory_constraints: if np.all(lb == ub): @@ -253,15 +247,19 @@ def __init__( self.eqconst_value = np.hstack(eqconst_value) if eqconst_value else [] # Create the constraints (inequality and equality) + # TODO: for collocation method, keep track of linear vs nonlinear self.constraints = [] + if len(self.constraint_lb) != 0: self.constraints.append(sp.optimize.NonlinearConstraint( self._constraint_function, self.constraint_lb, self.constraint_ub)) + if len(self.eqconst_value) != 0: self.constraints.append(sp.optimize.NonlinearConstraint( self._eqconst_function, self.eqconst_value, self.eqconst_value)) + if self.collocation: # Add the collocation constraints colloc_zeros = np.zeros(sys.nstates * self.timepts.size) @@ -275,7 +273,7 @@ def __init__( # Process the initial guess self.initial_guess = self._process_initial_guess(initial_guess) - # Store states, input, used later to minimize re-computation + # Store states, input (used later to minimize re-computation) self.last_x = np.full(self.system.nstates, np.nan) self.last_coeffs = np.full(self.initial_guess.shape, np.nan) @@ -286,10 +284,14 @@ def __init__( # # Cost function # - # Given the input U = [u[0], ... u[N]], we need to compute the cost of - # the trajectory generated by that input. This means we have to - # simulate the system to get the state trajectory X = [x[0], ..., - # x[N]] and then compute the cost at each point: + # For collocation methods we are given the states and inputs at each + # time point and we use a trapezoidal approximation to compute the + # integral cost, then add on the terminal cost. + # + # For shooting methods, given the input U = [u[0], ... u[N]] we need to + # compute the cost of the trajectory generated by that input. This + # means we have to simulate the system to get the state trajectory X = + # [x[0], ..., x[N]] and then compute the cost at each point: # # cost = sum_k integral_cost(x[k], u[k]) + terminal_cost(x[N], u[N]) # @@ -362,11 +364,12 @@ def _cost_function(self, coeffs): # is called at each point along the trajectory and compared against the # upper and lower bounds. # - # * If the upper and lower bound for the constraint are identical, then we - # separate out the evaluation into two different constraints, which - # allows the SciPy optimizers to be more efficient (and stops them from - # generating a warning about mixed constraints). This is handled - # through the use of the `_eqconst_function` and `eqconst_value` members. + # * If the upper and lower bound for the constraint are identical, then + # we separate out the evaluation into two different constraints, which + # allows the SciPy optimizers to be more efficient (and stops them + # from generating a warning about mixed constraints). This is handled + # through the use of the `_eqconst_function` and `eqconst_value` + # members. # # In both cases, the constraint is specified at a single point, but we # extend this to apply to each point in the trajectory. This means @@ -378,16 +381,12 @@ def _cost_function(self, coeffs): # For collocation methods, we can directly evaluate the constraints at # the collocation points. # - # For shooting methods, we do this by creating a function that - # simulates the system dynamics and returns a vector of values - # corresponding to the value of the function at each time. The - # class initialization methods takes care of replicating the upper - # and lower bounds for each point in time so that the SciPy - # optimization algorithm can do the proper evaluation. - # - # In addition, since SciPy's optimization function does not allow us to - # pass arguments to the constraint function, we have to store the initial - # state prior to optimization and retrieve it here. + # For shooting methods, we do this by creating a function that simulates + # the system dynamics and returns a vector of values corresponding to + # the value of the function at each time. The class initialization + # methods takes care of replicating the upper and lower bounds for each + # point in time so that the SciPy optimization algorithm can do the + # proper evaluation. # def _constraint_function(self, coeffs): if self.log: @@ -437,8 +436,7 @@ def _constraint_function(self, coeffs): "_constraint_function elapsed time: %g", stop_time - start_time) - # Debugging information - if self.log: + # Debugging information logging.debug( "constraint values\n" + str(value) + "\n" + "lb, ub =\n" + str(self.constraint_lb) + "\n" + @@ -492,8 +490,7 @@ def _eqconst_function(self, coeffs): logging.info( "_eqconst_function elapsed time: %g", stop_time - start_time) - # Debugging information - if self.log: + # Debugging information logging.debug( "eqconst values\n" + str(value) + "\n" + "desired =\n" + str(self.eqconst_value)) @@ -515,7 +512,7 @@ def _collocation_constraint(self, coeffs): self.timepts[0], states[:, 0], inputs[:, 0]) continue - # M. Kelly, SIAM Review (2017), equation (3.2), i = k+1 + # From M. Kelly, SIAM Review (2017), equation (3.2), i = k+1 # x[k+1] - x[k] = 0.5 hk (f(x[k+1], u[k+1] + f(x[k], u[k])) fkp1 = self.system._rhs(t, states[:, i], inputs[:, i]) self.colloc_vals[:, i] = states[:, i] - states[:, i-1] - \ @@ -529,18 +526,20 @@ def _collocation_constraint(self, coeffs): return self.colloc_vals.reshape(-1) # - # Initial guess + # Initial guess processing # # We store an initial guess in case it is not specified later. Note # that create_mpc_iosystem() will reset the initial guess based on # the current state of the MPC controller. # - # Note: the initial guess is passed as the inputs at the given time + # The functions below are used to process the initial guess, which can + # either consist of an input only (for shooting methods) or an input + # and/or state trajectory (for collocaiton methods). + # + # Note: The initial input guess is passed as the inputs at the given time # vector. If a basis is specified, this is converted to coefficient # values (which are generally of smaller dimension). # - # TODO: figure out how to modify this for collocation - # def _process_initial_guess(self, initial_guess): # Sort out the input guess and the state guess if self.collocation and initial_guess is not None and \ @@ -583,6 +582,7 @@ def _process_initial_guess(self, initial_guess): # Reshape for use by scipy.optimize.minimize() return input_guess.reshape(-1) + # Utility function to broadcast an initial guess to the right shape def _broadcast_initial_guess(self, initial_guess, shape): # Convert to a 1D array (or higher) initial_guess = np.atleast_1d(initial_guess) @@ -604,11 +604,11 @@ def _broadcast_initial_guess(self, initial_guess, shape): # # Utility function to convert input vector to coefficient vector # - # Initially guesses from the user are passed as input vectors as a + # Initial guesses from the user are passed as input vectors as a # function of time, but internally we store the guess in terms of the # basis coefficients. We do this by solving a least squares problem to - # find coefficients that match the input functions at the time points (as - # much as possible, if the problem is under-determined). + # find coefficients that match the input functions at the time points + # (as much as possible, if the problem is under-determined). # def _inputs_to_coeffs(self, inputs): # If there is no basis function, just return inputs as coeffs @@ -638,6 +638,7 @@ def _inputs_to_coeffs(self, inputs): # Utility function to convert coefficient vector to input vector def _coeffs_to_inputs(self, coeffs): # TODO: vectorize + # TODO: use BasisFamily eval() method (if more efficient)? inputs = np.zeros((self.system.ninputs, self.timepts.size)) offset = 0 for i in range(self.system.ninputs): @@ -689,7 +690,7 @@ def _print_statistics(self, reset=True): # These internal functions return the states and inputs at the # collocation points given the ceofficient (optimizer state) vector. # They keep track of whether a shooting method is being used or not and - # simulate the dynamis of needed. + # simulate the dynamics if needed. # # Compute the states and inputs from the coefficients @@ -738,6 +739,7 @@ def _simulate_states(self, x0, inputs): if self.log: logging.debug( "input_output_response returned states\n" + str(states)) + return states # @@ -930,16 +932,6 @@ def __init__( ocp._print_statistics() print("* Final cost:", self.cost) - if return_states and inputs.shape[1] == ocp.timepts.shape[0]: - # Simulate the system if we need the state back - # TODO: this is probably not needed due to compute_states_inputs() - _, _, states = ct.input_output_response( - ocp.system, ocp.timepts, inputs, ocp.x, return_x=True, - solve_ivp_kwargs=ocp.solve_ivp_kwargs, t_eval=ocp.timepts) - ocp.system_simulations += 1 - else: - states = None - # Process data as a time response (with "outputs" = inputs) response = TimeResponseData( ocp.timepts, inputs, states, issiso=ocp.system.issiso(), @@ -1065,10 +1057,22 @@ def solve_ocp( 'optimal', 'return_x', kwargs, return_states, pop=True) # Process (legacy) method keyword - if kwargs.get('method') and method not in optimal_methods: - if kwargs.get('minimize_method'): - raise ValueError("'minimize_method' specified more than once") - kwargs['minimize_method'] = kwargs.pop('method') + if kwargs.get('method'): + method = kwargs.pop('method') + if method not in optimal_methods: + if kwargs.get('minimize_method'): + raise ValueError("'minimize_method' specified more than once") + warnings.warn( + "'method' parameter is deprecated; assuming minimize_method", + DeprecationWarning) + kwargs['minimize_method'] = method + else: + if kwargs.get('trajectory_method'): + raise ValueError("'trajectory_method' specified more than once") + warnings.warn( + "'method' parameter is deprecated; assuming trajectory_method", + DeprecationWarning) + kwargs['trajectory_method'] = method # Set up the optimal control problem ocp = OptimalControlProblem( diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 0aded3531..4fc90464a 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -494,12 +494,12 @@ def test_ocp_argument_errors(): # Unrecognized trajectory constraint type constraints = [(None, np.eye(3), [0, 0, 0], [0, 0, 0])] - with pytest.raises(TypeError, match="unknown constraint type"): + with pytest.raises(TypeError, match="unknown trajectory constraint type"): res = opt.solve_ocp( sys, time, x0, cost, trajectory_constraints=constraints) # Unrecognized terminal constraint type - with pytest.raises(TypeError, match="unknown constraint type"): + with pytest.raises(TypeError, match="unknown terminal constraint type"): res = opt.solve_ocp( sys, time, x0, cost, terminal_constraints=constraints) @@ -609,28 +609,28 @@ def final_point_eval(x, u): # Try passing and unknown constraint type final_point = [(None, final_point_eval, [0, 0], [0, 0])] - with pytest.raises(TypeError, match="unknown constraint type"): + with pytest.raises(TypeError, match="unknown terminal constraint type"): optctrl = opt.OptimalControlProblem( sys, time, cost, terminal_constraints=final_point) res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) @pytest.mark.parametrize( - "method, npts, initial_guess", [ - # ('shooting', 3, None), # doesn't converge - # ('shooting', 3, 'zero'), # doesn't converge - ('shooting', 3, 'u0'), # github issue #782 - # ('shooting', 3, 'input'), # precision loss - # ('shooting', 5, 'input'), # precision loss - # ('collocation', 3, 'u0'), # doesn't converge - ('collocation', 5, 'u0'), # from documenentation - ('collocation', 5, 'input'), - ('collocation', 10, 'input'), - ('collocation', 10, 'u0'), - ('collocation', 10, 'state'), - ('collocation', 20, 'state'), + "method, npts, initial_guess, fail", [ + ('shooting', 3, None, 'xfail'), # doesn't converge + ('shooting', 3, 'zero', 'xfail'), # doesn't converge + ('shooting', 3, 'u0', None), # github issue #782 + ('shooting', 3, 'input', 'endpoint'), # doesn't converge to optimal + ('shooting', 5, 'input', 'endpoint'), # doesn't converge to optimal + ('collocation', 3, 'u0', 'endpoint'), # doesn't converge to optimal + ('collocation', 5, 'u0', 'endpoint'), + ('collocation', 5, 'input', 'openloop'),# open loop sim fails + ('collocation', 10, 'input', None), + ('collocation', 10, 'u0', None), # from documenentation + ('collocation', 10, 'state', None), + ('collocation', 20, 'state', None), ]) -def test_optimal_doc(method, npts, initial_guess): +def test_optimal_doc(method, npts, initial_guess, fail): """Test optimal control problem from documentation""" def vehicle_update(t, x, u, params): # Get the parameters for the model @@ -698,25 +698,35 @@ def vehicle_output(t, x, u, params): # Solve the optimal control problem result = opt.solve_ocp( - vehicle, timepts, x0, traj_cost, trajectory_method=method, - # minimize_method='COBYLA', # SLSQP', - # constraints, + vehicle, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, initial_guess=initial_guess, + trajectory_method=method, + # minimize_method='COBYLA', # SLSQP', ) - # Make sure we got a successful result (or precision loss error) - assert result.success or result.status == 2 - - # Make sure the resulting trajectory generated a good solution - resp = ct.input_output_response( - vehicle, timepts, result.inputs, x0, - t_eval=np.linspace(0, Tf, 10)) - t, y = resp - - assert abs(y[0, 0] - x0[0]) < 0.01 - assert abs((y[1, 0] - x0[1]) / x0[1]) < 0.01 - assert abs(y[2, 0]) < 0.01 + if fail == 'xfail': + assert not result.success + pytest.xfail("optimization fails to converge") + elif fail == 'precision': + assert result.status == 2 + pytest.xfail("optimization precision not achieved") + else: + # Make sure the optimization was successful + assert result.success - assert abs((y[0, -1] - xf[0]) / xf[0]) < 0.12 - assert abs((y[1, -1] - xf[1]) / xf[1]) < 0.12 - assert abs(y[2, -1]) < 0.1 + # Make sure we started and stopped at the right spot + if fail == 'endpoint': + pytest.xfail("optimization does not converge to endpoint") + else: + np.testing.assert_almost_equal(result.states[:, 0], x0, decimal=4) + + # Simulate the trajectory to make sure it looks OK + resp = ct.input_output_response( + vehicle, timepts, result.inputs, x0, + t_eval=np.linspace(0, Tf, 10)) + t, y = resp + if fail == 'openloop': + with pytest.raises(AssertionError): + np.testing.assert_almost_equal(y[:,-1], xf, decimal=1) + else: + np.testing.assert_almost_equal(y[:,-1], xf, decimal=1) diff --git a/doc/optimal.rst b/doc/optimal.rst index 107373915..00e39c24f 100644 --- a/doc/optimal.rst +++ b/doc/optimal.rst @@ -215,8 +215,8 @@ moving from the point x = 0 m, y = -2 m, :math:`\theta` = 0 to the point x = 100 m, y = 2 m, :math:`\theta` = 0) over a period of 10 seconds and with a with a starting and ending velocity of 10 m/s:: - x0 = [0., -2., 0.]; u0 = [10., 0.] - xf = [100., 2., 0.]; uf = [10., 0.] + x0 = np.array([0., -2., 0.]); u0 = np.array([10., 0.]) + xf = np.array([100., 2., 0.]); uf = np.array([10., 0.]) Tf = 10 To set up the optimal control problem we design a cost function that diff --git a/doc/steering-optimal.png b/doc/steering-optimal.png index f847923b5f8f86f6901d2d60c95d714f36289f18..518de89a499fff9e76ebfd81d014774e636a44cb 100644 GIT binary patch literal 28691 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 QSVPJVePg{G9h<=a0(3Y8y8r+H diff --git a/examples/steering-optimal.py b/examples/steering-optimal.py index 5661e0f38..778ac3c25 100644 --- a/examples/steering-optimal.py +++ b/examples/steering-optimal.py @@ -57,7 +57,7 @@ def vehicle_output(t, x, u, params): # # Utility function to plot the results # -def plot_results(t, y, u, figure=None, yf=None): +def plot_lanechange(t, y, u, yf=None, figure=None): plt.figure(figure) # Plot the xy trajectory @@ -65,7 +65,7 @@ def plot_results(t, y, u, figure=None, yf=None): plt.plot(y[0], y[1]) plt.xlabel("x [m]") plt.ylabel("y [m]") - if yf: + if yf is not None: plt.plot(yf[0], yf[1], 'ro') # Plot the inputs as a function of time @@ -90,8 +90,8 @@ def plot_results(t, y, u, figure=None, yf=None): # # Initial and final conditions -x0 = [0., -2., 0.]; u0 = [10., 0.] -xf = [100., 2., 0.]; uf = [10., 0.] +x0 = np.array([0., -2., 0.]); u0 = np.array([10., 0.]) +xf = np.array([100., 2., 0.]); uf = np.array([10., 0.]) Tf = 10 # @@ -109,10 +109,13 @@ def plot_results(t, y, u, figure=None, yf=None): quad_cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) # Define the time horizon (and spacing) for the optimization -horizon = np.linspace(0, Tf, 10, endpoint=True) +timepts = np.linspace(0, Tf, 20, endpoint=True) -# Provide an intial guess (will be extended to entire horizon) -bend_left = [10, 0.01] # slight left veer +# Provide an initial guess +straight_line = ( + np.array([x0 + (xf - x0) * time/Tf for time in timepts]).transpose(), + np.outer(u0, np.ones_like(timepts)) +) # Turn on debug level logging so that we can see what the optimizer is doing logging.basicConfig( @@ -122,9 +125,9 @@ def plot_results(t, y, u, figure=None, yf=None): # Compute the optimal control, setting step size for gradient calculation (eps) start_time = time.process_time() result1 = opt.solve_ocp( - vehicle, horizon, x0, quad_cost, initial_guess=bend_left, log=True, - minimize_method='trust-constr', - minimize_options={'finite_diff_rel_step': 0.01}, + vehicle, timepts, x0, quad_cost, initial_guess=straight_line, log=True, + # minimize_method='trust-constr', + # minimize_options={'finite_diff_rel_step': 0.01}, ) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) @@ -132,10 +135,15 @@ def plot_results(t, y, u, figure=None, yf=None): if 'PYCONTROL_TEST_EXAMPLES' in os.environ: assert result1.success -# Extract and plot the results (+ state trajectory) +# Plot the results from the optimization +plot_lanechange(timepts, result1.states, result1.inputs, xf, figure=1) +print("Final computed state: ", result1.states[:,-1]) + +# Simulate the system and see what happens t1, u1 = result1.time, result1.inputs -t1, y1 = ct.input_output_response(vehicle, horizon, u1, x0) -plot_results(t1, y1, u1, figure=1, yf=xf[0:2]) +t1, y1 = ct.input_output_response(vehicle, timepts, u1, x0) +plot_lanechange(t1, y1, u1, yf=xf[0:2], figure=1) +print("Final simulated state:", y1[:,-1]) # # Approach 2: input cost, input constraints, terminal cost @@ -147,7 +155,7 @@ def plot_results(t, y, u, figure=None, yf=None): # # We also set the solver explicitly. # -print("Approach 2: input cost and constraints plus terminal cost") +print("\nApproach 2: input cost and constraints plus terminal cost") # Add input constraint, input cost, terminal cost constraints = [ opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] @@ -159,22 +167,34 @@ def plot_results(t, y, u, figure=None, yf=None): level=logging.INFO, filename="./steering-terminal_cost.log", filemode='w', force=True) +# Use a straight line between initial and final position as initial guesss +input_guess = np.outer(u0, np.ones((1, timepts.size))) +state_guess = np.array([ + x0 + (xf - x0) * time/Tf for time in timepts]).transpose() +straight_line = (state_guess, input_guess) + # Compute the optimal control start_time = time.process_time() result2 = opt.solve_ocp( - vehicle, horizon, x0, traj_cost, constraints, terminal_cost=term_cost, - initial_guess=bend_left, log=True, - minimize_method='SLSQP', minimize_options={'eps': 0.01}) + vehicle, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + initial_guess=straight_line, log=True, + # minimize_method='SLSQP', minimize_options={'eps': 0.01} +) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) # If we are running CI tests, make sure we succeeded if 'PYCONTROL_TEST_EXAMPLES' in os.environ: assert result2.success -# Extract and plot the results (+ state trajectory) +# Plot the results from the optimization +plot_lanechange(timepts, result2.states, result2.inputs, xf, figure=2) +print("Final computed state: ", result2.states[:,-1]) + +# Simulate the system and see what happens t2, u2 = result2.time, result2.inputs -t2, y2 = ct.input_output_response(vehicle, horizon, u2, x0) -plot_results(t2, y2, u2, figure=2, yf=xf[0:2]) +t2, y2 = ct.input_output_response(vehicle, timepts, u2, x0) +plot_lanechange(t2, y2, u2, yf=xf[0:2], figure=2) +print("Final simulated state:", y2[:,-1]) # # Approach 3: terminal constraints @@ -183,7 +203,7 @@ def plot_results(t, y, u, figure=None, yf=None): # with a terminal *constraint* on the state. If a solution is found, # it guarantees we get to exactly the final state. # -print("Approach 3: terminal constraints") +print("\nApproach 3: terminal constraints") # Input cost and terminal constraints R = np.diag([1, 1]) # minimize applied inputs @@ -200,10 +220,10 @@ def plot_results(t, y, u, figure=None, yf=None): # Compute the optimal control start_time = time.process_time() result3 = opt.solve_ocp( - vehicle, horizon, x0, cost3, constraints, - terminal_constraints=terminal, initial_guess=bend_left, log=False, - solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, - minimize_method='trust-constr', + vehicle, timepts, x0, cost3, constraints, + terminal_constraints=terminal, initial_guess=straight_line, log=False, + # solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, + # minimize_method='trust-constr', ) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) @@ -211,10 +231,15 @@ def plot_results(t, y, u, figure=None, yf=None): if 'PYCONTROL_TEST_EXAMPLES' in os.environ: assert result3.success -# Extract and plot the results (+ state trajectory) +# Plot the results from the optimization +plot_lanechange(timepts, result3.states, result3.inputs, xf, figure=3) +print("Final computed state: ", result3.states[:,-1]) + +# Simulate the system and see what happens t3, u3 = result3.time, result3.inputs -t3, y3 = ct.input_output_response(vehicle, horizon, u3, x0) -plot_results(t3, y3, u3, figure=3, yf=xf[0:2]) +t3, y3 = ct.input_output_response(vehicle, timepts, u3, x0) +plot_lanechange(t3, y3, u3, yf=xf[0:2], figure=3) +print("Final simulated state:", y3[:,-1]) # # Approach 4: terminal constraints w/ basis functions @@ -224,20 +249,20 @@ def plot_results(t, y, u, figure=None, yf=None): # Here we parameterize the input by a set of 4 Bezier curves but solve # for a much more time resolved set of inputs. -print("Approach 4: Bezier basis") +print("\nApproach 4: Bezier basis") import control.flatsys as flat # Compute the optimal control start_time = time.process_time() result4 = opt.solve_ocp( - vehicle, horizon, x0, quad_cost, + vehicle, timepts, x0, quad_cost, constraints, terminal_constraints=terminal, - initial_guess=bend_left, - basis=flat.BezierFamily(4, T=Tf), + initial_guess=straight_line, + basis=flat.BezierFamily(6, T=Tf), # solve_ivp_kwargs={'method': 'RK45', 'atol': 1e-2, 'rtol': 1e-2}, - solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, - minimize_method='trust-constr', minimize_options={'disp': True}, + # solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, + # minimize_method='trust-constr', minimize_options={'disp': True}, log=False ) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) @@ -246,10 +271,15 @@ def plot_results(t, y, u, figure=None, yf=None): if 'PYCONTROL_TEST_EXAMPLES' in os.environ: assert result4.success -# Extract and plot the results (+ state trajectory) +# Plot the results from the optimization +plot_lanechange(timepts, result4.states, result4.inputs, xf, figure=4) +print("Final computed state: ", result3.states[:,-1]) + +# Simulate the system and see what happens t4, u4 = result4.time, result4.inputs -t4, y4 = ct.input_output_response(vehicle, horizon, u4, x0) -plot_results(t4, y4, u4, figure=4, yf=xf[0:2]) +t4, y4 = ct.input_output_response(vehicle, timepts, u4, x0) +plot_lanechange(t4, y4, u4, yf=xf[0:2], figure=4) +print("Final simulated state: ", y4[:,-1]) # If we are not running CI tests, display the results if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: From e0f71f4c743cc60a8076546a187a96ad0dd2c55d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 25 Nov 2022 22:01:45 -0800 Subject: [PATCH 5/6] update documentation + allow constraints in scipy.optimize form --- control/optimal.py | 97 +++++++++++++++++++++++------------ control/tests/optimal_test.py | 10 ++-- doc/optimal.rst | 12 ++--- 3 files changed, 74 insertions(+), 45 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index 0478505bc..377a6972e 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -50,32 +50,33 @@ class OptimalControlProblem(): integral_cost : callable Function that returns the integral cost given the current state and input. Called as integral_cost(x, u). - trajectory_constraints : list of tuples, optional + trajectory_constraints : list of constraints, optional List of constraints that should hold at each point in the time - vector. Each element of the list should consist of a tuple with - first element given by :meth:`~scipy.optimize.LinearConstraint` or - :meth:`~scipy.optimize.NonlinearConstraint` and the remaining - elements of the tuple are the arguments that would be passed to - those functions. The constraints will be applied at each time - point along the trajectory. + vector. Each element of the list should be an object of type + :class:`~scipy.optimize.LinearConstraint` with arguments `(A, lb, + ub)` or :class:`~scipy.optimize.NonlinearConstraint` with arguments + `(fun, lb, ub)`. The constraints will be applied at each time point + along the trajectory. terminal_cost : callable, optional Function that returns the terminal cost given the current state and input. Called as terminal_cost(x, u). - initial_guess : 1D or 2D array_like - Initial inputs to use as a guess for the optimal input. The - inputs should either be a 2D vector of shape (ninputs, horizon) - or a 1D input of shape (ninputs,) that will be broadcast by - extension of the time axis. trajectory_method : string, optional Method to use for carrying out the optimization. Currently supported methods are 'shooting' and 'collocation' (continuous time only). The default value is 'shooting' for discrete time systems and 'collocation' for continuous time systems + initial_guess : (tuple of) 1D or 2D array_like + Initial states and/or inputs to use as a guess for the optimal + trajectory. For shooting methods, an array of inputs for each time + point should be specified. For collocation methods, the initial + guess is either the input vector or a tuple consisting guesses for + the state and the input. Guess should either be a 2D vector of + shape (ninputs, ntimepts) or a 1D input of shape (ninputs,) that + will be broadcast by extension of the time axis. log : bool, optional If `True`, turn on logging messages (using Python logging module). - Use ``logging.basicConfig`` to enable logging output (e.g., to a file). - kwargs : dict, optional - Additional parameters (passed to :func:`scipy.optimal.minimize`). + Use :py:func:`logging.basicConfig` to enable logging output + (e.g., to a file). Returns ------- @@ -83,11 +84,14 @@ class OptimalControlProblem(): Optimal control problem object, to be used in computing optimal controllers. - Additional parameters - --------------------- + Other Parameters + ---------------- basis : BasisFamily, optional Use the given set of basis functions for the inputs instead of setting the value of the input at each point in the timepts vector. + terminal_constraints : list of constraints, optional + List of constraints that should hold at the terminal point in time, + in the same form as `trajectory_constraints`. solve_ivp_method : str, optional Set the method used by :func:`scipy.integrate.solve_ivp`. solve_ivp_kwargs : str, optional @@ -182,20 +186,6 @@ def __init__( if kwargs: raise TypeError("unrecognized keyword(s): ", str(kwargs)) - # Process trajectory constraints - def _process_constraints(constraint_list, name): - if isinstance(constraint_list, tuple): - constraint_list = [constraint_list] - elif not isinstance(constraint_list, list): - raise TypeError(f"{name} constraints must be a list") - - # Make sure that we recognize all of the constraint types - for ctype, fun, lb, ub in constraint_list: - if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: - raise TypeError(f"unknown {name} constraint type {ctype}") - - return constraint_list - self.trajectory_constraints = _process_constraints( trajectory_constraints, "trajectory") self.terminal_constraints = _process_constraints( @@ -1017,9 +1007,6 @@ def solve_ocp( If True, assume that 2D input arrays are transposed from the standard format. Used to convert MATLAB-style inputs to our format. - kwargs : dict, optional - Additional parameters (passed to :func:`scipy.optimal.minimize`). - Returns ------- res : OptimalControlResult @@ -1456,3 +1443,45 @@ def _evaluate_output_range_constraint(x, u): # Return a nonlinear constraint object based on the polynomial return (opt.NonlinearConstraint, _evaluate_output_range_constraint, lb, ub) + +# +# Utility functions +# + +# +# Process trajectory constraints +# +# Constraints were originally specified as a tuple with the type of +# constraint followed by the arguments. However, they are now specified +# directly as SciPy constraint objects. +# +# The _process_constraints() function will covert everything to a consistent +# internal representation (currently a tuple with the constraint type as the +# first element. +# +def _process_constraints(clist, name): + if isinstance( + clist, (tuple, opt.LinearConstraint, opt.NonlinearConstraint)): + clist = [clist] + elif not isinstance(clist, list): + raise TypeError(f"{name} constraints must be a list") + + # Process individual list elements + constraint_list = [] + for constraint in clist: + if isinstance(constraint, tuple): + # Original style of constraint + ctype, fun, lb, ub = constraint + if not ctype in [opt.LinearConstraint, opt.NonlinearConstraint]: + raise TypeError(f"unknown {name} constraint type {ctype}") + constraint_list.append(constraint) + elif isinstance(constraint, opt.LinearConstraint): + constraint_list.append( + (opt.LinearConstraint, constraint.A, + constraint.lb, constraint.ub)) + elif isinstance(constraint, opt.NonlinearConstraint): + constraint_list.append( + (opt.NonlinearConstraint, constraint.fun, + constraint.lb, constraint.ub)) + + return constraint_list diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 4fc90464a..c76859dfc 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -64,7 +64,7 @@ def test_finite_horizon_simple(method): # State and input constraints constraints = [ - (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + sp.optimize.LinearConstraint(np.eye(3), [-5, -5, -1], [5, 5, 1]), ] # Quadratic state and input penalty @@ -148,7 +148,7 @@ def test_discrete_lqr(): # Add state and input constraints trajectory_constraints = [ - (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -.5], [5, 5, 0.5]), + sp.optimize.LinearConstraint(np.eye(3), [-5, -5, -.5], [5, 5, 0.5]), ] # Re-solve @@ -461,7 +461,7 @@ def test_ocp_argument_errors(): # State and input constraints constraints = [ - (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + sp.optimize.LinearConstraint(np.eye(3), [-5, -5, -1], [5, 5, 1]), ] # Quadratic state and input penalty @@ -522,7 +522,7 @@ def test_optimal_basis_simple(basis): # State and input constraints constraints = [ - (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + sp.optimize.LinearConstraint(np.eye(3), [-5, -5, -1], [5, 5, 1]), ] # Quadratic state and input penalty @@ -594,7 +594,7 @@ def test_equality_constraints(): def final_point_eval(x, u): return x final_point = [ - (sp.optimize.NonlinearConstraint, final_point_eval, [0, 0], [0, 0])] + sp.optimize.NonlinearConstraint(final_point_eval, [0, 0], [0, 0])] optctrl = opt.OptimalControlProblem( sys, time, cost, terminal_constraints=final_point) diff --git a/doc/optimal.rst b/doc/optimal.rst index 00e39c24f..3033b38fc 100644 --- a/doc/optimal.rst +++ b/doc/optimal.rst @@ -125,16 +125,16 @@ parameter can be used to specify a cost function for the final point in the trajectory. The `constraints` parameter is a list of constraints similar to that used by -the :func:`scipy.optimize.minimize` function. Each constraint is a tuple of -one of the following forms:: +the :func:`scipy.optimize.minimize` function. Each constraint is specified +using one of the following forms:: - (LinearConstraint, A, lb, ub) - (NonlinearConstraint, f, lb, ub) + LinearConstraint(A, lb, ub) + NonlinearConstraint(f, lb, ub) For a linear constraint, the 2D array `A` is multiplied by a vector consisting of the current state `x` and current input `u` stacked -vertically, then compared with the upper and lower bound. This constrain is -satisfied if +vertically, then compared with the upper and lower bound. This constraint +is satisfied if .. code:: python From dd5e42d16e81e5a1e30899f8bf277ee85282bf91 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 26 Nov 2022 17:08:45 -0800 Subject: [PATCH 6/6] add benchmarks for shooting vs collocation --- benchmarks/optimal_bench.py | 38 ++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/benchmarks/optimal_bench.py b/benchmarks/optimal_bench.py index a424c7827..997b5a241 100644 --- a/benchmarks/optimal_bench.py +++ b/benchmarks/optimal_bench.py @@ -35,6 +35,7 @@ 'COBYLA': ('COBYLA', {}), } + # Utility function to create a basis of a given size def get_basis(name, size, Tf): if name == 'poly': @@ -43,14 +44,13 @@ def get_basis(name, size, Tf): basis = fs.BezierFamily(size, T=Tf) elif name == 'bspline': basis = fs.BSplineFamily([0, Tf/2, Tf], size) + else: + basis = None return basis -# -# Optimal trajectory generation with linear quadratic cost -# - -def time_optimal_lq_basis(basis_name, basis_size, npoints): +# Assess performance as a function of basis type and size +def time_optimal_lq_basis(basis_name, basis_size, npoints, method): # Create a sufficiently controllable random system to control ntrys = 20 while ntrys > 0: @@ -90,18 +90,20 @@ def time_optimal_lq_basis(basis_name, basis_size, npoints): res = opt.solve_ocp( sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, - basis=basis, + basis=basis, trajectory_method=method, ) # Only count this as a benchmark if we converged assert res.success # Parameterize the test against different choices of integrator and minimizer -time_optimal_lq_basis.param_names = ['basis', 'size', 'npoints'] +time_optimal_lq_basis.param_names = ['basis', 'size', 'npoints', 'method'] time_optimal_lq_basis.params = ( - ['poly', 'bezier', 'bspline'], [8, 10, 12], [5, 10, 20]) + [None, 'poly', 'bezier', 'bspline'], + [4, 8], [5, 10], ['shooting', 'collocation']) -def time_optimal_lq_methods(integrator_name, minimizer_name): +# Assess performance as a function of optimization and integration methods +def time_optimal_lq_methods(integrator_name, minimizer_name, method): # Get the integrator and minimizer parameters to use integrator = integrator_table[integrator_name] minimizer = minimizer_table[minimizer_name] @@ -134,17 +136,20 @@ def time_optimal_lq_methods(integrator_name, minimizer_name): sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, solve_ivp_method=integrator[0], solve_ivp_kwargs=integrator[1], minimize_method=minimizer[0], minimize_options=minimizer[1], + trajectory_method=method, ) # Only count this as a benchmark if we converged assert res.success # Parameterize the test against different choices of integrator and minimizer -time_optimal_lq_methods.param_names = ['integrator', 'minimizer'] +time_optimal_lq_methods.param_names = ['integrator', 'minimizer', 'method'] time_optimal_lq_methods.params = ( - ['RK23', 'RK45', 'LSODA'], ['trust', 'SLSQP', 'COBYLA']) + ['RK23', 'RK45', 'LSODA'], ['trust', 'SLSQP', 'COBYLA'], + ['shooting', 'collocation']) -def time_optimal_lq_size(nstates, ninputs, npoints): +# Assess performance as a function system size +def time_optimal_lq_size(nstates, ninputs, npoints, method): # Create a sufficiently controllable random system to control ntrys = 20 while ntrys > 0: @@ -181,19 +186,18 @@ def time_optimal_lq_size(nstates, ninputs, npoints): res = opt.solve_ocp( sys, timepts, x0, traj_cost, constraints, terminal_cost=term_cost, + trajectory_method=method, ) # Only count this as a benchmark if we converged assert res.success # Parameterize the test against different choices of integrator and minimizer -time_optimal_lq_size.param_names = ['nstates', 'ninputs', 'npoints'] -time_optimal_lq_size.params = ([1, 2, 4], [1, 2, 4], [5, 10, 20]) +time_optimal_lq_size.param_names = ['nstates', 'ninputs', 'npoints', 'method'] +time_optimal_lq_size.params = ( + [2, 4], [2, 4], [10, 20], ['shooting', 'collocation']) -# # Aircraft MPC example (from multi-parametric toolbox) -# - def time_discrete_aircraft_mpc(minimizer_name): # model of an aircraft discretized with 0.2s sampling time # Source: https://www.mpt3.org/UI/RegulationProblem