From 00297d6881aa423c5dd30143e4b539691984d7c0 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 28 Dec 2020 08:07:10 -0800 Subject: [PATCH 01/18] draft unit test --- control/tests/obc_test.py | 164 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 control/tests/obc_test.py diff --git a/control/tests/obc_test.py b/control/tests/obc_test.py new file mode 100644 index 000000000..9b3260d44 --- /dev/null +++ b/control/tests/obc_test.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python +# +# obc_test.py - tests for optimization based control +# RMM, 17 Apr 2019 +# +# This test suite checks the functionality for optimization based control. + +import unittest +import warnings +import numpy as np +import scipy as sp +import control as ct +import control.pwa as pwa +import polytope as pc + +class TestOBC(unittest.TestCase): + def setUp(self): + # Turn off numpy matrix warnings + import warnings + warnings.simplefilter('ignore', category=PendingDeprecationWarning) + + def test_finite_horizon_mpc_simple(self): + # Define a linear system with constraints + # Source: https://www.mpt3.org/UI/RegulationProblem + + # LTI prediction model + model = pwa.ConstrainedAffineSystem( + A = [[1, 1], [0, 1]], B = [[1], [0.5]], C = np.eye(2)) + + # state and input constraints + model.add_state_constraints(pc.box2poly([[-5, 5], [-5, 5]])) + model.add_input_constraints(pc.box2poly([-1, 1])) + + # quadratic state and input penalty + Q = [[1, 0], [0, 1]] + R = [[1]] + + # Create a model predictive controller system + mpc = obc.ModelPredictiveController( + model, + obc.QuadraticCost(Q, R), + horizon=5) + + # Optimal control input for a given value of the initial state: + x0 = [4, 0] + u = mpc.compute_input(x0) + self.assertEqual(u, -1) + + # retrieve the full open-loop predictions + (u_openloop, feasible, openloop) = mpc.compute_trajectory(x0) + np.testing.assert_array_almost_equal( + u_openloop, [-1, -1, 0.1393, 0.3361, -5.2042e-16]) + + # convert it to an explicit form + mpc_explicit = mpc.explicit(); + + # Test explicit controller + (u_explicit, feasible, openloop) = mpc_explicit(x0) + np.testing.assert_array_almost_equal(u_openloop, u_explicit) + + @unittest.skipIf(True, "Not yet implemented.") + def test_finite_horizon_mpc_oscillator(self): + # oscillator model defined in 2D + # Source: https://www.mpt3.org/UI/RegulationProblem + A = [[0.5403, -0.8415], [0.8415, 0.5403]] + B = [[-0.4597], [0.8415]] + C = [[1, 0]] + D = [[0]] + + # Linear discrete-time model with sample time 1 + sys = ss(A, B, C, D, 1); + model = LTISystem(sys); + + # state and input constraints + model.add_state_constraints(pc.box2poly([[-10, 10]])) + model.add_input_constraints(pc.box2poly([-1, 1])) + + # Include weights on states/inputs + model.x.penalty = QuadFunction(np.eye(2)); + model.u.penalty = QuadFunction(1); + + # Compute terminal set + Tset = model.LQRSet; + + # Compute terminal weight + PN = model.LQRPenalty; + + # Add terminal set and terminal penalty + # model.x.with('terminalSet'); + model.x.terminalSet = Tset; + # model.x.with('terminalPenalty'); + model.x.terminalPenalty = PN; + + # Formulate finite horizon MPC problem + ctrl = MPCController(model,5); + + # Add tests to make sure everything works + + # TODO: move this to examples? + @unittest.skipIf(True, "Not yet implemented.") + def test_finite_horizon_mpc_oscillator(self): + # model of an aircraft discretized with 0.2s sampling time + # Source: https://www.mpt3.org/UI/RegulationProblem + A = [[0.99, 0.01, 0.18, -0.09, 0], + [ 0, 0.94, 0, 0.29, 0], + [ 0, 0.14, 0.81, -0.9, 0] + [ 0, -0.2, 0, 0.95, 0], + [ 0, 0.09, 0, 0, 0.9]] + B = [[ 0.01, -0.02], + [-0.14, 0], + [ 0.05, -0.2], + [ 0.02, 0], + [-0.01, 0]] + C = [[0, 1, 0, 0, -1], + [0, 0, 1, 0, 0], + [0, 0, 0, 1, 0], + [1, 0, 0, 0, 0]] + model = LTISystem('A', A, 'B', B, 'C', C, 'Ts', 0.2); + + # compute the new steady state values for a particular value + # of the input + us = [0.8, -0.3]; + # ys = C*( (eye(5)-A)\B*us ); + + # computed values will be used as references for the desired + # steady state which can be added using "reference" filter + # model.u.with('reference'); + model.u.reference = us; + # model.y.with('reference'); + model.y.reference = ys; + + # provide constraints and penalties on the system signals + model.u.min = [-5, -6]; + model.u.max = [5, 6]; + + # penalties on outputs and inputs are provided as quadratic functions + model.u.penalty = QuadFunction( diag([3, 2]) ); + model.y.penalty = QuadFunction( diag([10, 10, 10, 10]) ); + + # online MPC controller object is constructed with a horizon 6 + ctrl = MPCController(model, 6) + + # loop = ClosedLoop(ctrl, model); + x0 = [0, 0, 0, 0, 0]; + Nsim = 30; + data = loop.simulate(x0, Nsim); + + # Plot the results + subplot(2,1,1) + plot(np.range(Nsim), data.Y); + plot(np.range(Nsim), ys*ones(1, Nsim), 'k--') + title('outputs') + subplot(2,1,2) + plot(np.range(Nsim), data.U); + plot(np.range(Nsim), us*ones(1, Nsim), 'k--') + title('inputs') + + +def suite(): + return unittest.TestLoader().loadTestsFromTestCase(TestTimeresp) + + +if __name__ == '__main__': + unittest.main() From 23cb793909b7bcb1cd040c8d4fb0971109d7a0c9 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Wed, 30 Dec 2020 13:50:46 -0800 Subject: [PATCH 02/18] convert to pytest --- control/tests/obc_test.py | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/control/tests/obc_test.py b/control/tests/obc_test.py index 9b3260d44..858989d2a 100644 --- a/control/tests/obc_test.py +++ b/control/tests/obc_test.py @@ -1,11 +1,10 @@ -#!/usr/bin/env python -# -# obc_test.py - tests for optimization based control -# RMM, 17 Apr 2019 -# -# This test suite checks the functionality for optimization based control. - -import unittest +"""obc_test.py - tests for optimization based control + +RMM, 17 Apr 2019 check the functionality for optimization based control. +RMM, 30 Dec 2020 convert to pytest +""" + +import pytest import warnings import numpy as np import scipy as sp @@ -13,7 +12,7 @@ import control.pwa as pwa import polytope as pc -class TestOBC(unittest.TestCase): +class TestOBC: def setUp(self): # Turn off numpy matrix warnings import warnings @@ -154,11 +153,3 @@ def test_finite_horizon_mpc_oscillator(self): plot(np.range(Nsim), data.U); plot(np.range(Nsim), us*ones(1, Nsim), 'k--') title('inputs') - - -def suite(): - return unittest.TestLoader().loadTestsFromTestCase(TestTimeresp) - - -if __name__ == '__main__': - unittest.main() From 8711900e309e1c9a9f589383aa2982df52c82669 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 12 Feb 2021 19:51:09 -0800 Subject: [PATCH 03/18] initial minimal implementation (working) --- control/obc.py | 217 ++++++++++++++++++++++++++++++++++++++ control/tests/obc_test.py | 214 +++++++++++++------------------------ 2 files changed, 289 insertions(+), 142 deletions(-) create mode 100644 control/obc.py diff --git a/control/obc.py b/control/obc.py new file mode 100644 index 000000000..3c502d2d2 --- /dev/null +++ b/control/obc.py @@ -0,0 +1,217 @@ +# obc.py - optimization based control module +# +# RMM, 11 Feb 2021 +# + +"""The "mod:`~control.obc` module provides support for optimization-based +controllers for nonlinear systems with state and input constraints. + +""" + +import numpy as np +import scipy as sp +import scipy.optimize as opt +import control as ct +import warnings + +from .timeresp import _process_time_response + +class ModelPredictiveController(): + """The :class:`ModelPredictiveController` class is a front end for computing + an optimal control input for a nonilinear system with a user-defined cost + function and state and input constraints. + + """ + def __init__( + self, sys, time, integral_cost, trajectory_constraints=[], + terminal_cost=None, terminal_constraints=[]): + + self.system = sys + self.time_vector = time + self.integral_cost = integral_cost + self.trajectory_constraints = trajectory_constraints + self.terminal_cost = terminal_cost + self.terminal_constraints = terminal_constraints + + # + # The approach that we use here is to set up an optimization over the + # inputs at each point in time, using the integral and terminal costs + # as well as the trajectory and terminal constraints. The main work + # of this method is to create the optimization problem that can be + # solved with scipy.optimize.minimize(). + # + + # Gather together all of the constraints + constraint_lb, constraint_ub = [], [] + for time in self.time_vector: + for constraint in self.trajectory_constraints: + type, fun, lb, ub = constraint + constraint_lb.append(lb) + constraint_ub.append(ub) + for constraint in self.terminal_constraints: + type, fun, lb, ub = constraint + constraint_lb.append(lb) + constraint_ub.append(ub) + + # Turn constraint vectors into 1D arrays + self.constraint_lb = np.hstack(constraint_lb) + self.constraint_ub = np.hstack(constraint_ub) + + # Create the new constraint + self.constraints = sp.optimize.NonlinearConstraint( + self.constraint_function, self.constraint_lb, self.constraint_ub) + + # Initial guess + self.initial_guess = np.zeros( + self.system.ninputs * self.time_vector.size) + + # + # 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: + # + # Cost = sum_k integral_cost(x[k], u[k]) + terminal_cost(x[N], u[N]) + # + def cost_function(self, inputs): + # Reshape the input vector + inputs = inputs.reshape( + (self.system.ninputs, self.time_vector.size)) + + # Simulate the system to get the state + _, _, states = ct.input_output_response( + self.system, self.time_vector, inputs, self.x, return_x=True) + + # Trajectory cost + # TODO: vectorize + cost = 0 + for i, time in enumerate(self.time_vector): + cost += self.integral_cost(states[:,i], inputs[:,i]) + + # Terminal cost + if self.terminal_cost is not None: + cost += self.terminal_cost(states[:,-1], inputs[:,-1]) + + # Return the total cost for this input sequence + return cost + + # + # Constraints + # + # We are given the constraints along the trajectory and the terminal + # constraints, which each take inputs [x, u] and evaluate the + # constraint. How we handle these depends on the type of constraint: + # + # We have stored the form of the constraint at a single point, but we + # now need to extend this to apply to each point in the trajectory. + # This means that we need to create N constraints, each of which 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. We also replicate the upper and lower + # bounds for each point in time. + # + + # Define a function to evaluate all of the constraints + def constraint_function(self, inputs): + # Reshape the input vector + inputs = inputs.reshape( + (self.system.ninputs, self.time_vector.size)) + + # Simulate the system to get the state + _, _, states = ct.input_output_response( + self.system, self.time_vector, inputs, self.x, return_x=True) + + value = [] + for i, time in enumerate(self.time_vector): + for constraint in self.trajectory_constraints: + type, fun, lb, ub = constraint + if type == opt.LinearConstraint: + value.append( + np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) + else: + raise TypeError("unknown constraint type %s" % + constraint[0]) + + for constraint in self.terminal_constraints: + type, fun, lb, ub = constraint + if type == opt.LinearConstraint: + value.append( + np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) + else: + raise TypeError("unknown constraint type %s" % + constraint[0]) + + # Return the value of the constraint function + return np.hstack(value) + + def __call__(self, x): + """Compute the optimal input at state x""" + # Store the starting point + # TODO: call compute_trajectory? + self.x = x + + # Call ScipPy optimizer + res = sp.optimize.minimize( + self.cost_function, self.initial_guess, + constraints=self.constraints) + + # Return the result + if res.success: + return res.x[0] + else: + warnings.warn(res.message) + return None + + def compute_trajectory( + self, x, squeeze=None, transpose=None, return_x=None): + """Compute the optimal input at state x""" + # Store the starting point + self.x = x + + # Call ScipPy optimizer + res = sp.optimize.minimize( + self.cost_function, self.initial_guess, + constraints=self.constraints) + + # See if we got an answer + if not res.success: + warnings.warn(res.message) + return None + + # Reshape the input vector + inputs = res.x.reshape( + (self.system.ninputs, self.time_vector.size)) + + return _process_time_response( + self.system, self.time_vector, inputs, None, + transpose=transpose, return_x=return_x, squeeze=squeeze) + +def state_poly_constraint(sys, polytope): + """Create state constraint from polytope""" + # TODO: make sure the system and constraints are compatible + + # Return a linear constraint object based on the polynomial + return (opt.LinearConstraint, + np.hstack( + [polytope.A, np.zeros((polytope.A.shape[0], sys.ninputs))]), + np.full(polytope.A.shape[0], -np.inf), polytope.b) + + +def input_poly_constraint(sys, polytope): + """Create input constraint from polytope""" + # TODO: make sure the system and constraints are compatible + + # Return a linear constraint object based on the polynomial + return (opt.LinearConstraint, + np.hstack( + [np.zeros((polytope.A.shape[0], sys.nstates)), polytope.A]), + np.full(polytope.A.shape[0], -np.inf), polytope.b) + + +def quadratic_cost(sys, Q, R): + """Create quadratic cost function""" + return lambda x, u: x @ Q @ x + u @ R @ u diff --git a/control/tests/obc_test.py b/control/tests/obc_test.py index 858989d2a..b340d2c12 100644 --- a/control/tests/obc_test.py +++ b/control/tests/obc_test.py @@ -9,147 +9,77 @@ import numpy as np import scipy as sp import control as ct -import control.pwa as pwa +import control.obc as obc import polytope as pc -class TestOBC: - def setUp(self): - # Turn off numpy matrix warnings - import warnings - warnings.simplefilter('ignore', category=PendingDeprecationWarning) - - def test_finite_horizon_mpc_simple(self): - # Define a linear system with constraints - # Source: https://www.mpt3.org/UI/RegulationProblem - - # LTI prediction model - model = pwa.ConstrainedAffineSystem( - A = [[1, 1], [0, 1]], B = [[1], [0.5]], C = np.eye(2)) - - # state and input constraints - model.add_state_constraints(pc.box2poly([[-5, 5], [-5, 5]])) - model.add_input_constraints(pc.box2poly([-1, 1])) - - # quadratic state and input penalty - Q = [[1, 0], [0, 1]] - R = [[1]] - - # Create a model predictive controller system - mpc = obc.ModelPredictiveController( - model, - obc.QuadraticCost(Q, R), - horizon=5) - - # Optimal control input for a given value of the initial state: - x0 = [4, 0] - u = mpc.compute_input(x0) - self.assertEqual(u, -1) - - # retrieve the full open-loop predictions - (u_openloop, feasible, openloop) = mpc.compute_trajectory(x0) - np.testing.assert_array_almost_equal( - u_openloop, [-1, -1, 0.1393, 0.3361, -5.2042e-16]) - - # convert it to an explicit form - mpc_explicit = mpc.explicit(); - - # Test explicit controller - (u_explicit, feasible, openloop) = mpc_explicit(x0) - np.testing.assert_array_almost_equal(u_openloop, u_explicit) - - @unittest.skipIf(True, "Not yet implemented.") - def test_finite_horizon_mpc_oscillator(self): - # oscillator model defined in 2D - # Source: https://www.mpt3.org/UI/RegulationProblem - A = [[0.5403, -0.8415], [0.8415, 0.5403]] - B = [[-0.4597], [0.8415]] - C = [[1, 0]] - D = [[0]] - - # Linear discrete-time model with sample time 1 - sys = ss(A, B, C, D, 1); - model = LTISystem(sys); - - # state and input constraints - model.add_state_constraints(pc.box2poly([[-10, 10]])) - model.add_input_constraints(pc.box2poly([-1, 1])) - - # Include weights on states/inputs - model.x.penalty = QuadFunction(np.eye(2)); - model.u.penalty = QuadFunction(1); - - # Compute terminal set - Tset = model.LQRSet; - - # Compute terminal weight - PN = model.LQRPenalty; - - # Add terminal set and terminal penalty - # model.x.with('terminalSet'); - model.x.terminalSet = Tset; - # model.x.with('terminalPenalty'); - model.x.terminalPenalty = PN; - - # Formulate finite horizon MPC problem - ctrl = MPCController(model,5); - - # Add tests to make sure everything works - - # TODO: move this to examples? - @unittest.skipIf(True, "Not yet implemented.") - def test_finite_horizon_mpc_oscillator(self): - # model of an aircraft discretized with 0.2s sampling time - # Source: https://www.mpt3.org/UI/RegulationProblem - A = [[0.99, 0.01, 0.18, -0.09, 0], - [ 0, 0.94, 0, 0.29, 0], - [ 0, 0.14, 0.81, -0.9, 0] - [ 0, -0.2, 0, 0.95, 0], - [ 0, 0.09, 0, 0, 0.9]] - B = [[ 0.01, -0.02], - [-0.14, 0], - [ 0.05, -0.2], - [ 0.02, 0], - [-0.01, 0]] - C = [[0, 1, 0, 0, -1], - [0, 0, 1, 0, 0], - [0, 0, 0, 1, 0], - [1, 0, 0, 0, 0]] - model = LTISystem('A', A, 'B', B, 'C', C, 'Ts', 0.2); - - # compute the new steady state values for a particular value - # of the input - us = [0.8, -0.3]; - # ys = C*( (eye(5)-A)\B*us ); - - # computed values will be used as references for the desired - # steady state which can be added using "reference" filter - # model.u.with('reference'); - model.u.reference = us; - # model.y.with('reference'); - model.y.reference = ys; - - # provide constraints and penalties on the system signals - model.u.min = [-5, -6]; - model.u.max = [5, 6]; - - # penalties on outputs and inputs are provided as quadratic functions - model.u.penalty = QuadFunction( diag([3, 2]) ); - model.y.penalty = QuadFunction( diag([10, 10, 10, 10]) ); - - # online MPC controller object is constructed with a horizon 6 - ctrl = MPCController(model, 6) - - # loop = ClosedLoop(ctrl, model); - x0 = [0, 0, 0, 0, 0]; - Nsim = 30; - data = loop.simulate(x0, Nsim); - - # Plot the results - subplot(2,1,1) - plot(np.range(Nsim), data.Y); - plot(np.range(Nsim), ys*ones(1, Nsim), 'k--') - title('outputs') - subplot(2,1,2) - plot(np.range(Nsim), data.U); - plot(np.range(Nsim), us*ones(1, Nsim), 'k--') - title('inputs') +def test_finite_horizon_mpc_simple(): + # Define a linear system with constraints + # Source: https://www.mpt3.org/UI/RegulationProblem + + # LTI prediction model + sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) + + # State and input constraints + constraints = [ + obc.state_poly_constraint(sys, pc.box2poly([[-5, 5], [-5, 5]])), + obc.input_poly_constraint(sys, pc.box2poly([[-1, 1]])), + ] + + # Quadratic state and input penalty + Q = [[1, 0], [0, 1]] + R = [[1]] + cost = obc.quadratic_cost(sys, Q, R) + + # Create a model predictive controller system + time = np.arange(0, 5, 1) + mpc = obc.ModelPredictiveController(sys, time, cost, constraints) + + # Optimal control input for a given value of the initial state + x0 = [4, 0] + u = mpc(x0) + np.testing.assert_almost_equal(u, -1) + + # Retrieve the full open-loop predictions + t, u_openloop = mpc.compute_trajectory(x0, squeeze=True) + np.testing.assert_almost_equal( + u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=4) + + # Convert controller to an explicit form (not implemented yet) + # mpc_explicit = mpc.explicit(); + + # Test explicit controller + # u_explicit = mpc_explicit(x0) + # np.testing.assert_array_almost_equal(u_openloop, u_explicit) + +def test_finite_horizon_mpc_oscillator(): + # oscillator model defined in 2D + # Source: https://www.mpt3.org/UI/RegulationProblem + A = [[0.5403, -0.8415], [0.8415, 0.5403]] + B = [[-0.4597], [0.8415]] + C = [[1, 0]] + D = [[0]] + + # Linear discrete-time model with sample time 1 + sys = ct.ss2io(ct.ss(A, B, C, D, 1)) + + # state and input constraints + trajectory_constraints = [ + obc.state_poly_constraint(sys, pc.box2poly([[-10, 10]])), + obc.input_poly_constraint(sys, pc.box2poly([[-1, 1]])) + ] + + # Include weights on states/inputs + Q = np.eye(2) + R = 1 + K, S, E = ct.lqr(A, B, Q, R) + + # Compute the integral and terminal cost + integral_cost = obc.quadratic_cost(sys, Q, R) + terminal_cost = obc.quadratic_cost(sys, S, 0) + + # Formulate finite horizon MPC problem + time = np.arange(0, 5, 5) + mpc = obc.ModelPredictiveController( + sys, time, integral_cost, trajectory_constraints, terminal_cost) + + # Add tests to make sure everything works From 6f9c092b26f0167c5f5f47ccdeb182475a835f5d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 13 Feb 2021 13:57:37 -0800 Subject: [PATCH 04/18] minor refactoring plus additional comments on method --- control/obc.py | 221 ++++++++++++++++++++++++++++++-------- control/tests/obc_test.py | 14 +-- 2 files changed, 186 insertions(+), 49 deletions(-) diff --git a/control/obc.py b/control/obc.py index 3c502d2d2..e96339c39 100644 --- a/control/obc.py +++ b/control/obc.py @@ -16,16 +16,46 @@ from .timeresp import _process_time_response -class ModelPredictiveController(): - """The :class:`ModelPredictiveController` class is a front end for computing - an optimal control input for a nonilinear system with a user-defined cost +# +# OptimalControlProblem class +# +# The OptimalControlProblem class holds all of the information required to +# specify and optimal control problem: the system dynamics, cost function, +# and constraints. As much as possible, the information used to specify an +# optimal control problem matches the notation and terminology of the SciPy +# `optimize.minimize` module, with the hope that this makes it easier to +# remember how to describe a problem. +# +# The approach that we use here is to set up an optimization over the +# inputs at each point in time, using the integral and terminal costs as +# well as the trajectory and terminal constraints. The main function of +# this class is to create an optimization problem that can be solved using +# scipy.optimize.minimize(). +# +# The `cost_function` method takes the information stored here and computes +# the cost of the trajectory generated by the proposed input. It does this +# by calling a user-defined function for the integral_cost given the +# current states and inputs at each point along the trajetory and then +# adding the value of a user-defined terminal cost at the final pint in the +# trajectory. +# +# The `constraint_function` method evaluates the constraint functions along +# the trajectory generated by the proposed input. As in the case of the +# cost function, the constraints are evaluated at the state and input along +# each point on the trjectory. This information is compared against the +# constraint upper and lower bounds. The constraint function is processed +# in the class initializer, so that it only needs to be computed once. +# +class OptimalControlProblem(): + """The :class:`OptimalControlProblem` class is a front end for computing an + optimal control input for a nonilinear system with a user-defined cost function and state and input constraints. """ def __init__( self, sys, time, integral_cost, trajectory_constraints=[], terminal_cost=None, terminal_constraints=[]): - + # Save the basic information for use later self.system = sys self.time_vector = time self.integral_cost = integral_cost @@ -34,20 +64,28 @@ def __init__( self.terminal_constraints = terminal_constraints # - # The approach that we use here is to set up an optimization over the - # inputs at each point in time, using the integral and terminal costs - # as well as the trajectory and terminal constraints. The main work - # of this method is to create the optimization problem that can be - # solved with scipy.optimize.minimize(). + # Compute and store constraints + # + # While the constraints are evaluated during the execution of the + # SciPy optimization method itself, we go ahead and pre-compute the + # `scipy.optimize.NonlinearConstraint` function that will be passed to + # the optimizer on initialization, since it doesn't change. This is + # mainly a matter of computing the lower and upper bound vectors, + # which we need to "stack" to account for the evaluation at each + # trajectory time point plus any terminal constraints (in a way that + # is consistent with the `constraint_function` that is used at + # evaluation time. # - - # Gather together all of the constraints constraint_lb, constraint_ub = [], [] + + # Go through each time point and stack the bounds for time in self.time_vector: for constraint in self.trajectory_constraints: type, fun, lb, ub = constraint constraint_lb.append(lb) constraint_ub.append(ub) + + # Add on the terminal constraints for constraint in self.terminal_constraints: type, fun, lb, ub = constraint constraint_lb.append(lb) @@ -60,8 +98,15 @@ def __init__( # Create the new constraint self.constraints = sp.optimize.NonlinearConstraint( self.constraint_function, self.constraint_lb, self.constraint_ub) - + + # # Initial guess + # + # We store an initial guess (zero input) in case it is not specified + # later. + # + # TODO: add the ability to overwride this when calling the optimizer. + # self.initial_guess = np.zeros( self.system.ninputs * self.time_vector.size) @@ -75,14 +120,18 @@ def __init__( # # Cost = sum_k integral_cost(x[k], u[k]) + terminal_cost(x[N], u[N]) # + # The initial state is for generating the simulation is store in the class + # parameter `x` prior to calling the optimization algorithm. + # def cost_function(self, inputs): - # Reshape the input vector + # Retrieve the initial state and reshape the input vector + x = self.x inputs = inputs.reshape( (self.system.ninputs, self.time_vector.size)) # Simulate the system to get the state _, _, states = ct.input_output_response( - self.system, self.time_vector, inputs, self.x, return_x=True) + self.system, self.time_vector, inputs, x, return_x=True) # Trajectory cost # TODO: vectorize @@ -104,43 +153,71 @@ def cost_function(self, inputs): # constraints, which each take inputs [x, u] and evaluate the # constraint. How we handle these depends on the type of constraint: # - # We have stored the form of the constraint at a single point, but we - # now need to extend this to apply to each point in the trajectory. - # This means that we need to create N constraints, each of which holds - # at a specific point in time, and implements the original constraint. + # * For linear constraints (LinearConstraint), a combined vector of the + # state and input is multiplied by the polytope A matrix for + # comparison against the upper and lower bounds. + # + # * For nonlinear constraints (NonlinearConstraint), a user-specific + # constraint function having the form + # + # constraint_fun(x, u) + # + # is called at each point along the trajectory and compared against the + # upper and lower bounds. + # + # 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 + # that for N time points with m trajectory constraints and p terminal + # constraints we need to compute N*m + p constraints, each of which + # 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. We also replicate the upper and lower - # bounds for each point in time. + # 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. # - - # Define a function to evaluate all of the constraints def constraint_function(self, inputs): - # Reshape the input vector + # Retrieve the initial state and reshape the input vector + x = self.x inputs = inputs.reshape( (self.system.ninputs, self.time_vector.size)) # Simulate the system to get the state _, _, states = ct.input_output_response( - self.system, self.time_vector, inputs, self.x, return_x=True) + self.system, self.time_vector, inputs, x, return_x=True) + # Evaluate the constraint function along the trajectory value = [] for i, time in enumerate(self.time_vector): for constraint in self.trajectory_constraints: type, fun, lb, ub = constraint if type == opt.LinearConstraint: + # `fun` is the A matrix associated with the polytope... value.append( np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) + elif type == opt.NonlinearConstraint: + value.append( + fun(np.hstack([states[:,i], inputs[:,i]]))) else: raise TypeError("unknown constraint type %s" % constraint[0]) + # Evaluate the terminal constraint functions for constraint in self.terminal_constraints: type, fun, lb, ub = constraint if type == opt.LinearConstraint: value.append( np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) + elif type == opt.NonlinearConstraint: + value.append( + fun(np.hstack([states[:,i], inputs[:,i]]))) else: raise TypeError("unknown constraint type %s" % constraint[0]) @@ -148,28 +225,22 @@ def constraint_function(self, inputs): # Return the value of the constraint function return np.hstack(value) - def __call__(self, x): + # Allow optctrl(x) as a replacement for optctrl.mpc(x) + def __call__(self, x, squeeze=None): """Compute the optimal input at state x""" - # Store the starting point - # TODO: call compute_trajectory? - self.x = x - - # Call ScipPy optimizer - res = sp.optimize.minimize( - self.cost_function, self.initial_guess, - constraints=self.constraints) + return self.mpc(x, squeeze=squeeze) - # Return the result - if res.success: - return res.x[0] - else: - warnings.warn(res.message) - return None + # Compute the current input to apply from the current state (MPC style) + def mpc(self, x, squeeze=None): + """Compute the optimal input at state x""" + _, inputs = self.compute_trajectory(x, squeeze=squeeze) + return None if inputs is None else inputs.transpose()[0] + # Compute the optimal trajectory from the current state def compute_trajectory( self, x, squeeze=None, transpose=None, return_x=None): """Compute the optimal input at state x""" - # Store the starting point + # Store the initial state (for use in constraint_function) self.x = x # Call ScipPy optimizer @@ -185,11 +256,33 @@ def compute_trajectory( # Reshape the input vector inputs = res.x.reshape( (self.system.ninputs, self.time_vector.size)) + + if return_x: + # Simulate the system if we need the state back + _, _, states = ct.input_output_response( + self.system, self.time_vector, inputs, x, return_x=True) + else: + states=None return _process_time_response( - self.system, self.time_vector, inputs, None, + self.system, self.time_vector, inputs, states, transpose=transpose, return_x=return_x, squeeze=squeeze) + +# +# Create a polytope constraint on the system state +# +# As in the cost function evaluation, the main "trick" in creating a constrain +# on the state or input is to properly evaluate the constraint on the stacked +# state and input vector at the current time point. The constraint itself +# will be called at each poing along the trajectory (or the endpoint) via the +# constrain_function() method. +# +# Note that these functions to not actually evaluate the constraint, they +# simply return the information required to do so. We use the SciPy +# optimization methods LinearConstraint and NonlinearConstraint as "types" to +# keep things consistent with the terminology in scipy.optimize. +# def state_poly_constraint(sys, polytope): """Create state constraint from polytope""" # TODO: make sure the system and constraints are compatible @@ -200,7 +293,7 @@ def state_poly_constraint(sys, polytope): [polytope.A, np.zeros((polytope.A.shape[0], sys.ninputs))]), np.full(polytope.A.shape[0], -np.inf), polytope.b) - +# Create a constraint polytope on the system input def input_poly_constraint(sys, polytope): """Create input constraint from polytope""" # TODO: make sure the system and constraints are compatible @@ -212,6 +305,48 @@ def input_poly_constraint(sys, polytope): np.full(polytope.A.shape[0], -np.inf), polytope.b) +# +# Create a constraint polytope on the system output +# +# Unlike the state and input constraints, for the output constraint we need to +# do a function evaluation before applying the constraints. +# +# TODO: for the special case of an LTI system, we can avoid the extra function +# call by multiplying the state by the C matrix for the system and then +# imposing a linear constraint: +# +# np.hstack( +# [polytope.A @ sys.C, np.zeros((polytope.A.shape[0], sys.ninputs))]) +# +def output_poly_constraint(sys, polytope): + """Create output constraint from polytope""" + # TODO: make sure the system and constraints are compatible + + # + # Function to create the output + def _evaluate_output_constraint(x): + # Separate the constraint into states and inputs + states = x[:sys.nstates] + inputs = x[sys.nstates:] + outputs = sys._out(0, states, inputs) + return polytope.A @ outputs + + # Return a nonlinear constraint object based on the polynomial + return (opt.NonlinearConstraint, + _evaluate_output_constraint, + np.full(polytope.A.shape[0], -np.inf), polytope.b) + + +# +# Quadratic cost function +# +# Since a quadratic function is common as a cost function, we provide a +# function that will take a Q and R matrix and return a callable that +# evaluates to associted quadratic cost. This is compatible with the way that +# the `cost_function` evaluates the cost at each point in the trajectory. +# def quadratic_cost(sys, Q, R): """Create quadratic cost function""" + Q = np.atleast_2d(Q) + R = np.atleast_2d(R) return lambda x, u: x @ Q @ x + u @ R @ u diff --git a/control/tests/obc_test.py b/control/tests/obc_test.py index b340d2c12..f04e9cc91 100644 --- a/control/tests/obc_test.py +++ b/control/tests/obc_test.py @@ -32,7 +32,8 @@ def test_finite_horizon_mpc_simple(): # Create a model predictive controller system time = np.arange(0, 5, 1) - mpc = obc.ModelPredictiveController(sys, time, cost, constraints) + optctrl = obc.OptimalControlProblem(sys, time, cost, constraints) + mpc = optctrl.mpc # Optimal control input for a given value of the initial state x0 = [4, 0] @@ -40,12 +41,12 @@ def test_finite_horizon_mpc_simple(): np.testing.assert_almost_equal(u, -1) # Retrieve the full open-loop predictions - t, u_openloop = mpc.compute_trajectory(x0, squeeze=True) + t, u_openloop = optctrl.compute_trajectory(x0, squeeze=True) np.testing.assert_almost_equal( u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=4) # Convert controller to an explicit form (not implemented yet) - # mpc_explicit = mpc.explicit(); + # mpc_explicit = obc.explicit_mpc(); # Test explicit controller # u_explicit = mpc_explicit(x0) @@ -64,7 +65,7 @@ def test_finite_horizon_mpc_oscillator(): # state and input constraints trajectory_constraints = [ - obc.state_poly_constraint(sys, pc.box2poly([[-10, 10]])), + obc.output_poly_constraint(sys, pc.box2poly([[-10, 10]])), obc.input_poly_constraint(sys, pc.box2poly([[-1, 1]])) ] @@ -78,8 +79,9 @@ def test_finite_horizon_mpc_oscillator(): terminal_cost = obc.quadratic_cost(sys, S, 0) # Formulate finite horizon MPC problem - time = np.arange(0, 5, 5) - mpc = obc.ModelPredictiveController( + time = np.arange(0, 5, 1) + optctrl = obc.OptimalControlProblem( sys, time, integral_cost, trajectory_constraints, terminal_cost) # Add tests to make sure everything works + t, u_openloop = optctrl.compute_trajectory([1, 1]) From bd322e7befd85e67492d87f87e5291bfcf800357 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 14 Feb 2021 21:59:49 -0800 Subject: [PATCH 05/18] remove polytope dependence; implement MPC iosys w/ notebook example --- control/obc.py | 91 +++++++++++----- control/tests/obc_test.py | 66 ++++++++++-- examples/mpc_aircraft.ipynb | 202 ++++++++++++++++++++++++++++++++++++ 3 files changed, 328 insertions(+), 31 deletions(-) create mode 100644 examples/mpc_aircraft.ipynb diff --git a/control/obc.py b/control/obc.py index e96339c39..ff56fbb43 100644 --- a/control/obc.py +++ b/control/obc.py @@ -103,9 +103,8 @@ def __init__( # Initial guess # # We store an initial guess (zero input) in case it is not specified - # later. - # - # TODO: add the ability to overwride this when calling the optimizer. + # later. Note that create_mpc_iosystem() will reset the initial guess + # based on the current state of the MPC controller. # self.initial_guess = np.zeros( self.system.ninputs * self.time_vector.size) @@ -128,24 +127,25 @@ def cost_function(self, inputs): x = self.x inputs = inputs.reshape( (self.system.ninputs, self.time_vector.size)) - + # Simulate the system to get the state _, _, states = ct.input_output_response( self.system, self.time_vector, inputs, x, return_x=True) - + # Trajectory cost # TODO: vectorize cost = 0 for i, time in enumerate(self.time_vector): - cost += self.integral_cost(states[:,i], inputs[:,i]) - + cost += self.integral_cost(states[:,i], inputs[:,i]) + # Terminal cost if self.terminal_cost is not None: cost += self.terminal_cost(states[:,-1], inputs[:,-1]) - + # Return the total cost for this input sequence return cost + # # Constraints # @@ -188,7 +188,7 @@ def constraint_function(self, inputs): x = self.x inputs = inputs.reshape( (self.system.ninputs, self.time_vector.size)) - + # Simulate the system to get the state _, _, states = ct.input_output_response( self.system, self.time_vector, inputs, x, return_x=True) @@ -234,7 +234,7 @@ def __call__(self, x, squeeze=None): def mpc(self, x, squeeze=None): """Compute the optimal input at state x""" _, inputs = self.compute_trajectory(x, squeeze=squeeze) - return None if inputs is None else inputs.transpose()[0] + return None if inputs is None else inputs[:,0] # Compute the optimal trajectory from the current state def compute_trajectory( @@ -242,7 +242,7 @@ def compute_trajectory( """Compute the optimal input at state x""" # Store the initial state (for use in constraint_function) self.x = x - + # Call ScipPy optimizer res = sp.optimize.minimize( self.cost_function, self.initial_guess, @@ -263,14 +263,33 @@ def compute_trajectory( self.system, self.time_vector, inputs, x, return_x=True) else: states=None - + return _process_time_response( self.system, self.time_vector, inputs, states, transpose=transpose, return_x=return_x, squeeze=squeeze) + # Create an input/output system implementing an MPC controller + def create_mpc_iosystem(self, dt=True): + def _update(t, x, u, params={}): + inputs = x.reshape((self.system.ninputs, self.time_vector.size)) + self.initial_guess = np.hstack( + [inputs[:,1:], inputs[:,-1:]]).reshape(-1) + _, inputs = self.compute_trajectory(u) + return inputs.reshape(-1) + + def _output(t, x, u, params={}): + inputs = x.reshape((self.system.ninputs, self.time_vector.size)) + return inputs[:,0] + + return ct.NonlinearIOSystem( + _update, _output, dt=dt, + inputs=self.system.nstates, + outputs=self.system.ninputs, + states=self.system.ninputs * self.time_vector.size) + # -# Create a polytope constraint on the system state +# Create a polytope constraint on the system state: A x <= b # # As in the cost function evaluation, the main "trick" in creating a constrain # on the state or input is to properly evaluate the constraint on the stacked @@ -283,26 +302,48 @@ def compute_trajectory( # optimization methods LinearConstraint and NonlinearConstraint as "types" to # keep things consistent with the terminology in scipy.optimize. # -def state_poly_constraint(sys, polytope): +def state_poly_constraint(sys, A, b): + """Create state constraint from polytope""" + # TODO: make sure the system and constraints are compatible + + # Return a linear constraint object based on the polynomial + return (opt.LinearConstraint, + np.hstack([A, np.zeros((A.shape[0], sys.ninputs))]), + np.full(A.shape[0], -np.inf), polytope.b) + + +def state_range_constraint(sys, lb, ub): """Create state constraint from polytope""" # TODO: make sure the system and constraints are compatible # Return a linear constraint object based on the polynomial return (opt.LinearConstraint, np.hstack( - [polytope.A, np.zeros((polytope.A.shape[0], sys.ninputs))]), - np.full(polytope.A.shape[0], -np.inf), polytope.b) + [np.eye(sys.nstates), np.zeros((sys.nstates, sys.ninputs))]), + np.array(lb), np.array(ub)) + # Create a constraint polytope on the system input -def input_poly_constraint(sys, polytope): +def input_poly_constraint(sys, A, b): + """Create input constraint from polytope""" + # TODO: make sure the system and constraints are compatible + + # Return a linear constraint object based on the polynomial + return (opt.LinearConstraint, + np.hstack( + [np.zeros((A.shape[0], sys.nstates)), A]), + np.full(A.shape[0], -np.inf), b) + + +def input_range_constraint(sys, lb, ub): """Create input constraint from polytope""" # TODO: make sure the system and constraints are compatible # Return a linear constraint object based on the polynomial return (opt.LinearConstraint, np.hstack( - [np.zeros((polytope.A.shape[0], sys.nstates)), polytope.A]), - np.full(polytope.A.shape[0], -np.inf), polytope.b) + [np.zeros((sys.ninputs, sys.nstates)), np.eye(sys.ninputs)]), + np.array(lb), np.array(ub)) # @@ -316,9 +357,9 @@ def input_poly_constraint(sys, polytope): # imposing a linear constraint: # # np.hstack( -# [polytope.A @ sys.C, np.zeros((polytope.A.shape[0], sys.ninputs))]) +# [A @ sys.C, np.zeros((A.shape[0], sys.ninputs))]) # -def output_poly_constraint(sys, polytope): +def output_poly_constraint(sys, A, b): """Create output constraint from polytope""" # TODO: make sure the system and constraints are compatible @@ -329,12 +370,12 @@ def _evaluate_output_constraint(x): states = x[:sys.nstates] inputs = x[sys.nstates:] outputs = sys._out(0, states, inputs) - return polytope.A @ outputs + return A @ outputs # Return a nonlinear constraint object based on the polynomial return (opt.NonlinearConstraint, _evaluate_output_constraint, - np.full(polytope.A.shape[0], -np.inf), polytope.b) + np.full(A.shape[0], -np.inf), b) # @@ -345,8 +386,8 @@ def _evaluate_output_constraint(x): # evaluates to associted quadratic cost. This is compatible with the way that # the `cost_function` evaluates the cost at each point in the trajectory. # -def quadratic_cost(sys, Q, R): +def quadratic_cost(sys, Q, R, x0=0, u0=0): """Create quadratic cost function""" Q = np.atleast_2d(Q) R = np.atleast_2d(R) - return lambda x, u: x @ Q @ x + u @ R @ u + return lambda x, u: ((x-x0) @ Q @ (x-x0) + (u-u0) @ R @ (u-u0)).item() diff --git a/control/tests/obc_test.py b/control/tests/obc_test.py index f04e9cc91..a98283034 100644 --- a/control/tests/obc_test.py +++ b/control/tests/obc_test.py @@ -10,7 +10,7 @@ import scipy as sp import control as ct import control.obc as obc -import polytope as pc +from control.tests.conftest import slycotonly def test_finite_horizon_mpc_simple(): # Define a linear system with constraints @@ -21,8 +21,7 @@ def test_finite_horizon_mpc_simple(): # State and input constraints constraints = [ - obc.state_poly_constraint(sys, pc.box2poly([[-5, 5], [-5, 5]])), - obc.input_poly_constraint(sys, pc.box2poly([[-1, 1]])), + (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), ] # Quadratic state and input penalty @@ -48,10 +47,12 @@ def test_finite_horizon_mpc_simple(): # Convert controller to an explicit form (not implemented yet) # mpc_explicit = obc.explicit_mpc(); - # Test explicit controller + # Test explicit controller # u_explicit = mpc_explicit(x0) # np.testing.assert_array_almost_equal(u_openloop, u_explicit) + +@slycotonly def test_finite_horizon_mpc_oscillator(): # oscillator model defined in 2D # Source: https://www.mpt3.org/UI/RegulationProblem @@ -65,8 +66,7 @@ def test_finite_horizon_mpc_oscillator(): # state and input constraints trajectory_constraints = [ - obc.output_poly_constraint(sys, pc.box2poly([[-10, 10]])), - obc.input_poly_constraint(sys, pc.box2poly([[-1, 1]])) + (sp.optimize.LinearConstraint, np.eye(3), [-10, -10, -1], [10, 10, 1]), ] # Include weights on states/inputs @@ -85,3 +85,57 @@ def test_finite_horizon_mpc_oscillator(): # Add tests to make sure everything works t, u_openloop = optctrl.compute_trajectory([1, 1]) + + +def test_mpc_iosystem(): + # model of an aircraft discretized with 0.2s sampling time + # Source: https://www.mpt3.org/UI/RegulationProblem + A = [[0.99, 0.01, 0.18, -0.09, 0], + [ 0, 0.94, 0, 0.29, 0], + [ 0, 0.14, 0.81, -0.9, 0], + [ 0, -0.2, 0, 0.95, 0], + [ 0, 0.09, 0, 0, 0.9]] + B = [[ 0.01, -0.02], + [-0.14, 0], + [ 0.05, -0.2], + [ 0.02, 0], + [-0.01, 0]] + C = [[0, 1, 0, 0, -1], + [0, 0, 1, 0, 0], + [0, 0, 0, 1, 0], + [1, 0, 0, 0, 0]] + model = ct.ss2io(ct.ss(A, B, C, 0, 0.2)) + + # For the simulation we need the full state output + sys = ct.ss2io(ct.ss(A, B, np.eye(5), 0, 0.2)) + + # compute the steady state values for a particular value of the input + ud = np.array([0.8, -0.3]) + xd = np.linalg.inv(np.eye(5) - A) @ B @ ud + yd = C @ xd + + # provide constraints on the system signals + constraints = [obc.input_range_constraint(sys, [-5, -6], [5, 6])] + + # provide penalties on the system signals + Q = model.C.transpose() @ np.diag([10, 10, 10, 10]) @ model.C + R = np.diag([3, 2]) + cost = obc.quadratic_cost(model, Q, R, x0=xd, u0=ud) + + # online MPC controller object is constructed with a horizon 6 + optctrl = obc.OptimalControlProblem( + model, np.arange(0, 6) * 0.2, cost, constraints) + + # Define an I/O system implementing model predictive control + ctrl = optctrl.create_mpc_iosystem() + loop = ct.feedback(sys, ctrl, 1) + + # Choose a nearby initial condition to speed up computation + X0 = np.hstack([xd, np.kron(ud, np.ones(6))]) * 0.99 + + Nsim = 10 + tout, xout = ct.input_output_response( + loop, np.arange(0, Nsim) * 0.2, 0, X0) + + # Make sure the system converged to the desired state + np.testing.assert_almost_equal(xout[0:sys.nstates, -1], xd, decimal=1) diff --git a/examples/mpc_aircraft.ipynb b/examples/mpc_aircraft.ipynb new file mode 100644 index 000000000..53b8bb13b --- /dev/null +++ b/examples/mpc_aircraft.ipynb @@ -0,0 +1,202 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Model Predictive Control: Aircraft Model\n", + "\n", + "RMM, 13 Feb 2021\n", + "\n", + "This example replicates the [MPT3 regulation problem example](https://www.mpt3.org/UI/RegulationProblem)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import control as ct\n", + "import numpy as np\n", + "import control.obc as obc\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "# model of an aircraft discretized with 0.2s sampling time\n", + "# Source: https://www.mpt3.org/UI/RegulationProblem\n", + "A = [[0.99, 0.01, 0.18, -0.09, 0],\n", + " [ 0, 0.94, 0, 0.29, 0],\n", + " [ 0, 0.14, 0.81, -0.9, 0],\n", + " [ 0, -0.2, 0, 0.95, 0],\n", + " [ 0, 0.09, 0, 0, 0.9]]\n", + "B = [[ 0.01, -0.02],\n", + " [-0.14, 0],\n", + " [ 0.05, -0.2],\n", + " [ 0.02, 0],\n", + " [-0.01, 0]]\n", + "C = [[0, 1, 0, 0, -1],\n", + " [0, 0, 1, 0, 0],\n", + " [0, 0, 0, 1, 0],\n", + " [1, 0, 0, 0, 0]]\n", + "model = ct.ss2io(ct.ss(A, B, C, 0, 0.2))\n", + "\n", + "# For the simulation we need the full state output\n", + "sys = ct.ss2io(ct.ss(A, B, np.eye(5), 0, 0.2))\n", + "\n", + "# compute the steady state values for a particular value of the input\n", + "ud = np.array([0.8, -0.3])\n", + "xd = np.linalg.inv(np.eye(5) - A) @ B @ ud\n", + "yd = C @ xd" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# computed values will be used as references for the desired\n", + "# steady state which can be added using \"reference\" filter\n", + "# model.u.with('reference');\n", + "# model.u.reference = us;\n", + "# model.y.with('reference');\n", + "# model.y.reference = ys;\n", + "\n", + "# provide constraints on the system signals\n", + "constraints = [obc.input_range_constraint(sys, [-5, -6], [5, 6])]\n", + "\n", + "# provide penalties on the system signals\n", + "Q = model.C.transpose() @ np.diag([10, 10, 10, 10]) @ model.C\n", + "R = np.diag([3, 2])\n", + "cost = obc.quadratic_cost(model, Q, R, x0=xd, u0=ud)\n", + "\n", + "# online MPC controller object is constructed with a horizon 6\n", + "optctrl = obc.OptimalControlProblem(model, np.arange(0, 6) * 0.2, cost, constraints)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "System: sys[7]\n", + "Inputs (2): u[0], u[1], \n", + "Outputs (5): y[0], y[1], y[2], y[3], y[4], \n", + "States (17): sys[1]_x[0], sys[1]_x[1], sys[1]_x[2], sys[1]_x[3], sys[1]_x[4], sys[6]_x[0], sys[6]_x[1], sys[6]_x[2], sys[6]_x[3], sys[6]_x[4], sys[6]_x[5], sys[6]_x[6], sys[6]_x[7], sys[6]_x[8], sys[6]_x[9], sys[6]_x[10], sys[6]_x[11], \n" + ] + } + ], + "source": [ + "# Define an I/O system implementing model predictive control\n", + "ctrl = optctrl.create_mpc_iosystem()\n", + "loop = ct.feedback(sys, ctrl, 1)\n", + "print(loop)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Computation time = 8.28132 seconds\n" + ] + } + ], + "source": [ + "import time\n", + "\n", + "# loop = ClosedLoop(ctrl, model);\n", + "# x0 = [0, 0, 0, 0, 0]\n", + "Nsim = 60\n", + "\n", + "start = time.time()\n", + "tout, xout = ct.input_output_response(loop, np.arange(0, Nsim) * 0.2, 0, 0)\n", + "end = time.time()\n", + "print(\"Computation time = %g seconds\" % (end-start))" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([-0.15441833, 0.00362039, 0.07760278, 0.00675162, 0.00698118])" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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\n", + "text/plain": [ + "
" + ] + }, + "metadata": { + "needs_background": "light" + }, + "output_type": "display_data" + } + ], + "source": [ + "# Plot the results\n", + "# plt.subplot(2, 1, 1)\n", + "for i, y in enumerate(C @ xout):\n", + " plt.plot(tout, y)\n", + " plt.plot(tout, yd[i] * np.ones(tout.shape), 'k--')\n", + "plt.title('outputs')\n", + "\n", + "# plt.subplot(2, 1, 2)\n", + "# plt.plot(t, u);\n", + "# plot(np.range(Nsim), us*ones(1, Nsim), 'k--')\n", + "# plt.title('inputs')\n", + "\n", + "plt.tight_layout()\n", + "\n", + "# Print the final error\n", + "xd - xout[:,-1]" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.8.5" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From 0d14642f22373ed22e4598d448b0482adc13060c Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 15 Feb 2021 12:59:45 -0800 Subject: [PATCH 06/18] add'l unit tests, cache sim results, equality constraint support --- control/obc.py | 157 +++++++++++++++++++++++++++++++------- control/tests/obc_test.py | 107 ++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 26 deletions(-) diff --git a/control/obc.py b/control/obc.py index ff56fbb43..e0cd0cc61 100644 --- a/control/obc.py +++ b/control/obc.py @@ -76,28 +76,46 @@ def __init__( # is consistent with the `constraint_function` that is used at # evaluation time. # - constraint_lb, constraint_ub = [], [] + constraint_lb, constraint_ub, eqconst_value = [], [], [] # Go through each time point and stack the bounds for time in self.time_vector: for constraint in self.trajectory_constraints: type, fun, lb, ub = constraint - constraint_lb.append(lb) - constraint_ub.append(ub) + if np.all(lb == ub): + # Equality constraint + eqconst_value.append(lb) + else: + # Inequality constraint + constraint_lb.append(lb) + constraint_ub.append(ub) # Add on the terminal constraints for constraint in self.terminal_constraints: type, fun, lb, ub = constraint - constraint_lb.append(lb) - constraint_ub.append(ub) + if np.all(lb == ub): + # Equality constraint + eqconst_value.append(lb) + else: + # Inequality constraint + constraint_lb.append(lb) + constraint_ub.append(ub) # Turn constraint vectors into 1D arrays - self.constraint_lb = np.hstack(constraint_lb) - self.constraint_ub = np.hstack(constraint_ub) - - # Create the new constraint - self.constraints = sp.optimize.NonlinearConstraint( - self.constraint_function, self.constraint_lb, self.constraint_ub) + self.constraint_lb = np.hstack(constraint_lb) if constraint_lb else [] + self.constraint_ub = np.hstack(constraint_ub) if constraint_ub else [] + self.eqconst_value = np.hstack(eqconst_value) if eqconst_value else [] + + # Create the constraints (inequality and equality) + 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)) # # Initial guess @@ -109,6 +127,10 @@ def __init__( self.initial_guess = np.zeros( self.system.ninputs * self.time_vector.size) + # Store states, input to minimize re-computation + self.last_x = np.full(self.system.nstates, np.nan) + self.last_inputs = np.full(self.initial_guess.shape, np.nan) + # # Cost function # @@ -117,7 +139,7 @@ def __init__( # 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]) + # cost = sum_k integral_cost(x[k], u[k]) + terminal_cost(x[N], u[N]) # # The initial state is for generating the simulation is store in the class # parameter `x` prior to calling the optimization algorithm. @@ -128,9 +150,17 @@ def cost_function(self, inputs): inputs = inputs.reshape( (self.system.ninputs, self.time_vector.size)) - # Simulate the system to get the state - _, _, states = ct.input_output_response( - self.system, self.time_vector, inputs, x, return_x=True) + # See if we already have a simulation for this condition + if np.array_equal(x, self.last_x) and \ + np.array_equal(inputs, self.last_inputs): + states = self.last_states + else: + # Simulate the system to get the state + _, _, states = ct.input_output_response( + self.system, self.time_vector, inputs, x, return_x=True) + self.last_x = x + self.last_inputs = inputs + self.last_states = states # Trajectory cost # TODO: vectorize @@ -160,11 +190,17 @@ def cost_function(self, inputs): # * For nonlinear constraints (NonlinearConstraint), a user-specific # constraint function having the form # - # constraint_fun(x, u) + # constraint_fun(x, u) TODO: convert from [x, u] to (x, u) # # 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 is 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 # that for N time points with m trajectory constraints and p terminal @@ -189,22 +225,32 @@ def constraint_function(self, inputs): inputs = inputs.reshape( (self.system.ninputs, self.time_vector.size)) - # Simulate the system to get the state - _, _, states = ct.input_output_response( - self.system, self.time_vector, inputs, x, return_x=True) + # See if we already have a simulation for this condition + if np.array_equal(x, self.last_x) and \ + np.array_equal(inputs, self.last_inputs): + states = self.last_states + else: + # Simulate the system to get the state + _, _, states = ct.input_output_response( + self.system, self.time_vector, inputs, x, return_x=True) + self.last_x = x + self.last_inputs = inputs + self.last_states = states # Evaluate the constraint function along the trajectory value = [] for i, time in enumerate(self.time_vector): for constraint in self.trajectory_constraints: type, fun, lb, ub = constraint - if type == opt.LinearConstraint: + if np.all(lb == ub): + # Skip equality constraints + continue + elif type == opt.LinearConstraint: # `fun` is the A matrix associated with the polytope... value.append( np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) elif type == opt.NonlinearConstraint: - value.append( - fun(np.hstack([states[:,i], inputs[:,i]]))) + value.append(fun(states[:,i], inputs[:,i])) else: raise TypeError("unknown constraint type %s" % constraint[0]) @@ -212,12 +258,68 @@ def constraint_function(self, inputs): # Evaluate the terminal constraint functions for constraint in self.terminal_constraints: type, fun, lb, ub = constraint - if type == opt.LinearConstraint: + if np.all(lb == ub): + # Skip equality constraints + continue + elif type == opt.LinearConstraint: value.append( np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) elif type == opt.NonlinearConstraint: + value.append(fun(states[:,i], inputs[:,i])) + else: + raise TypeError("unknown constraint type %s" % + constraint[0]) + + # Return the value of the constraint function + return np.hstack(value) + + def eqconst_function(self, inputs): + # Retrieve the initial state and reshape the input vector + x = self.x + inputs = inputs.reshape( + (self.system.ninputs, self.time_vector.size)) + + # See if we already have a simulation for this condition + if np.array_equal(x, self.last_x) and \ + np.array_equal(inputs, self.last_inputs): + states = self.last_states + else: + # Simulate the system to get the state + _, _, states = ct.input_output_response( + self.system, self.time_vector, inputs, x, return_x=True) + self.last_x = x + self.last_inputs = inputs + self.last_states = states + + # Evaluate the constraint function along the trajectory + value = [] + for i, time in enumerate(self.time_vector): + for constraint in self.trajectory_constraints: + type, fun, lb, ub = constraint + if np.any(lb != ub): + # Skip iniquality constraints + continue + elif type == opt.LinearConstraint: + # `fun` is the A matrix associated with the polytope... + value.append( + np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) + elif type == opt.NonlinearConstraint: + value.append(fun(states[:,i], inputs[:,i])) + else: + raise TypeError("unknown constraint type %s" % + constraint[0]) + + # Evaluate the terminal constraint functions + for constraint in self.terminal_constraints: + type, fun, lb, ub = constraint + if np.any(lb != ub): + # Skip inequality constraints + continue + elif type == opt.LinearConstraint: value.append( - fun(np.hstack([states[:,i], inputs[:,i]]))) + np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) + elif type == opt.NonlinearConstraint: + value.append(fun(states[:,i], inputs[:,i])) else: raise TypeError("unknown constraint type %s" % constraint[0]) @@ -225,6 +327,7 @@ def constraint_function(self, inputs): # Return the value of the constraint function return np.hstack(value) + # Allow optctrl(x) as a replacement for optctrl.mpc(x) def __call__(self, x, squeeze=None): """Compute the optimal input at state x""" @@ -250,7 +353,9 @@ def compute_trajectory( # See if we got an answer if not res.success: - warnings.warn(res.message) + warnings.warn( + "unable to solve optimal control problem\n" + "scipy.optimize.minimize returned " + res.message, UserWarning) return None # Reshape the input vector @@ -309,7 +414,7 @@ def state_poly_constraint(sys, A, b): # Return a linear constraint object based on the polynomial return (opt.LinearConstraint, np.hstack([A, np.zeros((A.shape[0], sys.ninputs))]), - np.full(A.shape[0], -np.inf), polytope.b) + np.full(A.shape[0], -np.inf), b) def state_range_constraint(sys, lb, ub): diff --git a/control/tests/obc_test.py b/control/tests/obc_test.py index a98283034..919f1377b 100644 --- a/control/tests/obc_test.py +++ b/control/tests/obc_test.py @@ -12,6 +12,7 @@ import control.obc as obc from control.tests.conftest import slycotonly + def test_finite_horizon_mpc_simple(): # Define a linear system with constraints # Source: https://www.mpt3.org/UI/RegulationProblem @@ -139,3 +140,109 @@ def test_mpc_iosystem(): # Make sure the system converged to the desired state np.testing.assert_almost_equal(xout[0:sys.nstates, -1], xd, decimal=1) + + +# Test various constraint combinations; need to use a somewhat convoluted +# parametrization due to the need to define sys instead the test function +@pytest.mark.parametrize("constraint_list", [ + [(sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1],)], + [(obc.state_range_constraint, [-5, -5], [5, 5]), + (obc.input_range_constraint, [-1], [1])], + [(obc.state_range_constraint, [-5, -5], [5, 5]), + (obc.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], + [(obc.state_poly_constraint, + np.array([[1, 0], [0, 1], [-1, 0], [0, -1]]), [5, 5, 5, 5]), + (obc.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], + [(sp.optimize.NonlinearConstraint, + lambda x, u: np.array([abs(x[0]), x[1], u[0]**2]), + [-np.inf, -5, -1e-12], [5, 5, 1],)], # -1e-12 for SciPy bug? +]) +def test_constraint_specification(constraint_list): + sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) + + """Test out different forms of constraints on a simple problem""" + # Parse out the constraint + constraints = [] + for constraint_setup in constraint_list: + if constraint_setup[0] in \ + (sp.optimize.LinearConstraint, sp.optimize.NonlinearConstraint): + # No processing required + constraints.append(constraint_setup) + else: + # Call the function in the first argument to set up the constraint + constraints.append(constraint_setup[0](sys, *constraint_setup[1:])) + + # Quadratic state and input penalty + Q = [[1, 0], [0, 1]] + R = [[1]] + cost = obc.quadratic_cost(sys, Q, R) + + # Create a model predictive controller system + time = np.arange(0, 5, 1) + optctrl = obc.OptimalControlProblem(sys, time, cost, constraints) + + # Compute optimal control and compare against MPT3 solution + x0 = [4, 0] + t, u_openloop = optctrl.compute_trajectory(x0, squeeze=True) + np.testing.assert_almost_equal( + u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=3) + + +def test_terminal_constraints(): + """Test out the ability to handle terminal constraints""" + # Discrete time "integrator" with 2 states, 2 inputs + sys = ct.ss2io(ct.ss([[1, 0], [0, 1]], np.eye(2), np.eye(2), 0, True)) + + # Shortest path to a point is a line + Q = np.zeros((2, 2)) + R = np.eye(2) + cost = obc.quadratic_cost(sys, Q, R) + + # Set up the terminal constraint to be the origin + final_point = [obc.state_range_constraint(sys, [0, 0], [0, 0])] + + # Create the optimal control problem + time = np.arange(0, 5, 1) + optctrl = obc.OptimalControlProblem( + sys, time, cost, terminal_constraints=final_point) + + # Find a path to the origin + x0 = np.array([4, 3]) + t, u1, x1 = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + np.testing.assert_almost_equal(x1[:,-1], 0) + + # Make sure it is a straight line + np.testing.assert_almost_equal( + x1, np.kron(x0.reshape((2, 1)), time[::-1]/4)) + + # Impose some cost on the state, which should change the path + Q = np.eye(2) + R = np.eye(2) * 0.1 + cost = obc.quadratic_cost(sys, Q, R) + optctrl = obc.OptimalControlProblem( + sys, time, cost, terminal_constraints=final_point) + + # Find a path to the origin + t, u2, x2 = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + np.testing.assert_almost_equal(x2[:,-1], 0) + + # Make sure that it is *not* a straight line path + assert np.any(np.abs(x2 - x1) > 0.1) + assert np.any(np.abs(u2) > 1) # To make sure next test is useful + + # Add some bounds on the inputs + constraints = [obc.input_range_constraint(sys, [-1, -1], [1, 1])] + optctrl = obc.OptimalControlProblem( + sys, time, cost, constraints, terminal_constraints=final_point) + t, u3, x3 = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + np.testing.assert_almost_equal(x2[:,-1], 0) + + # Make sure we got a new path and didn't violate the constraints + assert np.any(np.abs(x3 - x1) > 0.1) + np.testing.assert_array_less(np.abs(u3), 1 + 1e-12) + + # Make sure that infeasible problems are handled sensibly + x0 = np.array([10, 3]) + with pytest.warns(UserWarning, match="unable to solve"): + res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + assert res == None From 2456f365057c96393d2ec93be7e49380b6bc9e5e Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Mon, 15 Feb 2021 23:02:38 -0800 Subject: [PATCH 07/18] slight code refactoring + docstrings + initial doc/obc.rst --- control/obc.py | 567 ++++++++++++++++++++++++++++++------ control/tests/obc_test.py | 18 +- doc/classes.rst | 11 + doc/examples.rst | 1 + doc/index.rst | 1 + doc/mpc-overview.png | Bin 0 -> 175846 bytes doc/mpc_aircraft.ipynb | 1 + doc/obc.rst | 140 +++++++++ examples/mpc_aircraft.ipynb | 3 +- 9 files changed, 634 insertions(+), 108 deletions(-) create mode 100644 doc/mpc-overview.png create mode 120000 doc/mpc_aircraft.ipynb create mode 100644 doc/obc.rst diff --git a/control/obc.py b/control/obc.py index e0cd0cc61..e71677efc 100644 --- a/control/obc.py +++ b/control/obc.py @@ -3,7 +3,7 @@ # RMM, 11 Feb 2021 # -"""The "mod:`~control.obc` module provides support for optimization-based +"""The :mod:`~control.obc` module provides support for optimization-based controllers for nonlinear systems with state and input constraints. """ @@ -16,48 +16,81 @@ from .timeresp import _process_time_response -# -# OptimalControlProblem class -# -# The OptimalControlProblem class holds all of the information required to -# specify and optimal control problem: the system dynamics, cost function, -# and constraints. As much as possible, the information used to specify an -# optimal control problem matches the notation and terminology of the SciPy -# `optimize.minimize` module, with the hope that this makes it easier to -# remember how to describe a problem. -# -# The approach that we use here is to set up an optimization over the -# inputs at each point in time, using the integral and terminal costs as -# well as the trajectory and terminal constraints. The main function of -# this class is to create an optimization problem that can be solved using -# scipy.optimize.minimize(). -# -# The `cost_function` method takes the information stored here and computes -# the cost of the trajectory generated by the proposed input. It does this -# by calling a user-defined function for the integral_cost given the -# current states and inputs at each point along the trajetory and then -# adding the value of a user-defined terminal cost at the final pint in the -# trajectory. -# -# The `constraint_function` method evaluates the constraint functions along -# the trajectory generated by the proposed input. As in the case of the -# cost function, the constraints are evaluated at the state and input along -# each point on the trjectory. This information is compared against the -# constraint upper and lower bounds. The constraint function is processed -# in the class initializer, so that it only needs to be computed once. -# +__all__ = ['find_optimal_input'] + class OptimalControlProblem(): - """The :class:`OptimalControlProblem` class is a front end for computing an - optimal control input for a nonilinear system with a user-defined cost - function and state and input constraints. + """Description of a finite horizon, optimal control problem + + The `OptimalControlProblem` class holds all of the information required to + specify and optimal control problem: the system dynamics, cost function, + and constraints. As much as possible, the information used to specify an + optimal control problem matches the notation and terminology of the SciPy + `optimize.minimize` module, with the hope that this makes it easier to + remember how to describe a problem. + + Notes + ----- + This class sets up an optimization over the inputs at each point in + time, using the integral and terminal costs as well as the + trajectory and terminal constraints. The `compute_trajectory` + method sets up an optimization problem that can be solved using + :func:`scipy.optimize.minimize`. + + The `_cost_function` method takes the information computes the cost of the + trajectory generated by the proposed input. It does this by calling a + user-defined function for the integral_cost given the current states and + inputs at each point along the trajetory and then adding the value of a + user-defined terminal cost at the final pint in the trajectory. + + The `_constraint_function` method evaluates the constraint functions along + the trajectory generated by the proposed input. As in the case of the + cost function, the constraints are evaluated at the state and input along + each point on the trjectory. This information is compared against the + constraint upper and lower bounds. The constraint function is processed + in the class initializer, so that it only needs to be computed once. """ def __init__( - self, sys, time, integral_cost, trajectory_constraints=[], + self, sys, time_vector, integral_cost, trajectory_constraints=[], terminal_cost=None, terminal_constraints=[]): + """Set up an optimal control problem + + To describe an optimal control problem we need an input/output system, + a time horizon, a cost function, and (optionally) a set of constraints + on the state and/or input, either along the trajectory and at the + terminal time. + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the optimal input will be computed. + time_vector : 1D array_like + List of times at which the optimal input should be computed. + 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 + 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 constrains will be applied at each point + along the trajectory. + terminal_cost : callable + Function that returns the terminal cost given the current state + and input. Called as terminal_cost(x, u). + + Returns + ------- + ocp : OptimalControlProblem + Optimal control problem object, to be used in computing optimal + controllers. + + """ # Save the basic information for use later self.system = sys - self.time_vector = time + self.time_vector = time_vector self.integral_cost = integral_cost self.trajectory_constraints = trajectory_constraints self.terminal_cost = terminal_cost @@ -73,7 +106,7 @@ def __init__( # mainly a matter of computing the lower and upper bound vectors, # which we need to "stack" to account for the evaluation at each # trajectory time point plus any terminal constraints (in a way that - # is consistent with the `constraint_function` that is used at + # is consistent with the `_constraint_function` that is used at # evaluation time. # constraint_lb, constraint_ub, eqconst_value = [], [], [] @@ -110,11 +143,11 @@ def __init__( self.constraints = [] if len(self.constraint_lb) != 0: self.constraints.append(sp.optimize.NonlinearConstraint( - self.constraint_function, self.constraint_lb, + 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_function, self.eqconst_value, self.eqconst_value)) # @@ -144,7 +177,7 @@ def __init__( # The initial state is for generating the simulation is store in the class # parameter `x` prior to calling the optimization algorithm. # - def cost_function(self, inputs): + def _cost_function(self, inputs): # Retrieve the initial state and reshape the input vector x = self.x inputs = inputs.reshape( @@ -199,7 +232,7 @@ def cost_function(self, inputs): # 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. + # 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 @@ -219,7 +252,7 @@ def cost_function(self, inputs): # pass arguments to the constraint function, we have to store the initial # state prior to optimization and retrieve it here. # - def constraint_function(self, inputs): + def _constraint_function(self, inputs): # Retrieve the initial state and reshape the input vector x = self.x inputs = inputs.reshape( @@ -273,7 +306,7 @@ def constraint_function(self, inputs): # Return the value of the constraint function return np.hstack(value) - def eqconst_function(self, inputs): + def _eqconst_function(self, inputs): # Retrieve the initial state and reshape the input vector x = self.x inputs = inputs.reshape( @@ -327,28 +360,67 @@ def eqconst_function(self, inputs): # Return the value of the constraint function return np.hstack(value) + # Create an input/output system implementing an MPC controller + def _create_mpc_iosystem(self, dt=True): + """Create an I/O system implementing an MPC controller""" + def _update(t, x, u, params={}): + inputs = x.reshape((self.system.ninputs, self.time_vector.size)) + self.initial_guess = np.hstack( + [inputs[:,1:], inputs[:,-1:]]).reshape(-1) + _, inputs = self.compute_trajectory(u) + return inputs.reshape(-1) - # Allow optctrl(x) as a replacement for optctrl.mpc(x) - def __call__(self, x, squeeze=None): - """Compute the optimal input at state x""" - return self.mpc(x, squeeze=squeeze) + def _output(t, x, u, params={}): + inputs = x.reshape((self.system.ninputs, self.time_vector.size)) + return inputs[:,0] - # Compute the current input to apply from the current state (MPC style) - def mpc(self, x, squeeze=None): - """Compute the optimal input at state x""" - _, inputs = self.compute_trajectory(x, squeeze=squeeze) - return None if inputs is None else inputs[:,0] + return ct.NonlinearIOSystem( + _update, _output, dt=dt, + inputs=self.system.nstates, + outputs=self.system.ninputs, + states=self.system.ninputs * self.time_vector.size) # Compute the optimal trajectory from the current state def compute_trajectory( self, x, squeeze=None, transpose=None, return_x=None): - """Compute the optimal input at state x""" - # Store the initial state (for use in constraint_function) + """Compute the optimal input at state x + + Parameters + ---------- + x: array-like or number, optional + Initial state for the system. + return_x : bool, optional + If True, return the values of the state at each time (default = + False). + squeeze : bool, optional + If True and if the system has a single output, return the system + output as a 1D array rather than a 2D array. If False, return the + system output as a 2D array even if the system is SISO. Default + value set by config.defaults['control.squeeze_time_response']. + transpose : bool, optional + If True, assume that 2D input arrays are transposed from the + standard format. Used to convert MATLAB-style inputs to our + format. + + Returns + ------- + time : array + Time values of the input. + inputs : array + Optimal inputs for the system. If the system is SISO and squeeze + is not True, the array is 1D (indexed by time). If the system is + not SISO or squeeze is False, the array is 2D (indexed by the + output number and time). + states : array + Time evolution of the state vector (if return_x=True). + + """ + # Store the initial state (for use in _constraint_function) self.x = x # Call ScipPy optimizer res = sp.optimize.minimize( - self.cost_function, self.initial_guess, + self._cost_function, self.initial_guess, constraints=self.constraints) # See if we got an answer @@ -373,28 +445,216 @@ def compute_trajectory( self.system, self.time_vector, inputs, states, transpose=transpose, return_x=return_x, squeeze=squeeze) - # Create an input/output system implementing an MPC controller - def create_mpc_iosystem(self, dt=True): - def _update(t, x, u, params={}): - inputs = x.reshape((self.system.ninputs, self.time_vector.size)) - self.initial_guess = np.hstack( - [inputs[:,1:], inputs[:,-1:]]).reshape(-1) - _, inputs = self.compute_trajectory(u) - return inputs.reshape(-1) + # Compute the current input to apply from the current state (MPC style) + def compute_mpc(self, x, squeeze=None): + """Compute the optimal input at state x + + This function calls the :meth:`compute_trajectory` method and returns + the input at the first time point. + + Parameters + ---------- + x: array-like or number, optional + Initial state for the system. + squeeze : bool, optional + If True and if the system has a single output, return the system + output as a 1D array rather than a 2D array. If False, return the + system output as a 2D array even if the system is SISO. Default + value set by config.defaults['control.squeeze_time_response']. + + Returns + ------- + input : array + Optimal input for the system at the current time. If the system + is SISO and squeeze is not True, the array is 1D (indexed by + time). If the system is not SISO or squeeze is False, the array + is 2D (indexed by the output number and time). + + """ + _, inputs = self.compute_trajectory(x, squeeze=squeeze) + return None if inputs is None else inputs[:,0] - def _output(t, x, u, params={}): - inputs = x.reshape((self.system.ninputs, self.time_vector.size)) - return inputs[:,0] - return ct.NonlinearIOSystem( - _update, _output, dt=dt, - inputs=self.system.nstates, - outputs=self.system.ninputs, - states=self.system.ninputs * self.time_vector.size) +# Compute the input for a nonlinear, (constrained) optimal control problem +def compute_optimal_input( + sys, horizon, X0, cost, constraints=[], terminal_cost=None, + terminal_constraints=[], squeeze=None, transpose=None, return_x=None): + """Compute the solution to an optimal control problem + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the optimal input will be computed. + + horizon : 1D array_like + List of times at which the optimal input should be computed. + + X0: array-like or number, optional + Initial condition (default = 0). + + cost : callable + Function that returns the integral cost given the current state + and input. Called as cost(x, u). + + constraints : list of tuples, 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 following tuples are supported: + + * (LinearConstraint, A, lb, ub): The matrix A is multiplied by stacked + vector of the state and input at each point on the trajectory for + comparison against the upper and lower bounds. + + * (NonlinearConstraint, fun, lb, ub): a user-specific constraint + function `fun(x, u)` is called at each point along the trajectory + and compared against the upper and lower bounds. + + The constraints are applied at each 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). + + terminal_constraint : list of tuples, optional + List of constraints that should hold at the end of the trajectory. + Same format as `constraints`. + + return_x : bool, optional + If True, return the values of the state at each time (default = False). + + squeeze : bool, optional + If True and if the system has a single output, return the system + output as a 1D array rather than a 2D array. If False, return the + system output as a 2D array even if the system is SISO. Default value + set by config.defaults['control.squeeze_time_response']. + + transpose : bool, optional + If True, assume that 2D input arrays are transposed from the standard + format. Used to convert MATLAB-style inputs to our format. + + Returns + ------- + time : array + Time values of the input. + inputs : array + Optimal inputs for the system. If the system is SISO and squeeze is not + True, the array is 1D (indexed by time). If the system is not SISO or + squeeze is False, the array is 2D (indexed by the output number and + time). + states : array + Time evolution of the state vector (if return_x=True). + + """ + # Set up the optimal control problem + ocp = OptimalControlProblem( + sys, horizon, cost, trajectory_constraints=constraints, + terminal_cost=terminal_cost, terminal_constraints=terminal_constraints) + + # Solve for the optimal input from the current state + return ocp.compute_trajectory( + X0, squeeze=squeeze, transpose=None, return_x=None) + + +# Create a model predictive controller for an optimal control problem +def create_mpc_iosystem( + sys, horizon, cost, constraints=[], terminal_cost=None, + terminal_constraints=[], dt=True): + """Create a model predictive I/O control system + + This function creates an input/output system that implements a model + predictive control for a system given the time horizon, cost function and + constraints that define the finite-horizon optimization that should be + carried out at each state. + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the optimal input will be computed. + + horizon : 1D array_like + List of times at which the optimal input should be computed. + + cost : callable + Function that returns the integral cost given the current state + and input. Called as cost(x, u). + + constraints : list of tuples, optional + List of constraints that should hold at each point in the time vector. + See :func:`~control.obc.compute_optimal_input` for more details. + + terminal_cost : callable, optional + Function that returns the terminal cost given the current state + and input. Called as terminal_cost(x, u). + + terminal_constraint : list of tuples, optional + List of constraints that should hold at the end of the trajectory. + Same format as `constraints`. + + Returns + ------- + ctrl : InputOutputSystem + An I/O system taking the currrent state of the model system and + returning the current input to be applied that minimizes the cost + function while satisfying the constraints. + + """ + + # Set up the optimal control problem + ocp = OptimalControlProblem( + sys, horizon, cost, trajectory_constraints=constraints, + terminal_cost=terminal_cost, terminal_constraints=terminal_constraints) + # Return an I/O system implementing the model predictive controller + return ocp._create_mpc_iosystem(dt=dt) + +# +# Functions to create cost functions (quadratic cost function) # -# Create a polytope constraint on the system state: A x <= b +# Since a quadratic function is common as a cost function, we provide a +# function that will take a Q and R matrix and return a callable that +# evaluates to associted quadratic cost. This is compatible with the way that +# the `_cost_function` evaluates the cost at each point in the trajectory. +# +def quadratic_cost(sys, Q, R, x0=0, u0=0): + """Create quadratic cost function + + Returns a quadratic cost function that can be used for an optimal control + problem. The cost function is of the form + + cost = (x - x0)^T Q (x - x0) + (u - u0)^T R (u - u0) + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the cost function is being defined. + Q : 2D array_like + Weighting matrix for state cost. Dimensions must match system state. + R : 2D array_like + Weighting matrix for input cost. Dimensions must match system input. + x0 : 1D array + Nomimal value of the system state (for which cost should be zero). + u0 : 1D array + Nomimal value of the system input (for which cost should be zero). + + Returns + ------- + cost_fun : callable + Function that can be used to evaluate the cost at a given state and + input. The call signature of the function is cost_fun(x, u). + + """ + Q = np.atleast_2d(Q) + R = np.atleast_2d(R) + return lambda x, u: ((x-x0) @ Q @ (x-x0) + (u-u0) @ R @ (u-u0)).item() + + +# +# Functions to create constraints: either polytopes (A x <= b) or ranges +# (lb # <= x <= ub). # # As in the cost function evaluation, the main "trick" in creating a constrain # on the state or input is to properly evaluate the constraint on the stacked @@ -408,7 +668,26 @@ def _output(t, x, u, params={}): # keep things consistent with the terminology in scipy.optimize. # def state_poly_constraint(sys, A, b): - """Create state constraint from polytope""" + """Create state constraint from polytope + + Creates a linear constraint on the system state of the form A x <= b that + can be used as an optimal control constraint (trajectory or terminal). + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + A : 2D array + Constraint matrix + b : 1D array + Upper bound for the constraint + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ # TODO: make sure the system and constraints are compatible # Return a linear constraint object based on the polynomial @@ -418,7 +697,28 @@ def state_poly_constraint(sys, A, b): def state_range_constraint(sys, lb, ub): - """Create state constraint from polytope""" + """Create state constraint from polytope + + Creates a linear constraint on the system state that bounds the range of + the individual states to be between `lb` and `ub`. The upper and lower + bounds can be set of `inf` and `-inf` to indicate there is no constraint + or to the same value to describe an equality constraint. + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + lb : 1D array + Lower bound for each of the states. + ub : 1D array + Upper bound for each of the states. + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ # TODO: make sure the system and constraints are compatible # Return a linear constraint object based on the polynomial @@ -430,7 +730,26 @@ def state_range_constraint(sys, lb, ub): # Create a constraint polytope on the system input def input_poly_constraint(sys, A, b): - """Create input constraint from polytope""" + """Create input constraint from polytope + + Creates a linear constraint on the system input of the form A u <= b that + can be used as an optimal control constraint (trajectory or terminal). + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + A : 2D array + Constraint matrix + b : 1D array + Upper bound for the constraint + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ # TODO: make sure the system and constraints are compatible # Return a linear constraint object based on the polynomial @@ -441,7 +760,28 @@ def input_poly_constraint(sys, A, b): def input_range_constraint(sys, lb, ub): - """Create input constraint from polytope""" + """Create input constraint from polytope + + Creates a linear constraint on the system input that bounds the range of + the individual states to be between `lb` and `ub`. The upper and lower + bounds can be set of `inf` and `-inf` to indicate there is no constraint + or to the same value to describe an equality constraint. + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + lb : 1D array + Lower bound for each of the inputs. + ub : 1D array + Upper bound for each of the inputs. + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ # TODO: make sure the system and constraints are compatible # Return a linear constraint object based on the polynomial @@ -452,7 +792,7 @@ def input_range_constraint(sys, lb, ub): # -# Create a constraint polytope on the system output +# Create a constraint polytope/range constraint on the system output # # Unlike the state and input constraints, for the output constraint we need to # do a function evaluation before applying the constraints. @@ -465,12 +805,30 @@ def input_range_constraint(sys, lb, ub): # [A @ sys.C, np.zeros((A.shape[0], sys.ninputs))]) # def output_poly_constraint(sys, A, b): - """Create output constraint from polytope""" + """Create output constraint from polytope + + Creates a linear constraint on the system ouput of the form A y <= b that + can be used as an optimal control constraint (trajectory or terminal). + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + A : 2D array + Constraint matrix + b : 1D array + Upper bound for the constraint + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ # TODO: make sure the system and constraints are compatible - # # Function to create the output - def _evaluate_output_constraint(x): + def _evaluate_output_poly_constraint(x): # Separate the constraint into states and inputs states = x[:sys.nstates] inputs = x[sys.nstates:] @@ -479,20 +837,41 @@ def _evaluate_output_constraint(x): # Return a nonlinear constraint object based on the polynomial return (opt.NonlinearConstraint, - _evaluate_output_constraint, + _evaluate_output_poly_constraint, np.full(A.shape[0], -np.inf), b) -# -# Quadratic cost function -# -# Since a quadratic function is common as a cost function, we provide a -# function that will take a Q and R matrix and return a callable that -# evaluates to associted quadratic cost. This is compatible with the way that -# the `cost_function` evaluates the cost at each point in the trajectory. -# -def quadratic_cost(sys, Q, R, x0=0, u0=0): - """Create quadratic cost function""" - Q = np.atleast_2d(Q) - R = np.atleast_2d(R) - return lambda x, u: ((x-x0) @ Q @ (x-x0) + (u-u0) @ R @ (u-u0)).item() +def output_range_constraint(sys, lb, ub): + """Create output constraint from range + + Creates a linear constraint on the system output that bounds the range of + the individual states to be between `lb` and `ub`. The upper and lower + bounds can be set of `inf` and `-inf` to indicate there is no constraint + or to the same value to describe an equality constraint. + + Parameters + ---------- + sys : InputOutputSystem + I/O system for which the constraint is being defined. + lb : 1D array + Lower bound for each of the outputs. + ub : 1D array + Upper bound for each of the outputs. + + Returns + ------- + constraint : tuple + A tuple consisting of the constraint type and parameter values. + + """ + # TODO: make sure the system and constraints are compatible + + # Function to create the output + def _evaluate_output_range_constraint(x): + # Separate the constraint into states and inputs + states = x[:sys.nstates] + inputs = x[sys.nstates:] + outputs = sys._out(0, states, inputs) + + # Return a nonlinear constraint object based on the polynomial + return (opt.NonlinearConstraint, _evaluate_output_range_constraint, lb, ub) diff --git a/control/tests/obc_test.py b/control/tests/obc_test.py index 919f1377b..9ddc32a8c 100644 --- a/control/tests/obc_test.py +++ b/control/tests/obc_test.py @@ -13,7 +13,7 @@ from control.tests.conftest import slycotonly -def test_finite_horizon_mpc_simple(): +def test_finite_horizon_simple(): # Define a linear system with constraints # Source: https://www.mpt3.org/UI/RegulationProblem @@ -30,18 +30,13 @@ def test_finite_horizon_mpc_simple(): R = [[1]] cost = obc.quadratic_cost(sys, Q, R) - # Create a model predictive controller system + # Set up the optimal control problem time = np.arange(0, 5, 1) - optctrl = obc.OptimalControlProblem(sys, time, cost, constraints) - mpc = optctrl.mpc - - # Optimal control input for a given value of the initial state x0 = [4, 0] - u = mpc(x0) - np.testing.assert_almost_equal(u, -1) # Retrieve the full open-loop predictions - t, u_openloop = optctrl.compute_trajectory(x0, squeeze=True) + t, u_openloop = obc.compute_optimal_input( + sys, time, x0, cost, constraints, squeeze=True) np.testing.assert_almost_equal( u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=4) @@ -54,7 +49,7 @@ def test_finite_horizon_mpc_simple(): @slycotonly -def test_finite_horizon_mpc_oscillator(): +def test_class_interface(): # oscillator model defined in 2D # Source: https://www.mpt3.org/UI/RegulationProblem A = [[0.5403, -0.8415], [0.8415, 0.5403]] @@ -124,11 +119,10 @@ def test_mpc_iosystem(): cost = obc.quadratic_cost(model, Q, R, x0=xd, u0=ud) # online MPC controller object is constructed with a horizon 6 - optctrl = obc.OptimalControlProblem( + ctrl = obc.create_mpc_iosystem( model, np.arange(0, 6) * 0.2, cost, constraints) # Define an I/O system implementing model predictive control - ctrl = optctrl.create_mpc_iosystem() loop = ct.feedback(sys, ctrl, 1) # Choose a nearby initial condition to speed up computation diff --git a/doc/classes.rst b/doc/classes.rst index b948f23aa..6239bd2d1 100644 --- a/doc/classes.rst +++ b/doc/classes.rst @@ -30,3 +30,14 @@ that allow for linear, nonlinear, and interconnected elements: LinearICSystem LinearIOSystem NonlinearIOSystem + +Additional classes +================== +.. autosummary:: + + flatsys.BasisFamily + flatsys.FlatSystem + flatsys.LinearFlatSystem + flatsys.PolyFamily + flatsys.SystemTrajectory + obc.OptimalControlProblem diff --git a/doc/examples.rst b/doc/examples.rst index e56d46e70..91476bc9d 100644 --- a/doc/examples.rst +++ b/doc/examples.rst @@ -43,5 +43,6 @@ using running examples in FBS2e. cruise describing_functions + mpc_aircraft steering pvtol-lqr-nested diff --git a/doc/index.rst b/doc/index.rst index 3558b0b30..b5893d860 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -30,6 +30,7 @@ implements basic operations for analysis and design of feedback control systems. flatsys iosys descfcn + obc examples * :ref:`genindex` diff --git a/doc/mpc-overview.png b/doc/mpc-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..a51b9418a090677affa39e5b3f3159b8e9b062d3 GIT binary patch literal 175846 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 R#Q5-}b+UOeQ`l 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ξ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. + +Module usage +============ + +The `obc` module provides a means of computing optimal trajectories for +nonlinear systems and implementing optimization-based controllers, including +model predictive control. It follows the basic problem setup described +above, but carries out all computations in *discrete time* (so that +integrals become sums) and over a *finite horizon*. + +To describe an optimal control problem we need an input/output system, a +time horizon, a cost function, and (optionally) a set of constraints on the +state and/or input, either along the trajectory and at the terminal time. +The `obc` module operates by converting the optimal control 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 `~control.obc.compute_optimal_input` function: + + import control.obc as obc + inputs = obc.compute_optimal_inputs(sys, horizon, X0, cost, constraints) + +The `sys` parameter should be a :class:`~control.InputOutputSystem` and the +`horizon` parameter should represent a time vector that gives the list of +times at which the `cost` and `constraints` should be evaluated. By default, +`constraints` are taken to be trajectory constraints holding at all points +on the trajectory. The `terminal_constraint` parameter can be used to +specify a constraint that only holds at the final point of the trajectory +and the `terminal_cost` paramter can be used to specify a terminal cost +function. + + +Example +======= + +Module classes and functions +============================ +.. autosummary:: + :toctree: generated/ + + ~control.obc.OptimalControlProblem + ~control.obc.compute_optimal_input + ~control.obc.create_mpc_iosystem + ~control.obc.input_poly_constraint + ~control.obc.input_range_constraint + ~control.obc.output_poly_constraint + ~control.obc.output_range_constraint + ~control.obc.state_poly_constraint + ~control.obc.state_range_constraint diff --git a/examples/mpc_aircraft.ipynb b/examples/mpc_aircraft.ipynb index 53b8bb13b..87b512aee 100644 --- a/examples/mpc_aircraft.ipynb +++ b/examples/mpc_aircraft.ipynb @@ -78,7 +78,7 @@ "cost = obc.quadratic_cost(model, Q, R, x0=xd, u0=ud)\n", "\n", "# online MPC controller object is constructed with a horizon 6\n", - "optctrl = obc.OptimalControlProblem(model, np.arange(0, 6) * 0.2, cost, constraints)" + "ctrl = obc.create_mpc_iosystem(model, np.arange(0, 6) * 0.2, cost, constraints)" ] }, { @@ -99,7 +99,6 @@ ], "source": [ "# Define an I/O system implementing model predictive control\n", - "ctrl = optctrl.create_mpc_iosystem()\n", "loop = ct.feedback(sys, ctrl, 1)\n", "print(loop)" ] From 769eaa5ec980f7e46fc64e4182ad6af5f53e4e18 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Thu, 18 Feb 2021 23:01:17 -0800 Subject: [PATCH 08/18] add info/debug messages + code refactor, result object --- control/obc.py | 308 +++++++++++++++++++++++++++++------ control/tests/obc_test.py | 34 ++-- doc/obc.rst | 147 ++++++++++++++++- examples/run_examples.sh | 4 + examples/steering-optimal.py | 151 +++++++++++++++++ 5 files changed, 577 insertions(+), 67 deletions(-) create mode 100644 examples/steering-optimal.py diff --git a/control/obc.py b/control/obc.py index e71677efc..c4fa0dc4b 100644 --- a/control/obc.py +++ b/control/obc.py @@ -13,6 +13,8 @@ import scipy.optimize as opt import control as ct import warnings +import logging +import time from .timeresp import _process_time_response @@ -52,7 +54,8 @@ class OptimalControlProblem(): """ def __init__( self, sys, time_vector, integral_cost, trajectory_constraints=[], - terminal_cost=None, terminal_constraints=[]): + terminal_cost=None, terminal_constraints=[], initial_guess=None, + log=False, options={}): """Set up an optimal control problem To describe an optimal control problem we need an input/output system, @@ -77,9 +80,18 @@ def __init__( elements of the tuple are the arguments that would be passed to those functions. The constrains will be applied at each point along the trajectory. - terminal_cost : callable + 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. + log : bool, optional + If `True`, turn on logging messages (using Python logging module). + options : dict, optional + Solver options (passed to :func:`scipy.optimal.minimize`). Returns ------- @@ -95,6 +107,7 @@ def __init__( self.trajectory_constraints = trajectory_constraints self.terminal_cost = terminal_cost self.terminal_constraints = terminal_constraints + self.options = options # # Compute and store constraints @@ -112,7 +125,7 @@ def __init__( constraint_lb, constraint_ub, eqconst_value = [], [], [] # Go through each time point and stack the bounds - for time in self.time_vector: + for t in self.time_vector: for constraint in self.trajectory_constraints: type, fun, lb, ub = constraint if np.all(lb == ub): @@ -153,17 +166,42 @@ def __init__( # # Initial guess # - # We store an initial guess (zero input) 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. + # 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. # - self.initial_guess = np.zeros( - self.system.ninputs * self.time_vector.size) + if 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 len(initial_guess.shape) == 1: + # Broadcast inputs to entire time vector + initial_guess = np.broadcast_to( + initial_guess.reshape(-1, 1), + (self.system.ninputs, self.time_vector.size)) + elif len(initial_guess.shape) != 2: + raise ValueError("initial guess is the wrong shape") + + # Reshape for use by scipy.optimize.minimize() + self.initial_guess = initial_guess.reshape(-1) - # Store states, input to minimize re-computation + else: + self.initial_guess = np.zeros( + self.system.ninputs * self.time_vector.size) + + # Store states, input, used later to minimize re-computation self.last_x = np.full(self.system.nstates, np.nan) self.last_inputs = 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") + + # # Cost function # @@ -178,6 +216,10 @@ def __init__( # parameter `x` prior to calling the optimization algorithm. # def _cost_function(self, inputs): + if self.log: + start_time = time.process_time() + logging.info("_cost_function called at: %g", start_time) + # Retrieve the initial state and reshape the input vector x = self.x inputs = inputs.reshape( @@ -188,23 +230,42 @@ def _cost_function(self, inputs): np.array_equal(inputs, self.last_inputs): 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.time_vector, inputs, x, return_x=True) + self.system_simulations += 1 self.last_x = x self.last_inputs = inputs self.last_states = states + if self.log: + logging.debug("input_output_response returned states\n" + + str(states)) + # Trajectory cost # TODO: vectorize cost = 0 - for i, time in enumerate(self.time_vector): + for i, t in enumerate(self.time_vector): cost += self.integral_cost(states[:,i], inputs[:,i]) # Terminal cost if self.terminal_cost is not None: cost += self.terminal_cost(states[:,-1], inputs[:,-1]) + # Update statistics + self.cost_evaluations += 1 + if self.log: + stop_time = time.process_time() + self.cost_process_time += stop_time - start_time + logging.info( + "_cost_function returning %g; elapsed time: %g", + cost, stop_time - start_time) + # Return the total cost for this input sequence return cost @@ -253,6 +314,10 @@ def _cost_function(self, inputs): # state prior to optimization and retrieve it here. # def _constraint_function(self, inputs): + if self.log: + start_time = time.process_time() + logging.info("_constraint_function called at: %g", start_time) + # Retrieve the initial state and reshape the input vector x = self.x inputs = inputs.reshape( @@ -263,16 +328,22 @@ def _constraint_function(self, inputs): np.array_equal(inputs, self.last_inputs): 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.time_vector, inputs, x, return_x=True) + self.system_simulations += 1 self.last_x = x self.last_inputs = inputs self.last_states = states # Evaluate the constraint function along the trajectory value = [] - for i, time in enumerate(self.time_vector): + for i, t in enumerate(self.time_vector): for constraint in self.trajectory_constraints: type, fun, lb, ub = constraint if np.all(lb == ub): @@ -303,10 +374,30 @@ def _constraint_function(self, inputs): raise TypeError("unknown constraint type %s" % constraint[0]) + # Update statistics + self.constraint_evaluations += 1 + if self.log: + stop_time = time.process_time() + self.constraint_process_time += stop_time - start_time + logging.info( + "_constraint_function elapsed time: %g", + stop_time - start_time) + + # Debugging information + if self.log: + logging.debug( + "constraint values\n" + str(value) + "\n" + + "lb, ub =\n" + str(self.constraint_lb) + "\n" + + str(self.constraint_ub)) + # Return the value of the constraint function return np.hstack(value) def _eqconst_function(self, inputs): + if self.log: + start_time = time.process_time() + logging.info("_eqconst_function called at: %g", start_time) + # Retrieve the initial state and reshape the input vector x = self.x inputs = inputs.reshape( @@ -317,16 +408,26 @@ def _eqconst_function(self, inputs): np.array_equal(inputs, self.last_inputs): 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.time_vector, inputs, x, return_x=True) + self.system_simulations += 1 self.last_x = x self.last_inputs = inputs self.last_states = states + if self.log: + logging.debug("input_output_response returned states\n" + + str(states)) + # Evaluate the constraint function along the trajectory value = [] - for i, time in enumerate(self.time_vector): + for i, t in enumerate(self.time_vector): for constraint in self.trajectory_constraints: type, fun, lb, ub = constraint if np.any(lb != ub): @@ -357,9 +458,59 @@ def _eqconst_function(self, inputs): raise TypeError("unknown constraint type %s" % constraint[0]) + # Update statistics + self.eqconst_evaluations += 1 + if self.log: + stop_time = time.process_time() + self.eqconst_process_time += stop_time - start_time + logging.info( + "_eqconst_function elapsed time: %g", stop_time - start_time) + + # Debugging information + if self.log: + logging.debug( + "constraint values\n" + str(value) + "\n" + + "lb, ub =\n" + str(self.constraint_lb) + "\n" + + str(self.constraint_ub)) + # Return the value of the constraint function return np.hstack(value) + # + # Log and statistics + # + # To allow some insight into where time is being spent, we keep track of + # the number of times that various functions are called and (optionally) + # how long we spent inside each function. + # + def _reset_statistics(self, log=False): + """Reset counters for keeping track of statistics""" + self.log=log + self.cost_evaluations, self.cost_process_time = 0, 0 + self.constraint_evaluations, self.constraint_process_time = 0, 0 + self.eqconst_evaluations, self.eqconst_process_time = 0, 0 + self.system_simulations = 0 + + def _print_statistics(self, reset=True): + """Print out summary statistics from last run""" + print("Summary statistics:") + print("* Cost function calls:", self.cost_evaluations) + if self.log: + print("* Cost function process time:", self.cost_process_time) + if self.constraint_evaluations: + print("* Constraint calls:", self.constraint_evaluations) + if self.log: + print( + "* Constraint process time:", self.constraint_process_time) + if self.eqconst_evaluations: + print("* Eqconst calls:", self.eqconst_evaluations) + if self.log: + print( + "* Eqconst process time:", self.eqconst_process_time) + print("* System simulations:", self.system_simulations) + if reset: + self._reset_statistics(self.log) + # Create an input/output system implementing an MPC controller def _create_mpc_iosystem(self, dt=True): """Create an I/O system implementing an MPC controller""" @@ -367,8 +518,8 @@ def _update(t, x, u, params={}): inputs = x.reshape((self.system.ninputs, self.time_vector.size)) self.initial_guess = np.hstack( [inputs[:,1:], inputs[:,-1:]]).reshape(-1) - _, inputs = self.compute_trajectory(u) - return inputs.reshape(-1) + result = self.compute_trajectory(u) + return result.inputs.reshape(-1) def _output(t, x, u, params={}): inputs = x.reshape((self.system.ninputs, self.time_vector.size)) @@ -382,12 +533,13 @@ def _output(t, x, u, params={}): # Compute the optimal trajectory from the current state def compute_trajectory( - self, x, squeeze=None, transpose=None, return_x=None): + self, x, squeeze=None, transpose=None, return_x=None, + print_summary=True): """Compute the optimal input at state x Parameters ---------- - x: array-like or number, optional + x : array-like or number, optional Initial state for the system. return_x : bool, optional If True, return the values of the state at each time (default = @@ -421,29 +573,12 @@ def compute_trajectory( # Call ScipPy optimizer res = sp.optimize.minimize( self._cost_function, self.initial_guess, - constraints=self.constraints) - - # See if we got an answer - if not res.success: - warnings.warn( - "unable to solve optimal control problem\n" - "scipy.optimize.minimize returned " + res.message, UserWarning) - return None - - # Reshape the input vector - inputs = res.x.reshape( - (self.system.ninputs, self.time_vector.size)) - - if return_x: - # Simulate the system if we need the state back - _, _, states = ct.input_output_response( - self.system, self.time_vector, inputs, x, return_x=True) - else: - states=None + constraints=self.constraints, options=self.options) - return _process_time_response( - self.system, self.time_vector, inputs, states, - transpose=transpose, return_x=return_x, squeeze=squeeze) + # Process and return the results + return OptimalControlResult( + self, res, transpose=transpose, return_x=return_x, + squeeze=squeeze, print_summary=print_summary) # Compute the current input to apply from the current state (MPC style) def compute_mpc(self, x, squeeze=None): @@ -468,17 +603,81 @@ def compute_mpc(self, x, squeeze=None): Optimal input for the system at the current time. If the system is SISO and squeeze is not True, the array is 1D (indexed by time). If the system is not SISO or squeeze is False, the array - is 2D (indexed by the output number and time). + is 2D (indexed by the output number and time). Set to `None` + if the optimization failed. """ - _, inputs = self.compute_trajectory(x, squeeze=squeeze) - return None if inputs is None else inputs[:,0] + results = self.compute_trajectory(x, squeeze=squeeze) + return inputs[:, 0] if results.success else None + + +# Optimal control result +class OptimalControlResult(sp.optimize.OptimizeResult): + """Represents the optimal control result + + This class is a subclass of :class:`sp.optimize.OptimizeResult` with + additional attributes associated with solving optimal control problems. + + Attributes + ---------- + inputs : ndarray + The optimal inputs associated with the optimal control problem. + states : ndarray + If `return_states` was set to true, stores the state trajectory + associated with the optimal input. + success : bool + Whether or not the optimizer exited successful. + problem : OptimalControlProblem + Optimal control problem that generated this solution. + + """ + def __init__( + self, ocp, res, return_x=False, print_summary=False, + transpose=None, squeeze=None): + # Copy all of the fields we were sent by sp.optimize.minimize() + for key, val in res.items(): + setattr(self, key, val) + + # Remember the optimal control problem that we solved + self.problem = ocp + + # Reshape and process the input vector + inputs = res.x.reshape( + (ocp.system.ninputs, ocp.time_vector.size)) + + # See if we got an answer + if not res.success: + warnings.warn( + "unable to solve optimal control problem\n" + "scipy.optimize.minimize returned " + res.message, UserWarning) + + # Optionally print summary information + if print_summary: + ocp._print_statistics() + + if return_x and res.success: + # Simulate the system if we need the state back + _, _, states = ct.input_output_response( + ocp.system, ocp.time_vector, inputs, ocp.x, return_x=True) + ocp.system_simulations += 1 + else: + states = None + + retval = _process_time_response( + ocp.system, ocp.time_vector, inputs, states, + transpose=transpose, return_x=return_x, squeeze=squeeze) + + self.time = retval[0] + self.inputs = retval[1] + self.states = None if states is None else retval[2] # Compute the input for a nonlinear, (constrained) optimal control problem def compute_optimal_input( sys, horizon, X0, cost, constraints=[], terminal_cost=None, - terminal_constraints=[], squeeze=None, transpose=None, return_x=None): + terminal_constraints=[], initial_guess=None, squeeze=None, + transpose=None, return_x=None, log=False, options={}): + """Compute the solution to an optimal control problem Parameters @@ -522,6 +721,15 @@ def compute_optimal_input( List of constraints that should hold at the end of the trajectory. Same format as `constraints`. + 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. + + log : bool, optional + If `True`, turn on logging messages (using Python logging module). + return_x : bool, optional If True, return the values of the state at each time (default = False). @@ -535,10 +743,13 @@ def compute_optimal_input( If True, assume that 2D input arrays are transposed from the standard format. Used to convert MATLAB-style inputs to our format. + options : dict, optional + Solver options (passed to :func:`scipy.optimal.minimize`). + Returns ------- time : array - Time values of the input. + Time values of the input or `None` if the optimimation fails. inputs : array Optimal inputs for the system. If the system is SISO and squeeze is not True, the array is 1D (indexed by time). If the system is not SISO or @@ -551,7 +762,8 @@ def compute_optimal_input( # Set up the optimal control problem ocp = OptimalControlProblem( sys, horizon, cost, trajectory_constraints=constraints, - terminal_cost=terminal_cost, terminal_constraints=terminal_constraints) + terminal_cost=terminal_cost, terminal_constraints=terminal_constraints, + initial_guess=initial_guess, log=log, options=options) # Solve for the optimal input from the current state return ocp.compute_trajectory( @@ -561,7 +773,7 @@ def compute_optimal_input( # Create a model predictive controller for an optimal control problem def create_mpc_iosystem( sys, horizon, cost, constraints=[], terminal_cost=None, - terminal_constraints=[], dt=True): + terminal_constraints=[], dt=True, log=False, options={}): """Create a model predictive I/O control system This function creates an input/output system that implements a model @@ -593,6 +805,9 @@ def create_mpc_iosystem( List of constraints that should hold at the end of the trajectory. Same format as `constraints`. + options : dict, optional + Solver options (passed to :func:`scipy.optimal.minimize`). + Returns ------- ctrl : InputOutputSystem @@ -605,7 +820,8 @@ def create_mpc_iosystem( # Set up the optimal control problem ocp = OptimalControlProblem( sys, horizon, cost, trajectory_constraints=constraints, - terminal_cost=terminal_cost, terminal_constraints=terminal_constraints) + terminal_cost=terminal_cost, terminal_constraints=terminal_constraints, + log=log, options=options) # Return an I/O system implementing the model predictive controller return ocp._create_mpc_iosystem(dt=dt) diff --git a/control/tests/obc_test.py b/control/tests/obc_test.py index 9ddc32a8c..e15b92d41 100644 --- a/control/tests/obc_test.py +++ b/control/tests/obc_test.py @@ -11,7 +11,7 @@ import control as ct import control.obc as obc from control.tests.conftest import slycotonly - +from numpy.lib import NumpyVersion def test_finite_horizon_simple(): # Define a linear system with constraints @@ -35,8 +35,9 @@ def test_finite_horizon_simple(): x0 = [4, 0] # Retrieve the full open-loop predictions - t, u_openloop = obc.compute_optimal_input( + results = obc.compute_optimal_input( sys, time, x0, cost, constraints, squeeze=True) + t, u_openloop = results.time, results.inputs np.testing.assert_almost_equal( u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=4) @@ -80,7 +81,7 @@ def test_class_interface(): sys, time, integral_cost, trajectory_constraints, terminal_cost) # Add tests to make sure everything works - t, u_openloop = optctrl.compute_trajectory([1, 1]) + results = optctrl.compute_trajectory([1, 1]) def test_mpc_iosystem(): @@ -128,12 +129,13 @@ def test_mpc_iosystem(): # Choose a nearby initial condition to speed up computation X0 = np.hstack([xd, np.kron(ud, np.ones(6))]) * 0.99 - Nsim = 10 + Nsim = 12 tout, xout = ct.input_output_response( loop, np.arange(0, Nsim) * 0.2, 0, X0) # Make sure the system converged to the desired state - np.testing.assert_almost_equal(xout[0:sys.nstates, -1], xd, decimal=1) + np.testing.assert_allclose( + xout[0:sys.nstates, -1], xd, atol=0.1, rtol=0.01) # Test various constraint combinations; need to use a somewhat convoluted @@ -148,8 +150,7 @@ def test_mpc_iosystem(): np.array([[1, 0], [0, 1], [-1, 0], [0, -1]]), [5, 5, 5, 5]), (obc.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], [(sp.optimize.NonlinearConstraint, - lambda x, u: np.array([abs(x[0]), x[1], u[0]**2]), - [-np.inf, -5, -1e-12], [5, 5, 1],)], # -1e-12 for SciPy bug? + lambda x, u: np.array([x[0], x[1], u[0]]), [-5, -5, -1], [5, 5, 1])], ]) def test_constraint_specification(constraint_list): sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) @@ -177,7 +178,8 @@ def test_constraint_specification(constraint_list): # Compute optimal control and compare against MPT3 solution x0 = [4, 0] - t, u_openloop = optctrl.compute_trajectory(x0, squeeze=True) + results = optctrl.compute_trajectory(x0, squeeze=True) + t, u_openloop = results.time, results.inputs np.testing.assert_almost_equal( u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=3) @@ -202,7 +204,13 @@ def test_terminal_constraints(): # Find a path to the origin x0 = np.array([4, 3]) - t, u1, x1 = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + result = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + t, u1, x1 = result.time, result.inputs, result.states + + # Bug prior to SciPy 1.6 will result in incorrect results + if NumpyVersion(sp.__version__) < '1.6.0': + pytest.xfail("SciPy 1.6 or higher required") + np.testing.assert_almost_equal(x1[:,-1], 0) # Make sure it is a straight line @@ -217,7 +225,8 @@ def test_terminal_constraints(): sys, time, cost, terminal_constraints=final_point) # Find a path to the origin - t, u2, x2 = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + results = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + t, u2, x2 = results.time, results.inputs, results.states np.testing.assert_almost_equal(x2[:,-1], 0) # Make sure that it is *not* a straight line path @@ -228,7 +237,8 @@ def test_terminal_constraints(): constraints = [obc.input_range_constraint(sys, [-1, -1], [1, 1])] optctrl = obc.OptimalControlProblem( sys, time, cost, constraints, terminal_constraints=final_point) - t, u3, x3 = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + results = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + t, u3, x3 = results.time, results.inputs, results.states np.testing.assert_almost_equal(x2[:,-1], 0) # Make sure we got a new path and didn't violate the constraints @@ -239,4 +249,4 @@ def test_terminal_constraints(): x0 = np.array([10, 3]) with pytest.warns(UserWarning, match="unable to solve"): res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) - assert res == None + assert not res.success diff --git a/doc/obc.rst b/doc/obc.rst index 4fcec2ce5..072094beb 100644 --- a/doc/obc.rst +++ b/doc/obc.rst @@ -106,24 +106,153 @@ state and/or input, either along the trajectory and at the terminal time. The `obc` module operates by converting the optimal control 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 `~control.obc.compute_optimal_input` function: +by using the `~control.obc.compute_optimal_input` function:: - import control.obc as obc - inputs = obc.compute_optimal_inputs(sys, horizon, X0, cost, constraints) + inputs = obc.compute_optimal_input(sys, horizon, X0, cost, constraints) The `sys` parameter should be a :class:`~control.InputOutputSystem` and the `horizon` parameter should represent a time vector that gives the list of -times at which the `cost` and `constraints` should be evaluated. By default, -`constraints` are taken to be trajectory constraints holding at all points -on the trajectory. The `terminal_constraint` parameter can be used to -specify a constraint that only holds at the final point of the trajectory -and the `terminal_cost` paramter can be used to specify a terminal cost -function. +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` +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:: + + (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 + +.. code:: python + + lb <= A @ np.hstack([x, u]) <= ub + +A nonlinear constraint is satisfied if + +.. code:: python + + lb <= f(x, u) <= ub +By default, `constraints` are taken to be trajectory constraints holding at +all points on the trajectory. The `terminal_constraint` parameter can be +used to specify a constraint that only holds at the final point of the +trajectory. + +To simplify the specification of cost functions and constraints, the +:mod:`~control.ios` module defines a number of utility functions: + +.. autosummary:: + + ~control.obc.quadratic_cost + ~control.obc.input_poly_constraint + ~control.obc.input_rank_constraint + ~control.obc.output_poly_constraint + ~control.obc.output_rank_constraint + ~control.obc.state_poly_constraint + ~control.obc.state_rank_constraint Example ======= +Consider the vehicle steering example described in FBS2e. The dynamics of +the system can be defined as a nonlinear input/output system using the +following code:: + + import numpy as np + import control as ct + import control.obc as obc + import matplotlib.pyplot as plt + + def vehicle_update(t, x, u, params): + # Get the parameters for the model + l = params.get('wheelbase', 3.) # vehicle wheelbase + phimax = params.get('maxsteer', 0.5) # max steering angle (rad) + + # Saturate the steering input + phi = np.clip(u[1], -phimax, phimax) + + # Return the derivative of the state + return np.array([ + np.cos(x[2]) * u[0], # xdot = cos(theta) v + np.sin(x[2]) * u[0], # ydot = sin(theta) v + (u[0] / l) * np.tan(phi) # thdot = v/l tan(phi) + ]) + + def vehicle_output(t, x, u, params): + return x # return x, y, theta (full state) + + # Define the vehicle steering dynamics as an input/output system + vehicle = ct.NonlinearIOSystem( + vehicle_update, vehicle_output, states=3, name='vehicle', + inputs=('v', 'phi'), outputs=('x', 'y', 'theta')) + +We consider an optimal control problem that consists of "changing lanes" by +moving from the point x = 0m, y = -2 m, :math:`\theta` = 0 to the point x = +100m, 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.] + Tf = 10 + +To set up the optimal control problem we design a cost function that +penalizes the state and input using quadratic cost functions:: + + Q = np.diag([10, 10, 1]) + R = np.eye(2) * 0.1 + cost = obc.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + +We also constraint the maximum turning rate to 0.1 radians (about 6 degees) +and constrain the velocity to be in the range of 9 m/s to 11 m/s:: + + constraints = [ obc.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + terminal = [ obc.state_range_constraint(vehicle, xf, xf) ] + +Finally, we solve for the optimal inputs and plot the results:: + + horizon = np.linspace(0, Tf, 20, endpoint=True) + straight = [10, 0] # straight trajectory + bend_left = [10, 0.01] # slight left veer + t, u = obc.compute_optimal_input( + # vehicle, horizon, x0, cost, constraints, + # initial_guess=straight, logging=True) + vehicle, horizon, x0, cost, constraints, + terminal_constraints=terminal, initial_guess=straight) + t, y = ct.input_output_response(vehicle, horizon, u, x0) + + plt.subplot(3, 1, 1) + plt.plot(y[0], y[1]) + plt.xlabel("x [m]") + plt.ylabel("y [m]") + + plt.subplot(3, 1, 2) + plt.plot(t, u[0]) + plt.xlabel("t [sec]") + plt.ylabel("u1 [m/s]") + + plt.subplot(3, 1, 3) + plt.plot(t, u[1]) + plt.xlabel("t [sec]") + plt.ylabel("u2 [rad/s]") + + plt.suptitle("Lane change manuever") + plt.tight_layout() + plt.show() + +which yields + +.. image:: steer-optimal.png + + Module classes and functions ============================ .. autosummary:: diff --git a/examples/run_examples.sh b/examples/run_examples.sh index 6f04fe12c..48d481aef 100755 --- a/examples/run_examples.sh +++ b/examples/run_examples.sh @@ -18,6 +18,10 @@ for example in *.py; do fi done +# Get rid of the output files +rm *.log + +# List any files that generated errors if [ -n "${example_errors}" ]; then echo These examples had errors: echo "${example_errors}" diff --git a/examples/steering-optimal.py b/examples/steering-optimal.py new file mode 100644 index 000000000..109b60d13 --- /dev/null +++ b/examples/steering-optimal.py @@ -0,0 +1,151 @@ +# steering-optimal.py - optimal control for vehicle steering +# RMM, 18 Feb 2021 +# +# This file works through an optimal control example for the vehicle +# steering system. It is intended to demonstrate the functionality +# for optimization-based control (obc) module in the python-control +# package. + +import numpy as np +import control as ct +import control.obc as obc +import matplotlib.pyplot as plt +import logging + +# +# Vehicle steering dynamics +# +# The vehicle dynamics are given by a simple bicycle model. We take the state +# of the system as (x, y, theta) where (x, y) is the position of the vehicle +# in the plane and theta is the angle of the vehicle with respect to +# horizontal. The vehicle input is given by (v, phi) where v is the forward +# velocity of the vehicle and phi is the angle of the steering wheel. The +# model includes saturation of the vehicle steering angle. +# +# System state: x, y, theta +# System input: v, phi +# System output: x, y +# System parameters: wheelbase, maxsteer +# +def vehicle_update(t, x, u, params): + # Get the parameters for the model + l = params.get('wheelbase', 3.) # vehicle wheelbase + phimax = params.get('maxsteer', 0.5) # max steering angle (rad) + + # Saturate the steering input + phi = np.clip(u[1], -phimax, phimax) + + # Return the derivative of the state + return np.array([ + np.cos(x[2]) * u[0], # xdot = cos(theta) v + np.sin(x[2]) * u[0], # ydot = sin(theta) v + (u[0] / l) * np.tan(phi) # thdot = v/l tan(phi) + ]) + + +def vehicle_output(t, x, u, params): + return x # return x, y, theta (full state) + +# Define the vehicle steering dynamics as an input/output system +vehicle = ct.NonlinearIOSystem( + vehicle_update, vehicle_output, states=3, name='vehicle', + inputs=('v', 'phi'), + outputs=('x', 'y', 'theta')) + +# +# Utility function to plot the results +# +def plot_results(t, y, u, figure=None, yf=None): + plt.figure(figure) + + # Plot the xy trajectory + plt.subplot(3, 1, 1) + plt.plot(y[0], y[1]) + plt.xlabel("x [m]") + plt.ylabel("y [m]") + if yf: + plt.plot(yf[0], yf[1], 'ro') + + # Plot the inputs as a function of time + plt.subplot(3, 1, 2) + plt.plot(t, u[0]) + plt.xlabel("t [sec]") + plt.ylabel("velocity [m/s]") + + plt.subplot(3, 1, 3) + plt.plot(t, u[1]) + plt.xlabel("t [sec]") + plt.ylabel("steering [rad/s]") + + plt.suptitle("Lane change manuever") + plt.tight_layout() + plt.show(block=False) + +# +# Optimal control problem +# +# Perform a "lane change" manuever over the course of 10 seconds. +# + +# Initial and final conditions +x0 = [0., -2., 0.]; u0 = [10., 0.] +xf = [100., 2., 0.]; uf = [10., 0.] +Tf = 10 + +# Set up the cost functions +Q = np.diag([0.1, 1, 0.1]) # keep lateral error low +R = np.eye(2) # minimize applied inputs +cost = obc.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + +# +# Set up different types of constraints to demonstrate +# + +# Input constraints +constraints = [ obc.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + +# Terminal constraints (optional) +terminal = [ obc.state_range_constraint(vehicle, xf, xf) ] + +# Time horizon and possible initial guessses +horizon = np.linspace(0, Tf, 10, endpoint=True) +straight = [10, 0] # straight trajectory +bend_left = [10, 0.01] # slight left veer + +# +# Solve the optimal control problem in dififerent ways +# + +# Basic setup: quadratic cost, no terminal constraint, straight initial path +logging.basicConfig( + level=logging.DEBUG, filename="steering-straight.log", + filemode='w', force=True) +result = obc.compute_optimal_input( + vehicle, horizon, x0, cost, initial_guess=straight, + log=True, options={'eps': 0.01}) +t1, u1 = result.time, result.inputs +t1, y1 = ct.input_output_response(vehicle, horizon, u1, x0) +plot_results(t1, y1, u1, figure=1, yf=xf[0:2]) + +# Add constraint on the input to avoid high steering angles +logging.basicConfig( + level=logging.INFO, filename="./steering-bendleft.log", + filemode='w', force=True) +result = obc.compute_optimal_input( + vehicle, horizon, x0, cost, constraints, initial_guess=bend_left, + log=True, options={'eps': 0.01}) +t2, u2 = result.time, result.inputs +t2, y2 = ct.input_output_response(vehicle, horizon, u2, x0) +plot_results(t2, y2, u2, figure=2, yf=xf[0:2]) + +# Resolve with a terminal constraint (starting with previous result) +logging.basicConfig( + level=logging.WARN, filename="./steering-terminal.log", + filemode='w', force=True) +result = obc.compute_optimal_input( + vehicle, horizon, x0, cost, constraints, + terminal_constraints=terminal, initial_guess=u2, + log=True, options={'eps': 0.01}) +t3, u3 = result.time, result.inputs +t3, y3 = ct.input_output_response(vehicle, horizon, u3, x0) +plot_results(t3, y3, u3, figure=3, yf=xf[0:2]) From ea2884d0287aa56d2ea6b4693b48868c10f20f62 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 19 Feb 2021 19:51:48 -0800 Subject: [PATCH 09/18] slight refactoring of cost functions + example tweaks --- control/obc.py | 60 +++++++++++++----- examples/steering-optimal.py | 117 +++++++++++++++++++++++------------ 2 files changed, 123 insertions(+), 54 deletions(-) diff --git a/control/obc.py b/control/obc.py index c4fa0dc4b..a9eae643c 100644 --- a/control/obc.py +++ b/control/obc.py @@ -55,7 +55,7 @@ class OptimalControlProblem(): def __init__( self, sys, time_vector, integral_cost, trajectory_constraints=[], terminal_cost=None, terminal_constraints=[], initial_guess=None, - log=False, options={}): + log=False, **kwargs): """Set up an optimal control problem To describe an optimal control problem we need an input/output system, @@ -90,8 +90,8 @@ def __init__( extension of the time axis. log : bool, optional If `True`, turn on logging messages (using Python logging module). - options : dict, optional - Solver options (passed to :func:`scipy.optimal.minimize`). + kwargs : dict, optional + Additional parameters (passed to :func:`scipy.optimal.minimize`). Returns ------- @@ -107,7 +107,7 @@ def __init__( self.trajectory_constraints = trajectory_constraints self.terminal_cost = terminal_cost self.terminal_constraints = terminal_constraints - self.options = options + self.kwargs = kwargs # # Compute and store constraints @@ -251,7 +251,15 @@ def _cost_function(self, inputs): # TODO: vectorize cost = 0 for i, t in enumerate(self.time_vector): - cost += self.integral_cost(states[:,i], inputs[:,i]) + if ct.isctime(self.system): + # Approximate the integral using trapezoidal rule + if i > 0: + cost += 0.5 * ( + self.integral_cost(states[:, i-1], inputs[:, i-1]) + + self.integral_cost(states[:, i], inputs[:, i])) * ( + self.time_vector[i] - self.time_vector[i-1]) + else: + cost += self.integral_cost(states[:,i], inputs[:,i]) # Terminal cost if self.terminal_cost is not None: @@ -573,7 +581,7 @@ def compute_trajectory( # Call ScipPy optimizer res = sp.optimize.minimize( self._cost_function, self.initial_guess, - constraints=self.constraints, options=self.options) + constraints=self.constraints, **self.kwargs) # Process and return the results return OptimalControlResult( @@ -676,7 +684,7 @@ def __init__( def compute_optimal_input( sys, horizon, X0, cost, constraints=[], terminal_cost=None, terminal_constraints=[], initial_guess=None, squeeze=None, - transpose=None, return_x=None, log=False, options={}): + transpose=None, return_x=None, log=False, **kwargs): """Compute the solution to an optimal control problem @@ -743,8 +751,8 @@ def compute_optimal_input( If True, assume that 2D input arrays are transposed from the standard format. Used to convert MATLAB-style inputs to our format. - options : dict, optional - Solver options (passed to :func:`scipy.optimal.minimize`). + kwargs : dict, optional + Additional parameters (passed to :func:`scipy.optimal.minimize`). Returns ------- @@ -763,7 +771,7 @@ def compute_optimal_input( ocp = OptimalControlProblem( sys, horizon, cost, trajectory_constraints=constraints, terminal_cost=terminal_cost, terminal_constraints=terminal_constraints, - initial_guess=initial_guess, log=log, options=options) + initial_guess=initial_guess, log=log, **kwargs) # Solve for the optimal input from the current state return ocp.compute_trajectory( @@ -773,7 +781,7 @@ def compute_optimal_input( # Create a model predictive controller for an optimal control problem def create_mpc_iosystem( sys, horizon, cost, constraints=[], terminal_cost=None, - terminal_constraints=[], dt=True, log=False, options={}): + terminal_constraints=[], dt=True, log=False, **kwargs): """Create a model predictive I/O control system This function creates an input/output system that implements a model @@ -805,8 +813,8 @@ def create_mpc_iosystem( List of constraints that should hold at the end of the trajectory. Same format as `constraints`. - options : dict, optional - Solver options (passed to :func:`scipy.optimal.minimize`). + kwargs : dict, optional + Additional parameters (passed to :func:`scipy.optimal.minimize`). Returns ------- @@ -821,7 +829,7 @@ def create_mpc_iosystem( ocp = OptimalControlProblem( sys, horizon, cost, trajectory_constraints=constraints, terminal_cost=terminal_cost, terminal_constraints=terminal_constraints, - log=log, options=options) + log=log, **kwargs) # Return an I/O system implementing the model predictive controller return ocp._create_mpc_iosystem(dt=dt) @@ -863,8 +871,28 @@ def quadratic_cost(sys, Q, R, x0=0, u0=0): input. The call signature of the function is cost_fun(x, u). """ - Q = np.atleast_2d(Q) - R = np.atleast_2d(R) + # Process the input arguments + if Q is not None: + Q = np.atleast_2d(Q) + if Q.size == 1: # allow scalar weights + Q = np.eye(sys.nstates) * Q.item() + elif Q.shape != (sys.nstates, sys.nstates): + raise ValueError("Q matrix is the wrong shape") + + if R is not None: + R = np.atleast_2d(R) + if R.size == 1: # allow scalar weights + R = np.eye(sys.ninputs) * R.item() + elif R.shape != (sys.ninputs, sys.ninputs): + raise ValueError("R matrix is the wrong shape") + + if Q is None: + return lambda x, u: ((u-u0) @ R @ (u-u0)).item() + + if R is None: + return lambda x, u: ((x-x0) @ Q @ (x-x0)).item() + + # Received both Q and R matrices return lambda x, u: ((x-x0) @ Q @ (x-x0) + (u-u0) @ R @ (u-u0)).item() diff --git a/examples/steering-optimal.py b/examples/steering-optimal.py index 109b60d13..23d2e592b 100644 --- a/examples/steering-optimal.py +++ b/examples/steering-optimal.py @@ -92,60 +92,101 @@ def plot_results(t, y, u, figure=None, yf=None): xf = [100., 2., 0.]; uf = [10., 0.] Tf = 10 -# Set up the cost functions -Q = np.diag([0.1, 1, 0.1]) # keep lateral error low -R = np.eye(2) # minimize applied inputs -cost = obc.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) - # -# Set up different types of constraints to demonstrate +# Approach 1: standard quadratic cost +# +# We can set up the optimal control problem as trying to minimize the +# distance form the desired final point while at the same time as not +# exerting too much control effort to achieve our goal. +# +# Note: depending on what version of SciPy you are using, you might get a +# warning message about precision loss, but the solution is pretty good. # -# Input constraints -constraints = [ obc.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] - -# Terminal constraints (optional) -terminal = [ obc.state_range_constraint(vehicle, xf, xf) ] +# Set up the cost functions +Q = np.diag([1, 10, 1]) # keep lateral error low +R = np.diag([1, 1]) # minimize applied inputs +cost1 = obc.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) -# Time horizon and possible initial guessses +# Define the time horizon (and spacing) for the optimization horizon = np.linspace(0, Tf, 10, endpoint=True) -straight = [10, 0] # straight trajectory -bend_left = [10, 0.01] # slight left veer -# -# Solve the optimal control problem in dififerent ways -# +# Provide an intial guess (will be extended to entire horizon) +bend_left = [10, 0.01] # slight left veer -# Basic setup: quadratic cost, no terminal constraint, straight initial path +# Turn on debug level logging so that we can see what the optimizer is doing logging.basicConfig( - level=logging.DEBUG, filename="steering-straight.log", + level=logging.DEBUG, filename="steering-integral_cost.log", filemode='w', force=True) -result = obc.compute_optimal_input( - vehicle, horizon, x0, cost, initial_guess=straight, - log=True, options={'eps': 0.01}) -t1, u1 = result.time, result.inputs + +# Compute the optimal control, setting step size for gradient calculation (eps) +result1 = obc.compute_optimal_input( + vehicle, horizon, x0, cost1, initial_guess=bend_left, log=True, + options={'eps': 0.01}) + +# Extract and plot the results (+ state trajectory) +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]) -# Add constraint on the input to avoid high steering angles +# +# Approach 2: input cost, input constraints, terminal cost +# +# The previous solution integrates the position error for the entire +# horizon, and so the car changes lanes very quickly (at the cost of larger +# inputs). Instead, we can penalize the final state and impose a higher +# cost on the inputs, resuling in a more graduate lane change. +# +# We also set the solver explicitly (its actually the default one, but shows +# how to do this). +# + +# Add input constraint, input cost, terminal cost +constraints = [ obc.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] +traj_cost = obc.quadratic_cost(vehicle, None, np.diag([0.1, 1]), u0=uf) +term_cost = obc.quadratic_cost(vehicle, np.diag([1, 10, 10]), None, x0=xf) + +# Change logging to keep less information logging.basicConfig( - level=logging.INFO, filename="./steering-bendleft.log", + level=logging.INFO, filename="./steering-terminal_cost.log", filemode='w', force=True) -result = obc.compute_optimal_input( - vehicle, horizon, x0, cost, constraints, initial_guess=bend_left, - log=True, options={'eps': 0.01}) -t2, u2 = result.time, result.inputs + +# Compute the optimal control +result2 = obc.compute_optimal_input( + vehicle, horizon, x0, traj_cost, constraints, terminal_cost=term_cost, + initial_guess=bend_left, log=True, + method='SLSQP', options={'eps': 0.01}) + +# Extract and plot the results (+ state trajectory) +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]) -# Resolve with a terminal constraint (starting with previous result) -logging.basicConfig( - level=logging.WARN, filename="./steering-terminal.log", - filemode='w', force=True) -result = obc.compute_optimal_input( - vehicle, horizon, x0, cost, constraints, - terminal_constraints=terminal, initial_guess=u2, - log=True, options={'eps': 0.01}) -t3, u3 = result.time, result.inputs +# +# Approach 3: terminal constraints and new solver +# +# As a final example, we can remove the cost function on the state and +# replace it with a terminal *constraint* on the state. If a solution is +# found, it guarantees we get to exactly the final state. +# +# To speeds things up a bit, we initalize the problem using the previous +# optimal controller (which didn't quite hit the final value). +# + +# Input cost and terminal constraints +cost3 = obc.quadratic_cost(vehicle, np.zeros((3,3)), R, u0=uf) +terminal = [ obc.state_range_constraint(vehicle, xf, xf) ] + +# Reset logging to its default values +logging.basicConfig(level=logging.WARN, force=True) + +# Compute the optimal control +result3 = obc.compute_optimal_input( + vehicle, horizon, x0, cost3, constraints, + terminal_constraints=terminal, initial_guess=u2, log=True, + options={'eps': 0.01}) + +# Extract and plot the results (+ state trajectory) +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]) From 94940927221a914738e612394d168ca61ec4a3d0 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 20 Feb 2021 10:42:40 -0800 Subject: [PATCH 10/18] rename obc to optimal, new examples/unit tests --- control/config.py | 11 +- control/{obc.py => optimal.py} | 109 ++++++---- control/tests/config_test.py | 11 ++ .../tests/{obc_test.py => optimal_test.py} | 186 +++++++++++------- doc/classes.rst | 2 +- doc/index.rst | 2 +- doc/{obc.rst => optimal.rst} | 123 +++++++----- doc/steering-optimal.png | Bin 0 -> 39597 bytes examples/mpc_aircraft.ipynb | 8 +- examples/steering-optimal.py | 43 ++-- 10 files changed, 309 insertions(+), 186 deletions(-) rename control/{obc.py => optimal.py} (93%) rename control/tests/{obc_test.py => optimal_test.py} (54%) rename doc/{obc.rst => optimal.rst} (71%) create mode 100644 doc/steering-optimal.png diff --git a/control/config.py b/control/config.py index 9bb2dfcf4..2d2cc6248 100644 --- a/control/config.py +++ b/control/config.py @@ -67,7 +67,7 @@ def reset_defaults(): defaults.update(_iosys_defaults) -def _get_param(module, param, argval=None, defval=None, pop=False): +def _get_param(module, param, argval=None, defval=None, pop=False, last=False): """Return the default value for a configuration option. The _get_param() function is a utility function used to get the value of a @@ -91,11 +91,13 @@ def _get_param(module, param, argval=None, defval=None, pop=False): `config.defaults` dictionary. If a dictionary is provided, then `module.param` is used to determine the default value. Defaults to None. - pop : bool + pop : bool, optional If True and if argval is a dict, then pop the remove the parameter entry from the argval dict after retreiving it. This allows the use of a keyword argument list to be passed through to other functions internal to the function being called. + last : bool, optional + If True, check to make sure dictionary is empy after processing. """ @@ -108,7 +110,10 @@ def _get_param(module, param, argval=None, defval=None, pop=False): # If we were passed a dict for the argval, get the param value from there if isinstance(argval, dict): - argval = argval.pop(param, None) if pop else argval.get(param, None) + val = argval.pop(param, None) if pop else argval.get(param, None) + if last and argval: + raise TypeError("unrecognized keywords: " + str(argval)) + argval = val # If we were passed a dict for the defval, get the param value from there if isinstance(defval, dict): diff --git a/control/obc.py b/control/optimal.py similarity index 93% rename from control/obc.py rename to control/optimal.py index a9eae643c..86e59cf8d 100644 --- a/control/obc.py +++ b/control/optimal.py @@ -1,9 +1,9 @@ -# obc.py - optimization based control module +# optimal.py - optimization based control module # # RMM, 11 Feb 2021 # -"""The :mod:`~control.obc` module provides support for optimization-based +"""The :mod:`~control.optimal` module provides support for optimization-based controllers for nonlinear systems with state and input constraints. """ @@ -249,17 +249,26 @@ def _cost_function(self, inputs): # Trajectory cost # TODO: vectorize - cost = 0 - for i, t in enumerate(self.time_vector): - if ct.isctime(self.system): + if ct.isctime(self.system): + # Evaluate the costs + costs = [self.integral_cost(states[:, i], inputs[:, i]) for + i in range(self.time_vector.size)] + + # Compute the time intervals + dt = np.diff(self.time_vector) + + # Integrate the cost + cost = 0 + for i in range(self.time_vector.size-1): # Approximate the integral using trapezoidal rule - if i > 0: - cost += 0.5 * ( - self.integral_cost(states[:, i-1], inputs[:, i-1]) + - self.integral_cost(states[:, i], inputs[:, i])) * ( - self.time_vector[i] - self.time_vector[i-1]) - else: - cost += self.integral_cost(states[:,i], inputs[:,i]) + cost += 0.5 * (costs[i] + costs[i+1]) * dt[i] + + else: + # Sum the integral cost over the time (second) indices + # cost += self.integral_cost(states[:,i], inputs[:,i]) + cost = sum(map( + self.integral_cost, np.transpose(states), + np.transpose(inputs))) # Terminal cost if self.terminal_cost is not None: @@ -526,8 +535,8 @@ def _update(t, x, u, params={}): inputs = x.reshape((self.system.ninputs, self.time_vector.size)) self.initial_guess = np.hstack( [inputs[:,1:], inputs[:,-1:]]).reshape(-1) - result = self.compute_trajectory(u) - return result.inputs.reshape(-1) + res = self.compute_trajectory(u, print_summary=False) + return res.inputs.reshape(-1) def _output(t, x, u, params={}): inputs = x.reshape((self.system.ninputs, self.time_vector.size)) @@ -541,15 +550,15 @@ def _output(t, x, u, params={}): # Compute the optimal trajectory from the current state def compute_trajectory( - self, x, squeeze=None, transpose=None, return_x=None, - print_summary=True): + self, x, squeeze=None, transpose=None, return_states=None, + print_summary=True, **kwargs): """Compute the optimal input at state x Parameters ---------- x : array-like or number, optional Initial state for the system. - return_x : bool, optional + return_states : bool, optional If True, return the values of the state at each time (default = False). squeeze : bool, optional @@ -564,17 +573,25 @@ def compute_trajectory( Returns ------- - time : array + res : OptimalControlResult + Bundle object with the results of the optimal control problem. + res.success: bool + Boolean flag indicating whether the optimization was successful. + res.time : array Time values of the input. - inputs : array + res.inputs : array Optimal inputs for the system. If the system is SISO and squeeze is not True, the array is 1D (indexed by time). If the system is not SISO or squeeze is False, the array is 2D (indexed by the output number and time). - states : array - Time evolution of the state vector (if return_x=True). + res.states : array + Time evolution of the state vector (if return_states=True). """ + # Allow 'return_x` as a synonym for 'return_states' + return_states = ct.config._get_param( + 'optimal', 'return_x', kwargs, return_states, pop=True) + # Store the initial state (for use in _constraint_function) self.x = x @@ -585,7 +602,7 @@ def compute_trajectory( # Process and return the results return OptimalControlResult( - self, res, transpose=transpose, return_x=return_x, + self, res, transpose=transpose, return_states=return_states, squeeze=squeeze, print_summary=print_summary) # Compute the current input to apply from the current state (MPC style) @@ -615,8 +632,8 @@ def compute_mpc(self, x, squeeze=None): if the optimization failed. """ - results = self.compute_trajectory(x, squeeze=squeeze) - return inputs[:, 0] if results.success else None + res = self.compute_trajectory(x, squeeze=squeeze) + return inputs[:, 0] if res.success else None # Optimal control result @@ -640,8 +657,10 @@ class OptimalControlResult(sp.optimize.OptimizeResult): """ def __init__( - self, ocp, res, return_x=False, print_summary=False, + self, ocp, res, return_states=False, print_summary=False, transpose=None, squeeze=None): + """Create a OptimalControlResult object""" + # Copy all of the fields we were sent by sp.optimize.minimize() for key, val in res.items(): setattr(self, key, val) @@ -663,7 +682,7 @@ def __init__( if print_summary: ocp._print_statistics() - if return_x and res.success: + if return_states and res.success: # Simulate the system if we need the state back _, _, states = ct.input_output_response( ocp.system, ocp.time_vector, inputs, ocp.x, return_x=True) @@ -673,7 +692,7 @@ def __init__( retval = _process_time_response( ocp.system, ocp.time_vector, inputs, states, - transpose=transpose, return_x=return_x, squeeze=squeeze) + transpose=transpose, return_x=return_states, squeeze=squeeze) self.time = retval[0] self.inputs = retval[1] @@ -681,10 +700,10 @@ def __init__( # Compute the input for a nonlinear, (constrained) optimal control problem -def compute_optimal_input( +def solve_ocp( sys, horizon, X0, cost, constraints=[], terminal_cost=None, terminal_constraints=[], initial_guess=None, squeeze=None, - transpose=None, return_x=None, log=False, **kwargs): + transpose=None, return_states=None, log=False, **kwargs): """Compute the solution to an optimal control problem @@ -738,7 +757,7 @@ def compute_optimal_input( log : bool, optional If `True`, turn on logging messages (using Python logging module). - return_x : bool, optional + return_states : bool, optional If True, return the values of the state at each time (default = False). squeeze : bool, optional @@ -756,15 +775,23 @@ def compute_optimal_input( Returns ------- - time : array - Time values of the input or `None` if the optimimation fails. - inputs : array - Optimal inputs for the system. If the system is SISO and squeeze is not - True, the array is 1D (indexed by time). If the system is not SISO or - squeeze is False, the array is 2D (indexed by the output number and - time). - states : array - Time evolution of the state vector (if return_x=True). + res : OptimalControlResult + Bundle object with the results of the optimal control problem. + + res.success: bool + Boolean flag indicating whether the optimization was successful. + + res.time : array + Time values of the input. + + res.inputs : array + Optimal inputs for the system. If the system is SISO and squeeze is + not True, the array is 1D (indexed by time). If the system is not + SISO or squeeze is False, the array is 2D (indexed by the output + number and time). + + res.states : array + Time evolution of the state vector (if return_states=True). """ # Set up the optimal control problem @@ -775,7 +802,7 @@ def compute_optimal_input( # Solve for the optimal input from the current state return ocp.compute_trajectory( - X0, squeeze=squeeze, transpose=None, return_x=None) + X0, squeeze=squeeze, transpose=None, return_states=None) # Create a model predictive controller for an optimal control problem @@ -803,7 +830,7 @@ def create_mpc_iosystem( constraints : list of tuples, optional List of constraints that should hold at each point in the time vector. - See :func:`~control.obc.compute_optimal_input` for more details. + See :func:`~control.optimal.solve_ocp` for more details. terminal_cost : callable, optional Function that returns the terminal cost given the current state diff --git a/control/tests/config_test.py b/control/tests/config_test.py index b36b6b313..c8e4c6cd5 100644 --- a/control/tests/config_test.py +++ b/control/tests/config_test.py @@ -251,3 +251,14 @@ def test_change_default_dt_static(self): assert ct.tf(1, 1).dt is None assert ct.ss(0, 0, 0, 1).dt is None # TODO: add in test for static gain iosys + + def test_get_param_last(self): + """Test _get_param last keyword""" + kwargs = {'first': 1, 'second': 2} + + with pytest.raises(TypeError, match="unrecognized keyword.*second"): + assert ct.config._get_param( + 'config', 'first', kwargs, pop=True, last=True) == 1 + + assert ct.config._get_param( + 'config', 'second', kwargs, pop=True, last=True) == 2 diff --git a/control/tests/obc_test.py b/control/tests/optimal_test.py similarity index 54% rename from control/tests/obc_test.py rename to control/tests/optimal_test.py index e15b92d41..ac03626d1 100644 --- a/control/tests/obc_test.py +++ b/control/tests/optimal_test.py @@ -1,4 +1,4 @@ -"""obc_test.py - tests for optimization based control +"""optimal_test.py - tests for optimization based control RMM, 17 Apr 2019 check the functionality for optimization based control. RMM, 30 Dec 2020 convert to pytest @@ -9,7 +9,7 @@ import numpy as np import scipy as sp import control as ct -import control.obc as obc +import control.optimal as opt from control.tests.conftest import slycotonly from numpy.lib import NumpyVersion @@ -28,29 +28,38 @@ def test_finite_horizon_simple(): # Quadratic state and input penalty Q = [[1, 0], [0, 1]] R = [[1]] - cost = obc.quadratic_cost(sys, Q, R) + cost = opt.quadratic_cost(sys, Q, R) # Set up the optimal control problem time = np.arange(0, 5, 1) x0 = [4, 0] # Retrieve the full open-loop predictions - results = obc.compute_optimal_input( + res = opt.solve_ocp( sys, time, x0, cost, constraints, squeeze=True) - t, u_openloop = results.time, results.inputs + t, u_openloop = res.time, res.inputs np.testing.assert_almost_equal( u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=4) # Convert controller to an explicit form (not implemented yet) - # mpc_explicit = obc.explicit_mpc(); + # mpc_explicit = opt.explicit_mpc(); # Test explicit controller # u_explicit = mpc_explicit(x0) # np.testing.assert_array_almost_equal(u_openloop, u_explicit) +# +# Compare to LQR solution +# +# The next unit test is intended to confirm that a finite horizon +# optimal control problem with terminal cost set to LQR "cost to go" +# gives the same answer as LQR. Unfortunately, it requires a discrete +# time LQR function which is not yet availbale => for now this just +# tests the interface a bit. +# @slycotonly -def test_class_interface(): +def test_discrete_lqr(): # oscillator model defined in 2D # Source: https://www.mpt3.org/UI/RegulationProblem A = [[0.5403, -0.8415], [0.8415, 0.5403]] @@ -61,28 +70,41 @@ def test_class_interface(): # Linear discrete-time model with sample time 1 sys = ct.ss2io(ct.ss(A, B, C, D, 1)) - # state and input constraints - trajectory_constraints = [ - (sp.optimize.LinearConstraint, np.eye(3), [-10, -10, -1], [10, 10, 1]), - ] - # Include weights on states/inputs Q = np.eye(2) R = 1 - K, S, E = ct.lqr(A, B, Q, R) + K, S, E = ct.lqr(A, B, Q, R) # note: *continuous* time LQR # Compute the integral and terminal cost - integral_cost = obc.quadratic_cost(sys, Q, R) - terminal_cost = obc.quadratic_cost(sys, S, 0) + integral_cost = opt.quadratic_cost(sys, Q, R) + terminal_cost = opt.quadratic_cost(sys, S, 0) # Formulate finite horizon MPC problem time = np.arange(0, 5, 1) - optctrl = obc.OptimalControlProblem( - sys, time, integral_cost, trajectory_constraints, terminal_cost) + x0 = np.array([1, 1]) + optctrl = opt.OptimalControlProblem( + sys, time, integral_cost, terminal_cost=terminal_cost) + res1 = optctrl.compute_trajectory(x0, return_states=True) + + with pytest.xfail("discrete LQR not implemented"): + # Result should match LQR + K, S, E = ct.dlqr(A, B, Q, R) + lqr_sys = ct.ss2io(ct.ss(A - B @ K, B, C, D, 1)) + _, _, lqr_x = ct.input_output_response( + lqr_sys, time, 0, x0, return_x=True) + np.testing.assert_almost_equal(res1.states, lqr_x) + + # Add state and input constraints + trajectory_constraints = [ + (sp.optimize.LinearConstraint, np.eye(3), [-10, -10, -1], [10, 10, 1]), + ] - # Add tests to make sure everything works - results = optctrl.compute_trajectory([1, 1]) + # Re-solve + res2 = opt.solve_ocp( + sys, time, x0, integral_cost, constraints, terminal_cost=terminal_cost) + # Make sure we got a different solution + assert np.any(np.abs(res1.inputs - res2.inputs) > 0.1) def test_mpc_iosystem(): # model of an aircraft discretized with 0.2s sampling time @@ -112,15 +134,15 @@ def test_mpc_iosystem(): yd = C @ xd # provide constraints on the system signals - constraints = [obc.input_range_constraint(sys, [-5, -6], [5, 6])] + constraints = [opt.input_range_constraint(sys, [-5, -6], [5, 6])] # provide penalties on the system signals Q = model.C.transpose() @ np.diag([10, 10, 10, 10]) @ model.C R = np.diag([3, 2]) - cost = obc.quadratic_cost(model, Q, R, x0=xd, u0=ud) + cost = opt.quadratic_cost(model, Q, R, x0=xd, u0=ud) # online MPC controller object is constructed with a horizon 6 - ctrl = obc.create_mpc_iosystem( + ctrl = opt.create_mpc_iosystem( model, np.arange(0, 6) * 0.2, cost, constraints) # Define an I/O system implementing model predictive control @@ -142,13 +164,13 @@ def test_mpc_iosystem(): # parametrization due to the need to define sys instead the test function @pytest.mark.parametrize("constraint_list", [ [(sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1],)], - [(obc.state_range_constraint, [-5, -5], [5, 5]), - (obc.input_range_constraint, [-1], [1])], - [(obc.state_range_constraint, [-5, -5], [5, 5]), - (obc.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], - [(obc.state_poly_constraint, + [(opt.state_range_constraint, [-5, -5], [5, 5]), + (opt.input_range_constraint, [-1], [1])], + [(opt.state_range_constraint, [-5, -5], [5, 5]), + (opt.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], + [(opt.state_poly_constraint, np.array([[1, 0], [0, 1], [-1, 0], [0, -1]]), [5, 5, 5, 5]), - (obc.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], + (opt.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], [(sp.optimize.NonlinearConstraint, lambda x, u: np.array([x[0], x[1], u[0]]), [-5, -5, -1], [5, 5, 1])], ]) @@ -170,80 +192,110 @@ def test_constraint_specification(constraint_list): # Quadratic state and input penalty Q = [[1, 0], [0, 1]] R = [[1]] - cost = obc.quadratic_cost(sys, Q, R) + cost = opt.quadratic_cost(sys, Q, R) # Create a model predictive controller system time = np.arange(0, 5, 1) - optctrl = obc.OptimalControlProblem(sys, time, cost, constraints) + optctrl = opt.OptimalControlProblem(sys, time, cost, constraints) # Compute optimal control and compare against MPT3 solution x0 = [4, 0] - results = optctrl.compute_trajectory(x0, squeeze=True) - t, u_openloop = results.time, results.inputs + res = optctrl.compute_trajectory(x0, squeeze=True) + t, u_openloop = res.time, res.inputs np.testing.assert_almost_equal( u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=3) - -def test_terminal_constraints(): +@pytest.mark.parametrize("sys_args", [ + pytest.param( + ([[1, 0], [0, 1]], np.eye(2), np.eye(2), 0, True), + id = "discrete, no timebase"), + pytest.param( + ([[1, 0], [0, 1]], np.eye(2), np.eye(2), 0, 1), + id = "discrete, dt=1"), + pytest.param( + (np.zeros((2,2)), np.eye(2), np.eye(2), 0), + id = "continuous"), +]) +def test_terminal_constraints(sys_args): """Test out the ability to handle terminal constraints""" - # Discrete time "integrator" with 2 states, 2 inputs - sys = ct.ss2io(ct.ss([[1, 0], [0, 1]], np.eye(2), np.eye(2), 0, True)) - + # Create the system + sys = ct.ss2io(ct.ss(*sys_args)) + # Shortest path to a point is a line Q = np.zeros((2, 2)) R = np.eye(2) - cost = obc.quadratic_cost(sys, Q, R) + cost = opt.quadratic_cost(sys, Q, R) # Set up the terminal constraint to be the origin - final_point = [obc.state_range_constraint(sys, [0, 0], [0, 0])] + final_point = [opt.state_range_constraint(sys, [0, 0], [0, 0])] # Create the optimal control problem time = np.arange(0, 5, 1) - optctrl = obc.OptimalControlProblem( + optctrl = opt.OptimalControlProblem( sys, time, cost, terminal_constraints=final_point) # Find a path to the origin x0 = np.array([4, 3]) - result = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) - t, u1, x1 = result.time, result.inputs, result.states + res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + t, u1, x1 = res.time, res.inputs, res.states # Bug prior to SciPy 1.6 will result in incorrect results if NumpyVersion(sp.__version__) < '1.6.0': pytest.xfail("SciPy 1.6 or higher required") - np.testing.assert_almost_equal(x1[:,-1], 0) + np.testing.assert_almost_equal(x1[:,-1], 0, decimal=4) # Make sure it is a straight line - np.testing.assert_almost_equal( - x1, np.kron(x0.reshape((2, 1)), time[::-1]/4)) + Tf = time[-1] + if ct.isctime(sys): + # Continuous time is not that accurate on the input, so just skip test + pass + else: + # Final point doesn't affect cost => don't need to test + np.testing.assert_almost_equal( + u1[:, 0:-1], + np.kron((-x0/Tf).reshape((2, 1)), np.ones(time.shape))[:, 0:-1]) + np.testing.assert_allclose( + x1, np.kron(x0.reshape((2, 1)), time[::-1]/Tf), atol=0.1, rtol=0.01) # Impose some cost on the state, which should change the path Q = np.eye(2) R = np.eye(2) * 0.1 - cost = obc.quadratic_cost(sys, Q, R) - optctrl = obc.OptimalControlProblem( + cost = opt.quadratic_cost(sys, Q, R) + optctrl = opt.OptimalControlProblem( sys, time, cost, terminal_constraints=final_point) - # Find a path to the origin - results = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) - t, u2, x2 = results.time, results.inputs, results.states - np.testing.assert_almost_equal(x2[:,-1], 0) - - # Make sure that it is *not* a straight line path - assert np.any(np.abs(x2 - x1) > 0.1) - assert np.any(np.abs(u2) > 1) # To make sure next test is useful - - # Add some bounds on the inputs - constraints = [obc.input_range_constraint(sys, [-1, -1], [1, 1])] - optctrl = obc.OptimalControlProblem( - sys, time, cost, constraints, terminal_constraints=final_point) - results = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) - t, u3, x3 = results.time, results.inputs, results.states - np.testing.assert_almost_equal(x2[:,-1], 0) - - # Make sure we got a new path and didn't violate the constraints - assert np.any(np.abs(x3 - x1) > 0.1) - np.testing.assert_array_less(np.abs(u3), 1 + 1e-12) + # Turn off warning messages, since we sometimes don't get convergence + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", message="unable to solve", category=UserWarning) + # Find a path to the origin + res = optctrl.compute_trajectory( + x0, squeeze=True, return_x=True, initial_guess=u1) + t, u2, x2 = res.time, res.inputs, res.states + + # Not all configurations are able to converge (?) + if res.success: + np.testing.assert_almost_equal(x2[:,-1], 0) + + # Make sure that it is *not* a straight line path + assert np.any(np.abs(x2 - x1) > 0.1) + assert np.any(np.abs(u2) > 1) # Make sure next test is useful + + # Add some bounds on the inputs + constraints = [opt.input_range_constraint(sys, [-1, -1], [1, 1])] + optctrl = opt.OptimalControlProblem( + sys, time, cost, constraints, terminal_constraints=final_point) + res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) + t, u3, x3 = res.time, res.inputs, res.states + + # Check the answers only if we converged + if res.success: + np.testing.assert_almost_equal(x3[:,-1], 0, decimal=4) + + # Make sure we got a new path and didn't violate the constraints + assert np.any(np.abs(x3 - x1) > 0.1) + np.testing.assert_array_less(np.abs(u3), 1 + 1e-6) # Make sure that infeasible problems are handled sensibly x0 = np.array([10, 3]) diff --git a/doc/classes.rst b/doc/classes.rst index 6239bd2d1..fdf39a457 100644 --- a/doc/classes.rst +++ b/doc/classes.rst @@ -40,4 +40,4 @@ Additional classes flatsys.LinearFlatSystem flatsys.PolyFamily flatsys.SystemTrajectory - obc.OptimalControlProblem + optimal.OptimalControlProblem diff --git a/doc/index.rst b/doc/index.rst index b5893d860..98b184286 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -30,7 +30,7 @@ implements basic operations for analysis and design of feedback control systems. flatsys iosys descfcn - obc + optimal examples * :ref:`genindex` diff --git a/doc/obc.rst b/doc/optimal.rst similarity index 71% rename from doc/obc.rst rename to doc/optimal.rst index 072094beb..38bfca0db 100644 --- a/doc/obc.rst +++ b/doc/optimal.rst @@ -1,17 +1,17 @@ -.. _obc-module: +.. _optimal-module: -************************** -Optimization-based control -************************** +*************** +Optimal control +*************** -.. automodule:: control.obc +.. automodule:: control.optimal :no-members: :no-inherited-members: -Optimal control problem setup -============================= +Problem setup +============= -Consider now the *optimal control problem*: +Consider the *optimal control problem*: .. math:: @@ -29,7 +29,7 @@ Abstractly, this is a constrained optimization problem where we seek a .. math:: - J(x, u) = \int_0^T L(x,u)\, dt + V \bigl( x(T) \bigr). + J(x, u) = \int_0^T L(x, u)\, dt + V \bigl( x(T) \bigr). More formally, this problem is equivalent to the "standard" problem of minimizing a cost function :math:`J(x, u)` where :math:`(x, u) \in L_2[0,T]` @@ -94,25 +94,25 @@ Control `_. Module usage ============ -The `obc` module provides a means of computing optimal trajectories for -nonlinear systems and implementing optimization-based controllers, including -model predictive control. It follows the basic problem setup described -above, but carries out all computations in *discrete time* (so that -integrals become sums) and over a *finite horizon*. +The optimal control module provides a means of computing optimal +trajectories for nonlinear systems and implementing optimization-based +controllers, including model predictive control. It follows the basic +problem setup described above, but carries out all computations in *discrete +time* (so that integrals become sums) and over a *finite horizon*. To describe an optimal control problem we need an input/output system, a time horizon, a cost function, and (optionally) a set of constraints on the state and/or input, either along the trajectory and at the terminal time. -The `obc` module operates by converting the optimal control problem into a -standard optimization problem that can be solved by +The optimal control module operates by converting the optimal control +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 `~control.obc.compute_optimal_input` function:: +by using the :func:`~control.obc.solve_ocp` function:: - inputs = obc.compute_optimal_input(sys, horizon, X0, cost, constraints) + res = obc.solve_ocp(sys, horizon, X0, cost, constraints) -The `sys` parameter should be a :class:`~control.InputOutputSystem` and the +The `sys` parameter should be an :class:`~control.InputOutputSystem` and the `horizon` parameter should represent a time vector that gives the list of -times at which the `cost` and `constraints` should be evaluated. +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 @@ -147,18 +147,29 @@ all points on the trajectory. The `terminal_constraint` parameter can be used to specify a constraint that only holds at the final point of the trajectory. +The return value for :func:`~control.optimal.solve_ocp` is a bundle object +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 + +In addition, the results from :func:`scipy.optimize.minimize` are also +available. + To simplify the specification of cost functions and constraints, the :mod:`~control.ios` module defines a number of utility functions: .. autosummary:: - ~control.obc.quadratic_cost - ~control.obc.input_poly_constraint - ~control.obc.input_rank_constraint - ~control.obc.output_poly_constraint - ~control.obc.output_rank_constraint - ~control.obc.state_poly_constraint - ~control.obc.state_rank_constraint + ~control.optimal.quadratic_cost + ~control.optimal.input_poly_constraint + ~control.optimal.input_range_constraint + ~control.optimal.output_poly_constraint + ~control.optimal.output_range_constraint + ~control.optimal.state_poly_constraint + ~control.optimal.state_range_constraint Example ======= @@ -169,7 +180,7 @@ following code:: import numpy as np import control as ct - import control.obc as obc + import control.optimal as opt import matplotlib.pyplot as plt def vehicle_update(t, x, u, params): @@ -196,8 +207,8 @@ following code:: inputs=('v', 'phi'), outputs=('x', 'y', 'theta')) We consider an optimal control problem that consists of "changing lanes" by -moving from the point x = 0m, y = -2 m, :math:`\theta` = 0 to the point x = -100m, y = 2 m, :math:`\theta` = 0) over a period of 10 seconds and with a +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.] @@ -207,40 +218,48 @@ with a starting and ending velocity of 10 m/s:: To set up the optimal control problem we design a cost function that penalizes the state and input using quadratic cost functions:: - Q = np.diag([10, 10, 1]) + Q = np.diag([0.1, 10, .1]) # keep lateral error low R = np.eye(2) * 0.1 - cost = obc.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) We also constraint the maximum turning rate to 0.1 radians (about 6 degees) and constrain the velocity to be in the range of 9 m/s to 11 m/s:: - constraints = [ obc.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] - terminal = [ obc.state_range_constraint(vehicle, xf, xf) ] + constraints = [ opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] -Finally, we solve for the optimal inputs and plot the results:: +Finally, we solve for the optimal inputs:: horizon = np.linspace(0, Tf, 20, endpoint=True) - straight = [10, 0] # straight trajectory bend_left = [10, 0.01] # slight left veer - t, u = obc.compute_optimal_input( - # vehicle, horizon, x0, cost, constraints, - # initial_guess=straight, logging=True) - vehicle, horizon, x0, cost, constraints, - terminal_constraints=terminal, initial_guess=straight) + + result = opt.solve_ocp( + vehicle, horizon, x0, cost, constraints, initial_guess=bend_left, + options={'eps': 0.01}) # set step size for gradient calculation + + # Extract the results + u = result.inputs t, y = ct.input_output_response(vehicle, horizon, u, x0) +Plotting the results:: + + # Plot the results plt.subplot(3, 1, 1) plt.plot(y[0], y[1]) + plt.plot(x0[0], x0[1], 'ro', xf[0], xf[1], 'ro') plt.xlabel("x [m]") plt.ylabel("y [m]") plt.subplot(3, 1, 2) plt.plot(t, u[0]) + plt.axis([0, 10, 8.5, 11.5]) + plt.plot([0, 10], [9, 9], 'k--', [0, 10], [11, 11], 'k--') plt.xlabel("t [sec]") plt.ylabel("u1 [m/s]") plt.subplot(3, 1, 3) plt.plot(t, u[1]) + plt.axis([0, 10, -0.15, 0.15]) + plt.plot([0, 10], [-0.1, -0.1], 'k--', [0, 10], [0.1, 0.1], 'k--') plt.xlabel("t [sec]") plt.ylabel("u2 [rad/s]") @@ -248,9 +267,9 @@ Finally, we solve for the optimal inputs and plot the results:: plt.tight_layout() plt.show() -which yields +yields -.. image:: steer-optimal.png +.. image:: steering-optimal.png Module classes and functions @@ -258,12 +277,12 @@ Module classes and functions .. autosummary:: :toctree: generated/ - ~control.obc.OptimalControlProblem - ~control.obc.compute_optimal_input - ~control.obc.create_mpc_iosystem - ~control.obc.input_poly_constraint - ~control.obc.input_range_constraint - ~control.obc.output_poly_constraint - ~control.obc.output_range_constraint - ~control.obc.state_poly_constraint - ~control.obc.state_range_constraint + ~control.optimal.OptimalControlProblem + ~control.optimal.solve_ocp + ~control.optimal.create_mpc_iosystem + ~control.optimal.input_poly_constraint + ~control.optimal.input_range_constraint + ~control.optimal.output_poly_constraint + ~control.optimal.output_range_constraint + ~control.optimal.state_poly_constraint + ~control.optimal.state_range_constraint diff --git a/doc/steering-optimal.png b/doc/steering-optimal.png new file mode 100644 index 0000000000000000000000000000000000000000..6ff50c0f423ca3c58abffeb34f6be372333e1659 GIT binary patch literal 39597 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%0Ai;&5OpPwz%1`&WDgSb!13RxYKY3lsg!f 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 y?1%qb+7 Date: Sat, 20 Feb 2021 19:40:43 -0800 Subject: [PATCH 11/18] update unit tests for speed and coverage --- control/optimal.py | 18 +++++++++++++++++- control/tests/optimal_test.py | 24 +++++++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index 86e59cf8d..2fd2d6c54 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -104,11 +104,27 @@ def __init__( self.system = sys self.time_vector = time_vector self.integral_cost = integral_cost - self.trajectory_constraints = trajectory_constraints self.terminal_cost = terminal_cost self.terminal_constraints = terminal_constraints self.kwargs = 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 + + # 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 + + # # Compute and store constraints # diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index ac03626d1..be037d246 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -230,7 +230,7 @@ def test_terminal_constraints(sys_args): final_point = [opt.state_range_constraint(sys, [0, 0], [0, 0])] # Create the optimal control problem - time = np.arange(0, 5, 1) + time = np.arange(0, 3, 1) optctrl = opt.OptimalControlProblem( sys, time, cost, terminal_constraints=final_point) @@ -302,3 +302,25 @@ def test_terminal_constraints(sys_args): with pytest.warns(UserWarning, match="unable to solve"): res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) assert not res.success + +def test_optimal_logging(capsys): + """Test logging functions (mainly for code coverage)""" + sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) + + # Set up the optimal control problem + cost = opt.quadratic_cost(sys, 1, 1) + state_constraint = opt.state_range_constraint( + sys, [-np.inf, -10], [10, np.inf]) + input_constraint = opt.input_range_constraint(sys, -100, 100) + time = np.arange(0, 3, 1) + x0 = [-1, 1] + + # Solve it, with logging turned on + res = opt.solve_ocp( + sys, time, x0, cost, input_constraint, terminal_cost=cost, + terminal_constraints=state_constraint, log=True) + + # Make sure the output has info available only with logging turned on + captured = capsys.readouterr() + assert captured.out.find("process time") != -1 + From 5f261ccb132801f079892f19cfe4daed8b0e0d0a Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 20 Feb 2021 22:40:31 -0800 Subject: [PATCH 12/18] updated argument checking + unit tests (and coverage) + fixes --- control/optimal.py | 71 ++++++++++++++++------- control/tests/optimal_test.py | 104 +++++++++++++++++++++++++++++++--- 2 files changed, 146 insertions(+), 29 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index 2fd2d6c54..7410c1355 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -193,10 +193,15 @@ def __init__( # See whether we got entire guess or just first time point if len(initial_guess.shape) == 1: # Broadcast inputs to entire time vector - initial_guess = np.broadcast_to( - initial_guess.reshape(-1, 1), - (self.system.ninputs, self.time_vector.size)) - elif len(initial_guess.shape) != 2: + try: + initial_guess = np.broadcast_to( + initial_guess.reshape(-1, 1), + (self.system.ninputs, self.time_vector.size)) + except: + raise ValueError("initial guess is the wrong shape") + + elif initial_guess.shape != \ + (self.system.ninputs, self.time_vector.size): raise ValueError("initial guess is the wrong shape") # Reshape for use by scipy.optimize.minimize() @@ -975,7 +980,13 @@ def state_poly_constraint(sys, A, b): A tuple consisting of the constraint type and parameter values. """ - # TODO: make sure the system and constraints are compatible + # Convert arguments to arrays and make sure dimensions are right + A = np.atleast_2d(A) + b = np.atleast_1d(b) + if len(A.shape) != 2 or A.shape[1] != sys.nstates: + raise ValueError("polytope matrix must match number of states") + elif len(b.shape) != 1 or A.shape[0] != b.shape[0]: + raise ValueError("number of bounds must match number of constraints") # Return a linear constraint object based on the polynomial return (opt.LinearConstraint, @@ -1006,7 +1017,11 @@ def state_range_constraint(sys, lb, ub): A tuple consisting of the constraint type and parameter values. """ - # TODO: make sure the system and constraints are compatible + # Convert bounds to lists and make sure they are the right dimension + lb = np.atleast_1d(lb) + ub = np.atleast_1d(ub) + if lb.shape != (sys.nstates,) or ub.shape != (sys.nstates,): + raise ValueError("state bounds must match number of states") # Return a linear constraint object based on the polynomial return (opt.LinearConstraint, @@ -1037,7 +1052,13 @@ def input_poly_constraint(sys, A, b): A tuple consisting of the constraint type and parameter values. """ - # TODO: make sure the system and constraints are compatible + # Convert arguments to arrays and make sure dimensions are right + A = np.atleast_2d(A) + b = np.atleast_1d(b) + if len(A.shape) != 2 or A.shape[1] != sys.ninputs: + raise ValueError("polytope matrix must match number of inputs") + elif len(b.shape) != 1 or A.shape[0] != b.shape[0]: + raise ValueError("number of bounds must match number of constraints") # Return a linear constraint object based on the polynomial return (opt.LinearConstraint, @@ -1069,13 +1090,17 @@ def input_range_constraint(sys, lb, ub): A tuple consisting of the constraint type and parameter values. """ - # TODO: make sure the system and constraints are compatible + # Convert bounds to lists and make sure they are the right dimension + lb = np.atleast_1d(lb) + ub = np.atleast_1d(ub) + if lb.shape != (sys.ninputs,) or ub.shape != (sys.ninputs,): + raise ValueError("input bounds must match number of inputs") # Return a linear constraint object based on the polynomial return (opt.LinearConstraint, np.hstack( [np.zeros((sys.ninputs, sys.nstates)), np.eye(sys.ninputs)]), - np.array(lb), np.array(ub)) + lb, ub) # @@ -1112,15 +1137,17 @@ def output_poly_constraint(sys, A, b): A tuple consisting of the constraint type and parameter values. """ - # TODO: make sure the system and constraints are compatible + # Convert arguments to arrays and make sure dimensions are right + A = np.atleast_2d(A) + b = np.atleast_1d(b) + if len(A.shape) != 2 or A.shape[1] != sys.noutputs: + raise ValueError("polytope matrix must match number of outputs") + elif len(b.shape) != 1 or A.shape[0] != b.shape[0]: + raise ValueError("number of bounds must match number of constraints") # Function to create the output - def _evaluate_output_poly_constraint(x): - # Separate the constraint into states and inputs - states = x[:sys.nstates] - inputs = x[sys.nstates:] - outputs = sys._out(0, states, inputs) - return A @ outputs + def _evaluate_output_poly_constraint(x, u): + return A @ sys._out(0, x, u) # Return a nonlinear constraint object based on the polynomial return (opt.NonlinearConstraint, @@ -1151,14 +1178,16 @@ def output_range_constraint(sys, lb, ub): A tuple consisting of the constraint type and parameter values. """ - # TODO: make sure the system and constraints are compatible + # Convert bounds to lists and make sure they are the right dimension + lb = np.atleast_1d(lb) + ub = np.atleast_1d(ub) + if lb.shape != (sys.noutputs,) or ub.shape != (sys.noutputs,): + raise ValueError("output bounds must match number of outputs") # Function to create the output - def _evaluate_output_range_constraint(x): + def _evaluate_output_range_constraint(x, u): # Separate the constraint into states and inputs - states = x[:sys.nstates] - inputs = x[sys.nstates:] - outputs = sys._out(0, states, inputs) + return sys._out(0, x, u) # Return a nonlinear constraint object based on the polynomial return (opt.NonlinearConstraint, _evaluate_output_range_constraint, lb, ub) diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index be037d246..6a2e4a7dc 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -77,7 +77,7 @@ def test_discrete_lqr(): # Compute the integral and terminal cost integral_cost = opt.quadratic_cost(sys, Q, R) - terminal_cost = opt.quadratic_cost(sys, S, 0) + terminal_cost = opt.quadratic_cost(sys, S, None) # Formulate finite horizon MPC problem time = np.arange(0, 5, 1) @@ -171,6 +171,11 @@ def test_mpc_iosystem(): [(opt.state_poly_constraint, np.array([[1, 0], [0, 1], [-1, 0], [0, -1]]), [5, 5, 5, 5]), (opt.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], + [(opt.output_range_constraint, [-5, -5], [5, 5]), + (opt.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], + [(opt.output_poly_constraint, + np.array([[1, 0], [0, 1], [-1, 0], [0, -1]]), [5, 5, 5, 5]), + (opt.input_poly_constraint, np.array([[1], [-1]]), [1, 1])], [(sp.optimize.NonlinearConstraint, lambda x, u: np.array([x[0], x[1], u[0]]), [-5, -5, -1], [5, 5, 1])], ]) @@ -258,6 +263,10 @@ def test_terminal_constraints(sys_args): np.testing.assert_allclose( x1, np.kron(x0.reshape((2, 1)), time[::-1]/Tf), atol=0.1, rtol=0.01) + # Re-run using initial guess = optional and make sure nothing chnages + res = optctrl.compute_trajectory(x0, initial_guess=u1) + np.testing.assert_almost_equal(res.inputs, u1) + # Impose some cost on the state, which should change the path Q = np.eye(2) R = np.eye(2) * 0.1 @@ -305,22 +314,101 @@ def test_terminal_constraints(sys_args): def test_optimal_logging(capsys): """Test logging functions (mainly for code coverage)""" - sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) + sys = ct.ss2io(ct.ss(np.eye(2), np.eye(2), np.eye(2), 0, 1)) # Set up the optimal control problem cost = opt.quadratic_cost(sys, 1, 1) state_constraint = opt.state_range_constraint( - sys, [-np.inf, -10], [10, np.inf]) - input_constraint = opt.input_range_constraint(sys, -100, 100) + sys, [-np.inf, 1], [10, 1]) + input_constraint = opt.input_range_constraint(sys, [-100, -100], [100, 100]) time = np.arange(0, 3, 1) x0 = [-1, 1] - # Solve it, with logging turned on - res = opt.solve_ocp( - sys, time, x0, cost, input_constraint, terminal_cost=cost, - terminal_constraints=state_constraint, log=True) + # Solve it, with logging turned on (with warning due to mixed constraints) + with pytest.warns(sp.optimize.optimize.OptimizeWarning, + match="Equality and inequality .* same element"): + res = opt.solve_ocp( + sys, time, x0, cost, input_constraint, terminal_cost=cost, + terminal_constraints=state_constraint, log=True) # Make sure the output has info available only with logging turned on captured = capsys.readouterr() assert captured.out.find("process time") != -1 + +@pytest.mark.parametrize("fun, args, exception, match", [ + [opt.quadratic_cost, (np.zeros((2, 3)), np.eye(2)), ValueError, + "Q matrix is the wrong shape"], + [opt.quadratic_cost, (np.eye(2), 1), ValueError, + "R matrix is the wrong shape"], +]) +def test_constraint_constructor_errors(fun, args, exception, match): + """Test various error conditions for constraint constructors""" + sys = ct.ss2io(ct.rss(2, 2, 2)) + with pytest.raises(exception, match=match): + fun(sys, *args) + + +@pytest.mark.parametrize("fun, args, exception, match", [ + [opt.input_poly_constraint, (np.zeros((2, 3)), [0, 0]), ValueError, + "polytope matrix must match number of inputs"], + [opt.output_poly_constraint, (np.zeros((2, 3)), [0, 0]), ValueError, + "polytope matrix must match number of outputs"], + [opt.state_poly_constraint, (np.zeros((2, 3)), [0, 0]), ValueError, + "polytope matrix must match number of states"], + [opt.input_poly_constraint, (np.zeros((2, 2)), [0, 0, 0]), ValueError, + "number of bounds must match number of constraints"], + [opt.output_poly_constraint, (np.zeros((2, 2)), [0, 0, 0]), ValueError, + "number of bounds must match number of constraints"], + [opt.state_poly_constraint, (np.zeros((2, 2)), [0, 0, 0]), ValueError, + "number of bounds must match number of constraints"], + [opt.input_poly_constraint, (np.zeros((2, 2)), [[0, 0, 0]]), ValueError, + "number of bounds must match number of constraints"], + [opt.output_poly_constraint, (np.zeros((2, 2)), [[0, 0, 0]]), ValueError, + "number of bounds must match number of constraints"], + [opt.state_poly_constraint, (np.zeros((2, 2)), 0), ValueError, + "number of bounds must match number of constraints"], + [opt.input_range_constraint, ([1, 2, 3], [0, 0]), ValueError, + "input bounds must match"], + [opt.output_range_constraint, ([2, 3], [0, 0, 0]), ValueError, + "output bounds must match"], + [opt.state_range_constraint, ([1, 2, 3], [0, 0, 0]), ValueError, + "state bounds must match"], +]) +def test_constraint_constructor_errors(fun, args, exception, match): + """Test various error conditions for constraint constructors""" + sys = ct.ss2io(ct.rss(2, 2, 2)) + with pytest.raises(exception, match=match): + fun(sys, *args) + + +def test_ocp_argument_errors(): + sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) + + # State and input constraints + constraints = [ + (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + ] + + # Quadratic state and input penalty + Q = [[1, 0], [0, 1]] + R = [[1]] + cost = opt.quadratic_cost(sys, Q, R) + + # Set up the optimal control problem + time = np.arange(0, 5, 1) + x0 = [4, 0] + + # Trajectory constraints not in the right form + with pytest.raises(TypeError, match="constraints must be a list"): + res = opt.solve_ocp(sys, time, x0, cost, np.eye(2)) + + # Terminal constraints not in the right form + with pytest.raises(TypeError, match="constraints must be a list"): + res = opt.solve_ocp( + sys, time, x0, cost, constraints, terminal_constraints=np.eye(2)) + + # Initial guess in the wrong shape + with pytest.raises(ValueError, match="initial guess is the wrong shape"): + res = opt.solve_ocp( + sys, time, x0, cost, constraints, initial_guess=np.zeros((4,1,1))) From 980fa5f5ab0daf9e59aca375f7b448e165b05eab Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 20 Feb 2021 23:19:37 -0800 Subject: [PATCH 13/18] PEP8 cleanup --- control/optimal.py | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/control/optimal.py b/control/optimal.py index 7410c1355..a81aa728e 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -20,6 +20,7 @@ __all__ = ['find_optimal_input'] + class OptimalControlProblem(): """Description of a finite horizon, optimal control problem @@ -124,7 +125,6 @@ def __init__( else: self.terminal_constraints = terminal_constraints - # # Compute and store constraints # @@ -197,7 +197,7 @@ def __init__( initial_guess = np.broadcast_to( initial_guess.reshape(-1, 1), (self.system.ninputs, self.time_vector.size)) - except: + except ValueError: raise ValueError("initial guess is the wrong shape") elif initial_guess.shape != \ @@ -222,7 +222,6 @@ def __init__( if log: logging.info("New optimal control problem initailized") - # # Cost function # @@ -253,7 +252,7 @@ def _cost_function(self, inputs): else: if self.log: logging.debug("calling input_output_response from state\n" - + str(x)) + + str(x)) logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) # Simulate the system to get the state @@ -266,7 +265,7 @@ def _cost_function(self, inputs): if self.log: logging.debug("input_output_response returned states\n" - + str(states)) + + str(states)) # Trajectory cost # TODO: vectorize @@ -293,7 +292,7 @@ def _cost_function(self, inputs): # Terminal cost if self.terminal_cost is not None: - cost += self.terminal_cost(states[:,-1], inputs[:,-1]) + cost += self.terminal_cost(states[:, -1], inputs[:, -1]) # Update statistics self.cost_evaluations += 1 @@ -307,7 +306,6 @@ def _cost_function(self, inputs): # Return the total cost for this input sequence return cost - # # Constraints # @@ -368,7 +366,7 @@ def _constraint_function(self, inputs): else: if self.log: logging.debug("calling input_output_response from state\n" - + str(x)) + + str(x)) logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) # Simulate the system to get the state @@ -390,9 +388,9 @@ def _constraint_function(self, inputs): elif type == opt.LinearConstraint: # `fun` is the A matrix associated with the polytope... value.append( - np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) + np.dot(fun, np.hstack([states[:, i], inputs[:, i]]))) elif type == opt.NonlinearConstraint: - value.append(fun(states[:,i], inputs[:,i])) + value.append(fun(states[:, i], inputs[:, i])) else: raise TypeError("unknown constraint type %s" % constraint[0]) @@ -405,9 +403,9 @@ def _constraint_function(self, inputs): continue elif type == opt.LinearConstraint: value.append( - np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) + np.dot(fun, np.hstack([states[:, i], inputs[:, i]]))) elif type == opt.NonlinearConstraint: - value.append(fun(states[:,i], inputs[:,i])) + value.append(fun(states[:, i], inputs[:, i])) else: raise TypeError("unknown constraint type %s" % constraint[0]) @@ -448,7 +446,7 @@ def _eqconst_function(self, inputs): else: if self.log: logging.debug("calling input_output_response from state\n" - + str(x)) + + str(x)) logging.debug("initial input[0:3] =\n" + str(inputs[:, 0:3])) # Simulate the system to get the state @@ -461,7 +459,7 @@ def _eqconst_function(self, inputs): if self.log: logging.debug("input_output_response returned states\n" - + str(states)) + + str(states)) # Evaluate the constraint function along the trajectory value = [] @@ -474,9 +472,9 @@ def _eqconst_function(self, inputs): elif type == opt.LinearConstraint: # `fun` is the A matrix associated with the polytope... value.append( - np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) + np.dot(fun, np.hstack([states[:, i], inputs[:, i]]))) elif type == opt.NonlinearConstraint: - value.append(fun(states[:,i], inputs[:,i])) + value.append(fun(states[:, i], inputs[:, i])) else: raise TypeError("unknown constraint type %s" % constraint[0]) @@ -489,9 +487,9 @@ def _eqconst_function(self, inputs): continue elif type == opt.LinearConstraint: value.append( - np.dot(fun, np.hstack([states[:,i], inputs[:,i]]))) + np.dot(fun, np.hstack([states[:, i], inputs[:, i]]))) elif type == opt.NonlinearConstraint: - value.append(fun(states[:,i], inputs[:,i])) + value.append(fun(states[:, i], inputs[:, i])) else: raise TypeError("unknown constraint type %s" % constraint[0]) @@ -523,7 +521,7 @@ def _eqconst_function(self, inputs): # def _reset_statistics(self, log=False): """Reset counters for keeping track of statistics""" - self.log=log + self.log = log self.cost_evaluations, self.cost_process_time = 0, 0 self.constraint_evaluations, self.constraint_process_time = 0, 0 self.eqconst_evaluations, self.eqconst_process_time = 0, 0 @@ -555,13 +553,13 @@ def _create_mpc_iosystem(self, dt=True): def _update(t, x, u, params={}): inputs = x.reshape((self.system.ninputs, self.time_vector.size)) self.initial_guess = np.hstack( - [inputs[:,1:], inputs[:,-1:]]).reshape(-1) + [inputs[:, 1:], inputs[:, -1:]]).reshape(-1) res = self.compute_trajectory(u, print_summary=False) return res.inputs.reshape(-1) def _output(t, x, u, params={}): inputs = x.reshape((self.system.ninputs, self.time_vector.size)) - return inputs[:,0] + return inputs[:, 0] return ct.NonlinearIOSystem( _update, _output, dt=dt, From 7741fe9fbbd42dc99f4f6a1084d80c586dac925f Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Fri, 26 Feb 2021 22:42:38 -0800 Subject: [PATCH 14/18] add basis functions, solver options, examples/tests --- control/flatsys/__init__.py | 1 + control/flatsys/basis.py | 7 + control/flatsys/bezier.py | 69 ++++++++ control/iosys.py | 38 ++++- control/optimal.py | 303 +++++++++++++++++++++++++--------- control/tests/optimal_test.py | 74 ++++++++- examples/steering-optimal.py | 61 +++++-- 7 files changed, 456 insertions(+), 97 deletions(-) create mode 100644 control/flatsys/bezier.py diff --git a/control/flatsys/__init__.py b/control/flatsys/__init__.py index 9ff1e2337..0926fa81a 100644 --- a/control/flatsys/__init__.py +++ b/control/flatsys/__init__.py @@ -53,6 +53,7 @@ # Basis function families from .basis import BasisFamily from .poly import PolyFamily +from .bezier import BezierFamily # Classes from .systraj import SystemTrajectory diff --git a/control/flatsys/basis.py b/control/flatsys/basis.py index 83ea89cbd..7592b79a2 100644 --- a/control/flatsys/basis.py +++ b/control/flatsys/basis.py @@ -51,3 +51,10 @@ class BasisFamily: def __init__(self, N): """Create a basis family of order N.""" self.N = N # save number of basis functions + + def __call__(self, i, t): + """Evaluate the ith basis function at a point in time""" + return self.eval_deriv(i, 0, t) + + def eval_deriv(self, i, j, t): + raise NotImplementedError("Internal error; improper basis functions") diff --git a/control/flatsys/bezier.py b/control/flatsys/bezier.py new file mode 100644 index 000000000..8cb303312 --- /dev/null +++ b/control/flatsys/bezier.py @@ -0,0 +1,69 @@ +# bezier.m - 1D Bezier curve basis functions +# RMM, 24 Feb 2021 +# +# This class implements a set of basis functions based on Bezier curves: +# +# \phi_i(t) = \sum_{i=0}^n {n \choose i} (T - t)^{n-i} t^i +# + +# Copyright (c) 2012 by California Institute of Technology +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# 3. Neither the name of the California Institute of Technology nor +# the names of its contributors may be used to endorse or promote +# products derived from this software without specific prior +# written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL CALTECH +# OR THE CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF +# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT +# OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +# SUCH DAMAGE. + +import numpy as np +from scipy.special import binom +from .basis import BasisFamily + +class BezierFamily(BasisFamily): + r"""Polynomial basis functions. + + This class represents the family of polynomials of the form + + .. math:: + \phi_i(t) = \sum_{i=0}^n {n \choose i} (T - t)^{n-i} t^i + + """ + def __init__(self, N, T=1): + """Create a polynomial basis of order N.""" + self.N = N # save number of basis functions + self.T = T # save end of time interval + + # Compute the kth derivative of the ith basis function at time t + def eval_deriv(self, i, k, t): + """Evaluate the kth derivative of the ith basis function at time t.""" + if k > 0: + raise NotImplementedError("Bezier derivatives not yet available") + elif i > self.N: + raise ValueError("Basis function index too high") + + # Return the Bezier basis function (note N = # basis functions) + return binom(self.N - 1, i) * \ + (t/self.T)**i * (1 - t/self.T)**(self.N - i - 1) diff --git a/control/iosys.py b/control/iosys.py index 16ef633b7..e75108e33 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1412,9 +1412,10 @@ def __init__(self, io_sys, ss_sys=None): raise TypeError("Second argument must be a state space system.") -def input_output_response(sys, T, U=0., X0=0, params={}, method='RK45', - transpose=False, return_x=False, squeeze=None): - +def input_output_response( + sys, T, U=0., X0=0, params={}, + transpose=False, return_x=False, squeeze=None, + solve_ivp_kwargs={}, **kwargs): """Compute the output response of a system to a given input. Simulate a dynamical system with a given input and return its output @@ -1457,7 +1458,33 @@ def input_output_response(sys, T, U=0., X0=0, params={}, method='RK45', ValueError If time step does not match sampling time (for discrete time systems) + Additional parameters + --------------------- + solve_ivp_method : str, optional + Set the method used by :func:`scipy.integrate.solve_ivp`. Defaults + to 'RK45'. + solve_ivp_kwargs : str, optional + Pass additional keywords to :func:`scipy.integrate.solve_ivp`. + """ + # + # Process keyword arguments + # + + # Allow method as an alternative to solve_ivp_method + if kwargs.get('method', None): + solve_ivp_kwargs['method'] = kwargs.pop('method') + + # Figure out the method to be used + if kwargs.get('solve_ivp_method', None): + if kwargs.get('method', None): + raise ValueError("ivp_method specified more than once") + solve_ivp_kwargs['method'] = kwargs['solve_ivp_method'] + + # Set the default method to 'RK45' + if solve_ivp_kwargs.get('method', None) is None: + solve_ivp_kwargs['method'] = 'RK45' + # Sanity checking on the input if not isinstance(sys, InputOutputSystem): raise TypeError("System of type ", type(sys), " not valid") @@ -1504,8 +1531,9 @@ def ivp_rhs(t, x): return sys._rhs(t, x, u(t)) if not hasattr(sp.integrate, 'solve_ivp'): raise NameError("scipy.integrate.solve_ivp not found; " "use SciPy 1.0 or greater") - soln = sp.integrate.solve_ivp(ivp_rhs, (T0, Tf), X0, t_eval=T, - method=method, vectorized=False) + soln = sp.integrate.solve_ivp( + ivp_rhs, (T0, Tf), X0, t_eval=T, + vectorized=False, **solve_ivp_kwargs) # Compute the output associated with the state (and use sys.out to # figure out the number of outputs just in case it wasn't specified) diff --git a/control/optimal.py b/control/optimal.py index a81aa728e..9ec25b4fc 100644 --- a/control/optimal.py +++ b/control/optimal.py @@ -52,11 +52,16 @@ class OptimalControlProblem(): constraint upper and lower bounds. The constraint function is processed in the class initializer, so that it only needs to be computed once. + If `basis` is specified, then the optimization is done over coefficients + of the basis elements. Otherwise, the optimization is performed over the + values of the input at the specified times (using linear interpolation for + continuous systems). + """ def __init__( self, sys, time_vector, integral_cost, trajectory_constraints=[], terminal_cost=None, terminal_constraints=[], initial_guess=None, - log=False, **kwargs): + basis=None, log=False, **kwargs): """Set up an optimal control problem To describe an optimal control problem we need an input/output system, @@ -100,6 +105,19 @@ def __init__( Optimal control problem object, to be used in computing optimal controllers. + Additional parameters + --------------------- + solve_ivp_method : str, optional + Set the method used by :func:`scipy.integrate.solve_ivp`. + solve_ivp_kwargs : str, optional + Pass additional keywords to :func:`scipy.integrate.solve_ivp`. + minimize_method : str, optional + Set the method used by :func:`scipy.optimize.minimize`. + minimize_options : str, optional + Set the options keyword used by :func:`scipy.optimize.minimize`. + minimize_kwargs : str, optional + Pass additional keywords to :func:`scipy.optimize.minimize`. + """ # Save the basic information for use later self.system = sys @@ -107,7 +125,17 @@ def __init__( self.integral_cost = integral_cost self.terminal_cost = terminal_cost self.terminal_constraints = terminal_constraints - self.kwargs = kwargs + self.basis = basis + + # Process keyword arguments + self.solve_ivp_kwargs = {} + self.solve_ivp_kwargs['method'] = kwargs.pop('solve_ivp_method', None) + self.solve_ivp_kwargs.update(kwargs.pop('solve_ivp_kwargs', {})) + + self.minimize_kwargs = {} + self.minimize_kwargs['method'] = kwargs.pop('minimize_method', None) + self.minimize_kwargs['options'] = kwargs.pop('minimize_options', {}) + self.minimize_kwargs.update(kwargs.pop('minimize_kwargs', {})) # Process trajectory constraints if isinstance(trajectory_constraints, tuple): @@ -179,41 +207,12 @@ def __init__( self._eqconst_function, self.eqconst_value, self.eqconst_value)) - # - # Initial guess - # - # 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. - # - if 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 len(initial_guess.shape) == 1: - # Broadcast inputs to entire time vector - try: - initial_guess = np.broadcast_to( - initial_guess.reshape(-1, 1), - (self.system.ninputs, self.time_vector.size)) - except ValueError: - raise ValueError("initial guess is the wrong shape") - - elif initial_guess.shape != \ - (self.system.ninputs, self.time_vector.size): - raise ValueError("initial guess is the wrong shape") - - # Reshape for use by scipy.optimize.minimize() - self.initial_guess = initial_guess.reshape(-1) - - else: - self.initial_guess = np.zeros( - self.system.ninputs * self.time_vector.size) + # Process the initial guess + self.initial_guess = self._process_initial_guess(initial_guess) # Store states, input, used later to minimize re-computation self.last_x = np.full(self.system.nstates, np.nan) - self.last_inputs = np.full(self.initial_guess.shape, np.nan) + self.last_coeffs = np.full(self.initial_guess.shape, np.nan) # Reset run-time statistics self._reset_statistics(log) @@ -232,22 +231,29 @@ def __init__( # # cost = sum_k integral_cost(x[k], u[k]) + terminal_cost(x[N], u[N]) # - # The initial state is for generating the simulation is store in the class - # parameter `x` prior to calling the optimization algorithm. + # The initial state used for generating the simulation is stored in the + # class parameter `x` prior to calling the optimization algorithm. # - def _cost_function(self, inputs): + def _cost_function(self, coeffs): if self.log: start_time = time.process_time() logging.info("_cost_function called at: %g", start_time) # Retrieve the initial state and reshape the input vector x = self.x - inputs = inputs.reshape( - (self.system.ninputs, self.time_vector.size)) + coeffs = coeffs.reshape((self.system.ninputs, -1)) + + # Compute time points (if basis present) + if self.basis: + if self.log: + logging.debug("coefficients = " + str(coeffs)) + inputs = self._coeffs_to_inputs(coeffs) + else: + inputs = coeffs # See if we already have a simulation for this condition - if np.array_equal(x, self.last_x) and \ - np.array_equal(inputs, self.last_inputs): + if np.array_equal(coeffs, self.last_coeffs) and \ + np.array_equal(x, self.last_x): states = self.last_states else: if self.log: @@ -257,10 +263,11 @@ def _cost_function(self, inputs): # Simulate the system to get the state _, _, states = ct.input_output_response( - self.system, self.time_vector, inputs, x, return_x=True) + self.system, self.time_vector, inputs, x, return_x=True, + solve_ivp_kwargs=self.solve_ivp_kwargs) self.system_simulations += 1 self.last_x = x - self.last_inputs = inputs + self.last_coeffs = coeffs self.last_states = states if self.log: @@ -349,19 +356,24 @@ def _cost_function(self, inputs): # pass arguments to the constraint function, we have to store the initial # state prior to optimization and retrieve it here. # - def _constraint_function(self, inputs): + def _constraint_function(self, coeffs): if self.log: start_time = time.process_time() logging.info("_constraint_function called at: %g", start_time) # Retrieve the initial state and reshape the input vector x = self.x - inputs = inputs.reshape( - (self.system.ninputs, self.time_vector.size)) + coeffs = coeffs.reshape((self.system.ninputs, -1)) + + # Compute time points (if basis present) + if self.basis: + inputs = self._coeffs_to_inputs(coeffs) + else: + inputs = coeffs # See if we already have a simulation for this condition - if np.array_equal(x, self.last_x) and \ - np.array_equal(inputs, self.last_inputs): + if np.array_equal(coeffs, self.last_coeffs) \ + and np.array_equal(x, self.last_x): states = self.last_states else: if self.log: @@ -371,10 +383,11 @@ def _constraint_function(self, inputs): # Simulate the system to get the state _, _, states = ct.input_output_response( - self.system, self.time_vector, inputs, x, return_x=True) + self.system, self.time_vector, inputs, x, return_x=True, + solve_ivp_kwargs=self.solve_ivp_kwargs) self.system_simulations += 1 self.last_x = x - self.last_inputs = inputs + self.last_coeffs = coeffs self.last_states = states # Evaluate the constraint function along the trajectory @@ -429,19 +442,24 @@ def _constraint_function(self, inputs): # Return the value of the constraint function return np.hstack(value) - def _eqconst_function(self, inputs): + def _eqconst_function(self, coeffs): if self.log: start_time = time.process_time() logging.info("_eqconst_function called at: %g", start_time) # Retrieve the initial state and reshape the input vector x = self.x - inputs = inputs.reshape( - (self.system.ninputs, self.time_vector.size)) + coeffs = coeffs.reshape((self.system.ninputs, -1)) + + # Compute time points (if basis present) + if self.basis: + inputs = self._coeffs_to_inputs(coeffs) + else: + inputs = coeffs # See if we already have a simulation for this condition - if np.array_equal(x, self.last_x) and \ - np.array_equal(inputs, self.last_inputs): + if np.array_equal(coeffs, self.last_coeffs) and \ + np.array_equal(x, self.last_x): states = self.last_states else: if self.log: @@ -451,10 +469,11 @@ def _eqconst_function(self, inputs): # Simulate the system to get the state _, _, states = ct.input_output_response( - self.system, self.time_vector, inputs, x, return_x=True) + self.system, self.time_vector, inputs, x, return_x=True, + solve_ivp_kwargs=self.solve_ivp_kwargs) self.system_simulations += 1 self.last_x = x - self.last_inputs = inputs + self.last_coeffs = coeffs self.last_states = states if self.log: @@ -467,7 +486,7 @@ def _eqconst_function(self, inputs): for constraint in self.trajectory_constraints: type, fun, lb, ub = constraint if np.any(lb != ub): - # Skip iniquality constraints + # Skip inequality constraints continue elif type == opt.LinearConstraint: # `fun` is the A matrix associated with the polytope... @@ -505,13 +524,99 @@ def _eqconst_function(self, inputs): # Debugging information if self.log: logging.debug( - "constraint values\n" + str(value) + "\n" + - "lb, ub =\n" + str(self.constraint_lb) + "\n" + - str(self.constraint_ub)) + "eqconst values\n" + str(value) + "\n" + + "desired =\n" + str(self.eqconst_value)) # Return the value of the constraint function return np.hstack(value) + # + # Initial guess + # + # 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 + # vector. If a basis is specified, this is converted to coefficient + # values (which are generally of smaller dimension). + # + def _process_initial_guess(self, initial_guess): + if 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 len(initial_guess.shape) == 1: + # Broadcast inputs to entire time vector + try: + initial_guess = np.broadcast_to( + initial_guess.reshape(-1, 1), + (self.system.ninputs, self.time_vector.size)) + except ValueError: + raise ValueError("initial guess is the wrong shape") + + elif initial_guess.shape != \ + (self.system.ninputs, self.time_vector.size): + raise ValueError("initial guess is the wrong shape") + + # 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) + + # Reshape for use by scipy.optimize.minimize() + return initial_guess.reshape(-1) + + # Default is zero + return np.zeros( + self.system.ninputs * + (self.time_vector.size if self.basis is None else self.basis.N)) + + # + # Utility function to convert input vector to coefficient vector + # + # Initially 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 probelm to + # 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 + if self.basis is None: + return inputs + + # Solve least squares problems (M x = b) for coeffs on each input + coeffs = np.zeros((self.system.ninputs, self.basis.N)) + for i in range(self.system.ninputs): + # Set up the matrices to get inputs + M = np.zeros((self.time_vector.size, self.basis.N)) + b = np.zeros(self.time_vector.size) + + # Evaluate at each time point and for each basis function + # TODO: vectorize + for j, t in enumerate(self.time_vector): + for k in range(self.basis.N): + M[j, k] = self.basis(k, t) + b[j] = inputs[i, j] + + # Solve a least squares problem for the coefficients + alpha, residuals, rank, s = np.linalg.lstsq(M, b, rcond=None) + coeffs[i, :] = alpha + + return coeffs + + # Utility function to convert coefficient vector to input vector + def _coeffs_to_inputs(self, coeffs): + # TODO: vectorize + inputs = np.zeros((self.system.ninputs, self.time_vector.size)) + for i, t in enumerate(self.time_vector): + for k in range(self.basis.N): + phi_k = self.basis(k, t) + for inp in range(self.system.ninputs): + inputs[inp, i] += coeffs[inp, k] * phi_k + return inputs + # # Log and statistics # @@ -551,26 +656,36 @@ def _print_statistics(self, reset=True): def _create_mpc_iosystem(self, dt=True): """Create an I/O system implementing an MPC controller""" def _update(t, x, u, params={}): - inputs = x.reshape((self.system.ninputs, self.time_vector.size)) - self.initial_guess = np.hstack( - [inputs[:, 1:], inputs[:, -1:]]).reshape(-1) + coeffs = x.reshape((self.system.ninputs, -1)) + if self.basis: + # Keep the coeffecients unchanged + # TODO: could compute input vector, shift, and re-project (?) + self.initial_guess = coeffs + else: + # Shift the basis elements by one time step + self.initial_guess = np.hstack( + [coeffs[:, 1:], coeffs[:, -1:]]).reshape(-1) res = self.compute_trajectory(u, print_summary=False) return res.inputs.reshape(-1) def _output(t, x, u, params={}): - inputs = x.reshape((self.system.ninputs, self.time_vector.size)) + if self.basis: + # TODO: compute inputs from basis elements + raise NotImplementedError("basis elements not implemented") + else: + inputs = x.reshape((self.system.ninputs, -1)) return inputs[:, 0] return ct.NonlinearIOSystem( _update, _output, dt=dt, - inputs=self.system.nstates, - outputs=self.system.ninputs, - states=self.system.ninputs * self.time_vector.size) + inputs=self.system.nstates, outputs=self.system.ninputs, + states=self.system.ninputs * + (self.time_vector.size if self.basis is None else self.basis.N)) # Compute the optimal trajectory from the current state def compute_trajectory( self, x, squeeze=None, transpose=None, return_states=None, - print_summary=True, **kwargs): + initial_guess=None, print_summary=True, **kwargs): """Compute the optimal input at state x Parameters @@ -609,15 +724,21 @@ def compute_trajectory( """ # Allow 'return_x` as a synonym for 'return_states' return_states = ct.config._get_param( - 'optimal', 'return_x', kwargs, return_states, pop=True) + 'optimal', 'return_x', kwargs, return_states, pop=True, last=True) # Store the initial state (for use in _constraint_function) self.x = x + # Allow the initial guess to be overriden + if initial_guess is None: + initial_guess = self.initial_guess + else: + initial_guess = self._process_initial_guess(initial_guess) + # Call ScipPy optimizer res = sp.optimize.minimize( - self._cost_function, self.initial_guess, - constraints=self.constraints, **self.kwargs) + self._cost_function, initial_guess, + constraints=self.constraints, **self.minimize_kwargs) # Process and return the results return OptimalControlResult( @@ -688,8 +809,13 @@ def __init__( self.problem = ocp # Reshape and process the input vector - inputs = res.x.reshape( - (ocp.system.ninputs, ocp.time_vector.size)) + coeffs = res.x.reshape((ocp.system.ninputs, -1)) + + # Compute time points (if basis present) + if ocp.basis: + inputs = ocp._coeffs_to_inputs(coeffs) + else: + inputs = coeffs # See if we got an answer if not res.success: @@ -701,10 +827,11 @@ def __init__( if print_summary: ocp._print_statistics() - if return_states and res.success: + if return_states and inputs.shape[1] == ocp.time_vector.shape[0]: # Simulate the system if we need the state back _, _, states = ct.input_output_response( - ocp.system, ocp.time_vector, inputs, ocp.x, return_x=True) + ocp.system, ocp.time_vector, inputs, ocp.x, return_x=True, + solve_ivp_kwargs=ocp.solve_ivp_kwargs) ocp.system_simulations += 1 else: states = None @@ -721,8 +848,8 @@ def __init__( # Compute the input for a nonlinear, (constrained) optimal control problem def solve_ocp( sys, horizon, X0, cost, constraints=[], terminal_cost=None, - terminal_constraints=[], initial_guess=None, squeeze=None, - transpose=None, return_states=None, log=False, **kwargs): + terminal_constraints=[], initial_guess=None, basis=None, squeeze=None, + transpose=None, return_states=False, log=False, **kwargs): """Compute the solution to an optimal control problem @@ -812,16 +939,26 @@ def solve_ocp( res.states : array Time evolution of the state vector (if return_states=True). + Notes + ----- + Additional keyword parameters can be used to fine tune the behavior of + the underlying optimization and integrations functions. See + :func:`OptimalControlProblem` for more information. + """ + # Allow 'return_x` as a synonym for 'return_states' + return_states = ct.config._get_param( + 'optimal', 'return_x', kwargs, return_states, pop=True) + # Set up the optimal control problem ocp = OptimalControlProblem( sys, horizon, cost, trajectory_constraints=constraints, terminal_cost=terminal_cost, terminal_constraints=terminal_constraints, - initial_guess=initial_guess, log=log, **kwargs) + initial_guess=initial_guess, basis=basis, log=log, **kwargs) # Solve for the optimal input from the current state return ocp.compute_trajectory( - X0, squeeze=squeeze, transpose=None, return_states=None) + X0, squeeze=squeeze, transpose=transpose, return_states=return_states) # Create a model predictive controller for an optimal control problem @@ -869,6 +1006,12 @@ def create_mpc_iosystem( returning the current input to be applied that minimizes the cost function while satisfying the constraints. + Notes + ----- + Additional keyword parameters can be used to fine tune the behavior of + the underlying optimization and integrations functions. See + :func:`OptimalControlProblem` for more information. + """ # Set up the optimal control problem diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index 6a2e4a7dc..cedfe06fc 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -8,8 +8,10 @@ import warnings import numpy as np import scipy as sp +import math import control as ct import control.optimal as opt +import control.flatsys as flat from control.tests.conftest import slycotonly from numpy.lib import NumpyVersion @@ -225,7 +227,7 @@ def test_terminal_constraints(sys_args): """Test out the ability to handle terminal constraints""" # Create the system sys = ct.ss2io(ct.ss(*sys_args)) - + # Shortest path to a point is a line Q = np.zeros((2, 2)) R = np.eye(2) @@ -267,6 +269,11 @@ def test_terminal_constraints(sys_args): res = optctrl.compute_trajectory(x0, initial_guess=u1) np.testing.assert_almost_equal(res.inputs, u1) + # Re-run using a basis function and see if we get the same answer + res = opt.solve_ocp(sys, time, x0, cost, terminal_constraints=final_point, + basis=flat.BezierFamily(4, Tf)) + np.testing.assert_almost_equal(res.inputs, u1, decimal=2) + # Impose some cost on the state, which should change the path Q = np.eye(2) R = np.eye(2) * 0.1 @@ -412,3 +419,68 @@ def test_ocp_argument_errors(): with pytest.raises(ValueError, match="initial guess is the wrong shape"): res = opt.solve_ocp( sys, time, x0, cost, constraints, initial_guess=np.zeros((4,1,1))) + + +def test_optimal_basis_simple(): + pass + + +def test_optimal_basis_vehicle(): + # Define a nonlinear system to use (kinematic car) + def vehicle_update(t, x, u, params): + phi = np.clip(u[1], -0.5, 0.5) + return np.array([ + math.cos(x[2]) * u[0], # xdot = cos(theta) v + math.sin(x[2]) * u[0], # ydot = sin(theta) v + (u[0] / 3) * math.tan(phi) # thdot = v/l tan(phi) + ]) + + def vehicle_output(t, x, u, params): + return x # return x, y, theta (full state) + + # Define the vehicle steering dynamics as an input/output system + vehicle = ct.NonlinearIOSystem( + vehicle_update, vehicle_output, states=3, inputs=2, outputs=3) + + # Initial and final conditions + x0 = [0., -2., 0.]; u0 = [10., 0.] + xf = [100., 2., 0.]; uf = [10., 0.] + Tf = 10 + + # Set up costs and constriants + Q = np.diag([.1, 10, .1]) # keep lateral error low + R = np.diag([1, 1]) # minimize applied inputs + cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + constraints = [ opt.input_range_constraint(vehicle, [0, -0.1], [20, 0.1]) ] + terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] + bend_left = [10, 0.05] # slight left veer + near_optimal = [ + [ 1.15073736e+01, 1.16838616e+01, 1.15413395e+01, + 1.11585544e+01, 1.06142537e+01, 9.98718468e+00, + 9.35609454e+00, 8.79973057e+00, 8.39684004e+00, + 8.22617023e+00], + [ -9.99830506e-02, 8.98139594e-03, 5.26385615e-02, + 4.96635954e-02, 1.87316470e-02, -2.14821345e-02, + -5.23025996e-02, -5.50545990e-02, -1.10629834e-02, + 9.83473965e-02] ] + + # Set up horizon + horizon = np.linspace(0, Tf, 10, endpoint=True) + + # Set up the optimal control problem + res = opt.solve_ocp( + vehicle, horizon, x0, cost, + constraints, + terminal_constraints=terminal, + initial_guess=near_optimal, + basis=flat.BezierFamily(4, T=Tf), + minimize_method='trust-constr', minimize_options={'disp': True}, + # minimize_method='SLSQP', minimize_options={'eps': 0.01}, + solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + return_states=True + ) + t, u, x = res.time, res.inputs, res.states + + # Make sure we found a valid solution + assert res.success + np.testing.assert_almost_equal(x[:, -1], xf, decimal=4) diff --git a/examples/steering-optimal.py b/examples/steering-optimal.py index 0ac4cc53e..3bd14d711 100644 --- a/examples/steering-optimal.py +++ b/examples/steering-optimal.py @@ -6,11 +6,13 @@ # optimal control module (control.optimal) in the python-control package. import numpy as np +import math import control as ct import control.optimal as opt import matplotlib.pyplot as plt import logging import time +import os # # Vehicle steering dynamics @@ -37,9 +39,9 @@ def vehicle_update(t, x, u, params): # Return the derivative of the state return np.array([ - np.cos(x[2]) * u[0], # xdot = cos(theta) v - np.sin(x[2]) * u[0], # ydot = sin(theta) v - (u[0] / l) * np.tan(phi) # thdot = v/l tan(phi) + math.cos(x[2]) * u[0], # xdot = cos(theta) v + math.sin(x[2]) * u[0], # ydot = sin(theta) v + (u[0] / l) * math.tan(phi) # thdot = v/l tan(phi) ]) @@ -107,7 +109,7 @@ def plot_results(t, y, u, figure=None, yf=None): # Set up the cost functions Q = np.diag([.1, 10, .1]) # keep lateral error low R = np.diag([.1, 1]) # minimize applied inputs -cost1 = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) +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) @@ -123,8 +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, cost1, initial_guess=bend_left, log=True, - options={'eps': 0.01}) + vehicle, horizon, x0, quad_cost, initial_guess=bend_left, log=True, + # solve_ivp_kwargs={'atol': 1e-2, 'rtol': 1e-2}, + minimize_options={'eps': 0.01}) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) # Extract and plot the results (+ state trajectory) @@ -160,7 +163,8 @@ def plot_results(t, y, u, figure=None, yf=None): result2 = opt.solve_ocp( vehicle, horizon, x0, traj_cost, constraints, terminal_cost=term_cost, initial_guess=bend_left, log=True, - method='SLSQP', options={'eps': 0.01}) + # solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + minimize_method='SLSQP', minimize_options={'eps': 0.01}) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) # Extract and plot the results (+ state trajectory) @@ -171,9 +175,9 @@ def plot_results(t, y, u, figure=None, yf=None): # # Approach 3: terminal constraints # -# As a final example, we can remove the cost function on the state and -# replace it with a terminal *constraint* on the state. If a solution is -# found, it guarantees we get to exactly the final state. +# We can also remove the cost function on the state and replace it +# with a terminal *constraint* on the state. If a solution is found, +# it guarantees we get to exactly the final state. # # To speeds things up a bit, we initalize the problem using the previous # optimal controller (which didn't quite hit the final value). @@ -192,10 +196,45 @@ def plot_results(t, y, u, figure=None, yf=None): result3 = opt.solve_ocp( vehicle, horizon, x0, cost3, constraints, terminal_constraints=terminal, initial_guess=u2, log=False, - options={'eps': 0.01}) + # solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, + solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + minimize_options={'eps': 0.01}) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) # Extract and plot the results (+ state trajectory) 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]) + +# +# Approach 4: terminal constraints w/ basis functions +# +# As a final example, we can use a basis function to reduce the size +# of the problem and get faster answers with more temporal resolution. +# 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") +import control.flatsys as flat + +# Compute the optimal control +start_time = time.process_time() +result4 = opt.solve_ocp( + vehicle, horizon, x0, quad_cost, + constraints, + terminal_constraints=terminal, + initial_guess=u3, + basis=flat.BezierFamily(4, T=Tf), + minimize_method='trust-constr', minimize_options={'disp': True}, + # method='SLSQP', options={'eps': 0.01} + solve_ivp_kwargs={'atol': 1e-2, 'rtol': 1e-2}, +) +print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) + +# Extract and plot the results (+ state trajectory) +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]) + +if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: + plt.show() From df91cac6772f626df2ca3035b9a66d347f814e6d Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 27 Feb 2021 14:13:52 -0800 Subject: [PATCH 15/18] set up benchmarks/profiling via asv --- .gitignore | 5 +- asv.conf.json | 161 +++++++++++++++++++++++++ benchmarks/README | 39 ++++++ benchmarks/__init__.py | 0 benchmarks/optimal_bench.py | 216 ++++++++++++++++++++++++++++++++++ control/tests/optimal_test.py | 82 ++++--------- examples/steering-optimal.py | 37 ++++-- 7 files changed, 472 insertions(+), 68 deletions(-) create mode 100644 asv.conf.json create mode 100644 benchmarks/README create mode 100644 benchmarks/__init__.py create mode 100644 benchmarks/optimal_bench.py diff --git a/.gitignore b/.gitignore index 0262ab46f..b95f1730e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,7 @@ MANIFEST control/_version.py __conda_*.txt record.txt -build.log +*.log *.egg-info/ .eggs/ .coverage @@ -23,3 +23,6 @@ Untitled*.ipynb # Files created by or for emacs (RMM, 29 Dec 2017) *~ TAGS + +# Files created by or for asv (airspeed velocity) +.asv/ diff --git a/asv.conf.json b/asv.conf.json new file mode 100644 index 000000000..590c24db0 --- /dev/null +++ b/asv.conf.json @@ -0,0 +1,161 @@ +{ + // The version of the config file format. Do not change, unless + // you know what you are doing. + "version": 1, + + // The name of the project being benchmarked + "project": "python-control", + + // The project's homepage + "project_url": "http://python-control.org/", + + // The URL or local path of the source code repository for the + // project being benchmarked + "repo": ".", + + // The Python project's subdirectory in your repo. If missing or + // the empty string, the project is assumed to be located at the root + // of the repository. + // "repo_subdir": ".", + + // Customizable commands for building, installing, and + // uninstalling the project. See asv.conf.json documentation. + // + // "install_command": ["in-dir={env_dir} python -mpip install {wheel_file}"], + // "uninstall_command": ["return-code=any python -mpip uninstall -y {project}"], + "build_command": [ + "python make_version.py", + "python setup.py build", + "PIP_NO_BUILD_ISOLATION=false python -mpip wheel --no-deps --no-index -w {build_cache_dir} {build_dir}" + ], + + // List of branches to benchmark. If not provided, defaults to "master" + // (for git) or "default" (for mercurial). + // "branches": ["master"], // for git + // "branches": ["default"], // for mercurial + + // The DVCS being used. If not set, it will be automatically + // determined from "repo" by looking at the protocol in the URL + // (if remote), or by looking for special directories, such as + // ".git" (if local). + // "dvcs": "git", + + // The tool to use to create environments. May be "conda", + // "virtualenv" or other value depending on the plugins in use. + // If missing or the empty string, the tool will be automatically + // determined by looking for tools on the PATH environment + // variable. + "environment_type": "conda", + + // timeout in seconds for installing any dependencies in environment + // defaults to 10 min + //"install_timeout": 600, + + // the base URL to show a commit for the project. + "show_commit_url": "http://github.com/python-control/python-control/commit/", + + // The Pythons you'd like to test against. If not provided, defaults + // to the current version of Python used to run `asv`. + // "pythons": ["2.7", "3.6"], + + // The list of conda channel names to be searched for benchmark + // dependency packages in the specified order + // "conda_channels": ["conda-forge", "defaults"], + + // The matrix of dependencies to test. Each key is the name of a + // package (in PyPI) and the values are version numbers. An empty + // list or empty string indicates to just test against the default + // (latest) version. null indicates that the package is to not be + // installed. If the package to be tested is only available from + // PyPi, and the 'environment_type' is conda, then you can preface + // the package name by 'pip+', and the package will be installed via + // pip (with all the conda available packages installed first, + // followed by the pip installed packages). + // + // "matrix": { + // "numpy": ["1.6", "1.7"], + // "six": ["", null], // test with and without six installed + // "pip+emcee": [""], // emcee is only available for install with pip. + // }, + + // Combinations of libraries/python versions can be excluded/included + // from the set to test. Each entry is a dictionary containing additional + // key-value pairs to include/exclude. + // + // An exclude entry excludes entries where all values match. The + // values are regexps that should match the whole string. + // + // An include entry adds an environment. Only the packages listed + // are installed. The 'python' key is required. The exclude rules + // do not apply to includes. + // + // In addition to package names, the following keys are available: + // + // - python + // Python version, as in the *pythons* variable above. + // - environment_type + // Environment type, as above. + // - sys_platform + // Platform, as in sys.platform. Possible values for the common + // cases: 'linux2', 'win32', 'cygwin', 'darwin'. + // + // "exclude": [ + // {"python": "3.2", "sys_platform": "win32"}, // skip py3.2 on windows + // {"environment_type": "conda", "six": null}, // don't run without six on conda + // ], + // + // "include": [ + // // additional env for python2.7 + // {"python": "2.7", "numpy": "1.8"}, + // // additional env if run on windows+conda + // {"platform": "win32", "environment_type": "conda", "python": "2.7", "libpython": ""}, + // ], + + // The directory (relative to the current directory) that benchmarks are + // stored in. If not provided, defaults to "benchmarks" + // "benchmark_dir": "benchmarks", + + // The directory (relative to the current directory) to cache the Python + // environments in. If not provided, defaults to "env" + "env_dir": ".asv/env", + + // The directory (relative to the current directory) that raw benchmark + // results are stored in. If not provided, defaults to "results". + "results_dir": ".asv/results", + + // The directory (relative to the current directory) that the html tree + // should be written to. If not provided, defaults to "html". + "html_dir": ".asv/html", + + // The number of characters to retain in the commit hashes. + // "hash_length": 8, + + // `asv` will cache results of the recent builds in each + // environment, making them faster to install next time. This is + // the number of builds to keep, per environment. + // "build_cache_size": 2, + + // The commits after which the regression search in `asv publish` + // should start looking for regressions. Dictionary whose keys are + // regexps matching to benchmark names, and values corresponding to + // the commit (exclusive) after which to start looking for + // regressions. The default is to start from the first commit + // with results. If the commit is `null`, regression detection is + // skipped for the matching benchmark. + // + // "regressions_first_commits": { + // "some_benchmark": "352cdf", // Consider regressions only after this commit + // "another_benchmark": null, // Skip regression detection altogether + // }, + + // The thresholds for relative change in results, after which `asv + // publish` starts reporting regressions. Dictionary of the same + // form as in ``regressions_first_commits``, with values + // indicating the thresholds. If multiple entries match, the + // maximum is taken. If no entry matches, the default is 5%. + // + // "regressions_thresholds": { + // "some_benchmark": 0.01, // Threshold of 1% + // "another_benchmark": 0.5, // Threshold of 50% + // }, +} diff --git a/benchmarks/README b/benchmarks/README new file mode 100644 index 000000000..a10bbfc21 --- /dev/null +++ b/benchmarks/README @@ -0,0 +1,39 @@ +This directory contains various scripts that can be used to measure the +performance of the python-control package. The scripts are intended to be +used with the airspeed velocity package (https://pypi.org/project/asv/) and +are mainly intended for use by developers in identfying potential +improvements to their code. + +Running benchmarks +------------------ +To run the benchmarks listed here against the current (uncommitted) code, +you can use the following command from the root directory of the repository: + + PYTHONPATH=`pwd` asv run --python=python + +You can also run benchmarks against specific commits usuing + + asv run + +where is a range of commits to benchmark. To check against the HEAD +of the branch that is currently checked out, use + + asv run HEAD^! + +Code profiling +-------------- +You can also use the benchmarks to profile code and look for bottlenecks. +To profile a given test against the current (uncommitted) code use + + PYTHONPATH=`pwd` asv profile --python=python . + +where is the name of one of the files in the benchmark/ subdirectory +and is the name of a test function in that file. + +If you have the `snakeviz` profiling visualization package installed, the +following command will profile a test against the HEAD of the current branch +and open a graphical representation of the profiled code: + + asv profile --gui snakeviz . HEAD + +RMM, 27 Feb 2021 diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benchmarks/optimal_bench.py b/benchmarks/optimal_bench.py new file mode 100644 index 000000000..329066579 --- /dev/null +++ b/benchmarks/optimal_bench.py @@ -0,0 +1,216 @@ +# optimal_bench.py - benchmarks for optimal control package +# RMM, 27 Feb 2020 +# +# This benchmark tests the timing for the optimal control module +# (control.optimal) and is intended to be used for helping tune the +# performance of the functions used for optimization-base control. + +import numpy as np +import math +import control as ct +import control.flatsys as flat +import control.optimal as opt +import matplotlib.pyplot as plt +import logging +import time +import os + +# +# Vehicle steering dynamics +# +# The vehicle dynamics are given by a simple bicycle model. We take the state +# of the system as (x, y, theta) where (x, y) is the position of the vehicle +# in the plane and theta is the angle of the vehicle with respect to +# horizontal. The vehicle input is given by (v, phi) where v is the forward +# velocity of the vehicle and phi is the angle of the steering wheel. The +# model includes saturation of the vehicle steering angle. +# +# System state: x, y, theta +# System input: v, phi +# System output: x, y +# System parameters: wheelbase, maxsteer +# +def vehicle_update(t, x, u, params): + # Get the parameters for the model + l = params.get('wheelbase', 3.) # vehicle wheelbase + phimax = params.get('maxsteer', 0.5) # max steering angle (rad) + + # Saturate the steering input (use min/max instead of clip for speed) + phi = max(-phimax, min(u[1], phimax)) + + # Return the derivative of the state + return np.array([ + math.cos(x[2]) * u[0], # xdot = cos(theta) v + math.sin(x[2]) * u[0], # ydot = sin(theta) v + (u[0] / l) * math.tan(phi) # thdot = v/l tan(phi) + ]) + + +def vehicle_output(t, x, u, params): + return x # return x, y, theta (full state) + +vehicle = ct.NonlinearIOSystem( + vehicle_update, vehicle_output, states=3, name='vehicle', + inputs=('v', 'phi'), outputs=('x', 'y', 'theta')) + +x0 = [0., -2., 0.]; u0 = [10., 0.] +xf = [100., 2., 0.]; uf = [10., 0.] +Tf = 10 + +# Define the time horizon (and spacing) for the optimization +horizon = np.linspace(0, Tf, 10, endpoint=True) + +# Provide an intial guess (will be extended to entire horizon) +bend_left = [10, 0.01] # slight left veer + +def time_integrated_cost(): + # Set up the cost functions + Q = np.diag([.1, 10, .1]) # keep lateral error low + R = np.diag([.1, 1]) # minimize applied inputs + quad_cost = opt.quadratic_cost( + vehicle, Q, R, x0=xf, u0=uf) + + res = opt.solve_ocp( + vehicle, horizon, x0, quad_cost, + initial_guess=bend_left, print_summary=False, + # solve_ivp_kwargs={'atol': 1e-2, 'rtol': 1e-2}, + minimize_method='trust-constr', + minimize_options={'finite_diff_rel_step': 0.01}, + ) + + # Only count this as a benchmark if we converged + assert res.success + +def time_terminal_cost(): + # Define cost and constraints + traj_cost = opt.quadratic_cost( + vehicle, None, np.diag([0.1, 1]), u0=uf) + term_cost = opt.quadratic_cost( + vehicle, np.diag([1, 10, 10]), None, x0=xf) + constraints = [ + opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + + res = opt.solve_ocp( + vehicle, horizon, x0, traj_cost, constraints, + terminal_cost=term_cost, initial_guess=bend_left, print_summary=False, + # solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + minimize_method='SLSQP', minimize_options={'eps': 0.01} + ) + + # Only count this as a benchmark if we converged + assert res.success + +def time_terminal_constraint(): + # Input cost and terminal constraints + R = np.diag([1, 1]) # minimize applied inputs + cost = opt.quadratic_cost(vehicle, np.zeros((3,3)), R, u0=uf) + constraints = [ + opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] + terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] + + res = opt.solve_ocp( + vehicle, horizon, x0, cost, constraints, + terminal_constraints=terminal, initial_guess=bend_left, log=False, + solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, + # solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + minimize_method='trust-constr', + # minimize_method='SLSQP', minimize_options={'eps': 0.01} + ) + + # Only count this as a benchmark if we converged + assert res.success + +# Reset the timeout value to allow for longer runs +time_terminal_constraint.timeout = 120 + +def time_optimal_basis_vehicle(): + # Set up costs and constriants + Q = np.diag([.1, 10, .1]) # keep lateral error low + R = np.diag([1, 1]) # minimize applied inputs + cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) + constraints = [ opt.input_range_constraint(vehicle, [0, -0.1], [20, 0.1]) ] + terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] + bend_left = [10, 0.05] # slight left veer + near_optimal = [ + [ 1.15073736e+01, 1.16838616e+01, 1.15413395e+01, + 1.11585544e+01, 1.06142537e+01, 9.98718468e+00, + 9.35609454e+00, 8.79973057e+00, 8.39684004e+00, + 8.22617023e+00], + [ -9.99830506e-02, 8.98139594e-03, 5.26385615e-02, + 4.96635954e-02, 1.87316470e-02, -2.14821345e-02, + -5.23025996e-02, -5.50545990e-02, -1.10629834e-02, + 9.83473965e-02] ] + + # Set up horizon + horizon = np.linspace(0, Tf, 10, endpoint=True) + + # Set up the optimal control problem + res = opt.solve_ocp( + vehicle, horizon, x0, cost, + constraints, + terminal_constraints=terminal, + initial_guess=near_optimal, + basis=flat.BezierFamily(4, T=Tf), + minimize_method='trust-constr', + # minimize_method='SLSQP', minimize_options={'eps': 0.01}, + solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + return_states=True, print_summary=False + ) + t, u, x = res.time, res.inputs, res.states + + # Make sure we found a valid solution + assert res.success + np.testing.assert_almost_equal(x[:, -1], xf, decimal=4) + +def time_mpc_iosystem(): + # model of an aircraft discretized with 0.2s sampling time + # Source: https://www.mpt3.org/UI/RegulationProblem + A = [[0.99, 0.01, 0.18, -0.09, 0], + [ 0, 0.94, 0, 0.29, 0], + [ 0, 0.14, 0.81, -0.9, 0], + [ 0, -0.2, 0, 0.95, 0], + [ 0, 0.09, 0, 0, 0.9]] + B = [[ 0.01, -0.02], + [-0.14, 0], + [ 0.05, -0.2], + [ 0.02, 0], + [-0.01, 0]] + C = [[0, 1, 0, 0, -1], + [0, 0, 1, 0, 0], + [0, 0, 0, 1, 0], + [1, 0, 0, 0, 0]] + model = ct.ss2io(ct.ss(A, B, C, 0, 0.2)) + + # For the simulation we need the full state output + sys = ct.ss2io(ct.ss(A, B, np.eye(5), 0, 0.2)) + + # compute the steady state values for a particular value of the input + ud = np.array([0.8, -0.3]) + xd = np.linalg.inv(np.eye(5) - A) @ B @ ud + yd = C @ xd + + # provide constraints on the system signals + constraints = [opt.input_range_constraint(sys, [-5, -6], [5, 6])] + + # provide penalties on the system signals + Q = model.C.transpose() @ np.diag([10, 10, 10, 10]) @ model.C + R = np.diag([3, 2]) + cost = opt.quadratic_cost(model, Q, R, x0=xd, u0=ud) + + # online MPC controller object is constructed with a horizon 6 + ctrl = opt.create_mpc_iosystem( + model, np.arange(0, 6) * 0.2, cost, constraints) + + # Define an I/O system implementing model predictive control + loop = ct.feedback(sys, ctrl, 1) + + # Choose a nearby initial condition to speed up computation + X0 = np.hstack([xd, np.kron(ud, np.ones(6))]) * 0.99 + + Nsim = 12 + tout, xout = ct.input_output_response( + loop, np.arange(0, Nsim) * 0.2, 0, X0) + + # Make sure the system converged to the desired state + np.testing.assert_allclose( + xout[0:sys.nstates, -1], xd, atol=0.1, rtol=0.01) diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index cedfe06fc..fc0ff79d7 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -265,7 +265,7 @@ def test_terminal_constraints(sys_args): np.testing.assert_allclose( x1, np.kron(x0.reshape((2, 1)), time[::-1]/Tf), atol=0.1, rtol=0.01) - # Re-run using initial guess = optional and make sure nothing chnages + # 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) @@ -422,65 +422,27 @@ def test_ocp_argument_errors(): def test_optimal_basis_simple(): - pass - - -def test_optimal_basis_vehicle(): - # Define a nonlinear system to use (kinematic car) - def vehicle_update(t, x, u, params): - phi = np.clip(u[1], -0.5, 0.5) - return np.array([ - math.cos(x[2]) * u[0], # xdot = cos(theta) v - math.sin(x[2]) * u[0], # ydot = sin(theta) v - (u[0] / 3) * math.tan(phi) # thdot = v/l tan(phi) - ]) - - def vehicle_output(t, x, u, params): - return x # return x, y, theta (full state) - - # Define the vehicle steering dynamics as an input/output system - vehicle = ct.NonlinearIOSystem( - vehicle_update, vehicle_output, states=3, inputs=2, outputs=3) - - # Initial and final conditions - x0 = [0., -2., 0.]; u0 = [10., 0.] - xf = [100., 2., 0.]; uf = [10., 0.] - Tf = 10 - - # Set up costs and constriants - Q = np.diag([.1, 10, .1]) # keep lateral error low - R = np.diag([1, 1]) # minimize applied inputs - cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) - constraints = [ opt.input_range_constraint(vehicle, [0, -0.1], [20, 0.1]) ] - terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] - bend_left = [10, 0.05] # slight left veer - near_optimal = [ - [ 1.15073736e+01, 1.16838616e+01, 1.15413395e+01, - 1.11585544e+01, 1.06142537e+01, 9.98718468e+00, - 9.35609454e+00, 8.79973057e+00, 8.39684004e+00, - 8.22617023e+00], - [ -9.99830506e-02, 8.98139594e-03, 5.26385615e-02, - 4.96635954e-02, 1.87316470e-02, -2.14821345e-02, - -5.23025996e-02, -5.50545990e-02, -1.10629834e-02, - 9.83473965e-02] ] - - # Set up horizon - horizon = np.linspace(0, Tf, 10, endpoint=True) + sys = ct.ss2io(ct.ss([[1, 1], [0, 1]], [[1], [0.5]], np.eye(2), 0, 1)) + + # State and input constraints + constraints = [ + (sp.optimize.LinearConstraint, np.eye(3), [-5, -5, -1], [5, 5, 1]), + ] + + # Quadratic state and input penalty + Q = [[1, 0], [0, 1]] + R = [[1]] + cost = opt.quadratic_cost(sys, Q, R) # Set up the optimal control problem - res = opt.solve_ocp( - vehicle, horizon, x0, cost, - constraints, - terminal_constraints=terminal, - initial_guess=near_optimal, - basis=flat.BezierFamily(4, T=Tf), - minimize_method='trust-constr', minimize_options={'disp': True}, - # minimize_method='SLSQP', minimize_options={'eps': 0.01}, - solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, - return_states=True - ) - t, u, x = res.time, res.inputs, res.states - - # Make sure we found a valid solution + time = np.arange(0, 5, 1) + x0 = [4, 0] + + # Basic optimal control problem + res = opt.solve_ocp(sys, time, x0, cost, constraints, return_x=True) assert res.success - np.testing.assert_almost_equal(x[:, -1], xf, decimal=4) + + # Make sure the constraints were satisfied + np.testing.assert_array_less(np.abs(res.states[0]), 5 + 1e-6) + np.testing.assert_array_less(np.abs(res.states[1]), 5 + 1e-6) + np.testing.assert_array_less(np.abs(res.inputs[0]), 1 + 1e-6) diff --git a/examples/steering-optimal.py b/examples/steering-optimal.py index 3bd14d711..df76ea1ad 100644 --- a/examples/steering-optimal.py +++ b/examples/steering-optimal.py @@ -34,8 +34,8 @@ def vehicle_update(t, x, u, params): l = params.get('wheelbase', 3.) # vehicle wheelbase phimax = params.get('maxsteer', 0.5) # max steering angle (rad) - # Saturate the steering input - phi = np.clip(u[1], -phimax, phimax) + # Saturate the steering input (use min/max instead of clip for speed) + phi = max(-phimax, min(u[1], phimax)) # Return the derivative of the state return np.array([ @@ -127,9 +127,17 @@ def plot_results(t, y, u, figure=None, yf=None): result1 = opt.solve_ocp( vehicle, horizon, x0, quad_cost, initial_guess=bend_left, log=True, # solve_ivp_kwargs={'atol': 1e-2, 'rtol': 1e-2}, - minimize_options={'eps': 0.01}) + # solve_ivp_kwargs={'method': 'RK23', 'atol': 1e-2, 'rtol': 1e-2}, + minimize_method='trust-constr', + minimize_options={'finite_diff_rel_step': 0.01}, + # 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 result1.success + # Extract and plot the results (+ state trajectory) t1, u1 = result1.time, result1.inputs t1, y1 = ct.input_output_response(vehicle, horizon, u1, x0) @@ -167,6 +175,10 @@ def plot_results(t, y, u, figure=None, yf=None): 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) t2, u2 = result2.time, result2.inputs t2, y2 = ct.input_output_response(vehicle, horizon, u2, x0) @@ -189,18 +201,24 @@ def plot_results(t, y, u, figure=None, yf=None): terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] # Reset logging to its default values -logging.basicConfig(level=logging.WARN, force=True) +logging.basicConfig( + level=logging.DEBUG, filename="./steering-terminal_constraint.log", + filemode='w', force=True) # Compute the optimal control start_time = time.process_time() result3 = opt.solve_ocp( vehicle, horizon, x0, cost3, constraints, - terminal_constraints=terminal, initial_guess=u2, log=False, + terminal_constraints=terminal, initial_guess=u2, log=True, # solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, - solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + solve_ivp_kwargs={'method': 'RK23', 'atol': 1e-4, 'rtol': 1e-2}, 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 result3.success + # Extract and plot the results (+ state trajectory) t3, u3 = result3.time, result3.inputs t3, y3 = ct.input_output_response(vehicle, horizon, u3, x0) @@ -225,16 +243,21 @@ def plot_results(t, y, u, figure=None, yf=None): terminal_constraints=terminal, initial_guess=u3, basis=flat.BezierFamily(4, T=Tf), + solve_ivp_kwargs={'method': 'RK45', 'atol': 1e-2, 'rtol': 1e-2}, minimize_method='trust-constr', minimize_options={'disp': True}, # method='SLSQP', options={'eps': 0.01} - solve_ivp_kwargs={'atol': 1e-2, 'rtol': 1e-2}, ) 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 result4.success + # Extract and plot the results (+ state trajectory) 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]) +# If we are not running CI tests, display the results if 'PYCONTROL_TEST_EXAMPLES' not in os.environ: plt.show() From d735f794701a1eb5bab4230073eb4ce81ff55160 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 27 Feb 2021 22:45:32 -0800 Subject: [PATCH 16/18] add unit tests for additional coverage --- control/tests/optimal_test.py | 33 +++++++++++++++++++++++++++------ examples/steering-optimal.py | 11 ++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/control/tests/optimal_test.py b/control/tests/optimal_test.py index fc0ff79d7..d4b3fd6ef 100644 --- a/control/tests/optimal_test.py +++ b/control/tests/optimal_test.py @@ -15,6 +15,7 @@ from control.tests.conftest import slycotonly from numpy.lib import NumpyVersion + def test_finite_horizon_simple(): # Define a linear system with constraints # Source: https://www.mpt3.org/UI/RegulationProblem @@ -108,6 +109,7 @@ def test_discrete_lqr(): # Make sure we got a different solution assert np.any(np.abs(res1.inputs - res2.inputs) > 0.1) + def test_mpc_iosystem(): # model of an aircraft discretized with 0.2s sampling time # Source: https://www.mpt3.org/UI/RegulationProblem @@ -212,6 +214,7 @@ def test_constraint_specification(constraint_list): np.testing.assert_almost_equal( u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=3) + @pytest.mark.parametrize("sys_args", [ pytest.param( ([[1, 0], [0, 1]], np.eye(2), np.eye(2), 0, True), @@ -319,6 +322,7 @@ def test_terminal_constraints(sys_args): res = optctrl.compute_trajectory(x0, squeeze=True, return_x=True) assert not res.success + def test_optimal_logging(capsys): """Test logging functions (mainly for code coverage)""" sys = ct.ss2io(ct.ss(np.eye(2), np.eye(2), np.eye(2), 0, 1)) @@ -435,14 +439,31 @@ def test_optimal_basis_simple(): cost = opt.quadratic_cost(sys, Q, R) # Set up the optimal control problem - time = np.arange(0, 5, 1) + Tf = 5 + time = np.arange(0, Tf, 1) x0 = [4, 0] # Basic optimal control problem - res = opt.solve_ocp(sys, time, x0, cost, constraints, return_x=True) - assert res.success + res1 = opt.solve_ocp( + sys, time, x0, cost, constraints, + basis=flat.BezierFamily(4, Tf), return_x=True) + assert res1.success # Make sure the constraints were satisfied - np.testing.assert_array_less(np.abs(res.states[0]), 5 + 1e-6) - np.testing.assert_array_less(np.abs(res.states[1]), 5 + 1e-6) - np.testing.assert_array_less(np.abs(res.inputs[0]), 1 + 1e-6) + np.testing.assert_array_less(np.abs(res1.states[0]), 5 + 1e-6) + np.testing.assert_array_less(np.abs(res1.states[1]), 5 + 1e-6) + np.testing.assert_array_less(np.abs(res1.inputs[0]), 1 + 1e-6) + + # Pass an initial guess and rerun + res2 = opt.solve_ocp( + sys, time, x0, cost, constraints, initial_guess=0.99*res1.inputs, + basis=flat.BezierFamily(4, Tf), return_x=True) + assert res2.success + np.testing.assert_almost_equal(res2.inputs, res1.inputs, decimal=3) + + # Run with logging turned on for code coverage + res3 = opt.solve_ocp( + sys, time, x0, cost, constraints, + basis=flat.BezierFamily(4, Tf), return_x=True, log=True) + assert res3.success + np.testing.assert_almost_equal(res3.inputs, res1.inputs, decimal=3) diff --git a/examples/steering-optimal.py b/examples/steering-optimal.py index df76ea1ad..2fc7f4cc2 100644 --- a/examples/steering-optimal.py +++ b/examples/steering-optimal.py @@ -107,8 +107,8 @@ def plot_results(t, y, u, figure=None, yf=None): print("Approach 1: standard quadratic cost") # Set up the cost functions -Q = np.diag([.1, 10, .1]) # keep lateral error low -R = np.diag([.1, 1]) # minimize applied inputs +Q = np.diag([.1, 10, .1]) # keep lateral error low +R = np.diag([.1, 1]) # minimize applied inputs quad_cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) # Define the time horizon (and spacing) for the optimization @@ -209,9 +209,9 @@ def plot_results(t, y, u, figure=None, yf=None): start_time = time.process_time() result3 = opt.solve_ocp( vehicle, horizon, x0, cost3, constraints, - terminal_constraints=terminal, initial_guess=u2, log=True, - # solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, - solve_ivp_kwargs={'method': 'RK23', 'atol': 1e-4, 'rtol': 1e-2}, + terminal_constraints=terminal, initial_guess=u2, log=False, + solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + # solve_ivp_kwargs={'method': 'RK23', 'atol': 1e-4, 'rtol': 1e-2}, minimize_options={'eps': 0.01}) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) @@ -246,6 +246,7 @@ def plot_results(t, y, u, figure=None, yf=None): solve_ivp_kwargs={'method': 'RK45', 'atol': 1e-2, 'rtol': 1e-2}, minimize_method='trust-constr', minimize_options={'disp': True}, # method='SLSQP', options={'eps': 0.01} + log=True ) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) From 5838c2fa2da69cab46935d03a801be3ca63275d3 Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sat, 27 Feb 2021 23:17:42 -0800 Subject: [PATCH 17/18] clean up steering-optimal example --- examples/steering-optimal.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/examples/steering-optimal.py b/examples/steering-optimal.py index 2fc7f4cc2..f6dfe8ac9 100644 --- a/examples/steering-optimal.py +++ b/examples/steering-optimal.py @@ -101,9 +101,6 @@ def plot_results(t, y, u, figure=None, yf=None): # distance form the desired final point while at the same time as not # exerting too much control effort to achieve our goal. # -# Note: depending on what version of SciPy you are using, you might get a -# warning message about precision loss, but the solution is pretty good. -# print("Approach 1: standard quadratic cost") # Set up the cost functions @@ -126,11 +123,8 @@ def plot_results(t, y, u, figure=None, yf=None): start_time = time.process_time() result1 = opt.solve_ocp( vehicle, horizon, x0, quad_cost, initial_guess=bend_left, log=True, - # solve_ivp_kwargs={'atol': 1e-2, 'rtol': 1e-2}, - # solve_ivp_kwargs={'method': 'RK23', 'atol': 1e-2, 'rtol': 1e-2}, minimize_method='trust-constr', minimize_options={'finite_diff_rel_step': 0.01}, - # minimize_options={'eps': 0.01} ) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) @@ -171,7 +165,6 @@ def plot_results(t, y, u, figure=None, yf=None): result2 = opt.solve_ocp( vehicle, horizon, x0, traj_cost, constraints, terminal_cost=term_cost, initial_guess=bend_left, log=True, - # solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, minimize_method='SLSQP', minimize_options={'eps': 0.01}) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) @@ -191,13 +184,13 @@ 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. # -# To speeds things up a bit, we initalize the problem using the previous -# optimal controller (which didn't quite hit the final value). -# print("Approach 3: terminal constraints") # Input cost and terminal constraints +R = np.diag([1, 1]) # minimize applied inputs cost3 = opt.quadratic_cost(vehicle, np.zeros((3,3)), R, u0=uf) +constraints = [ + opt.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ] terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] # Reset logging to its default values @@ -209,10 +202,10 @@ def plot_results(t, y, u, figure=None, yf=None): start_time = time.process_time() result3 = opt.solve_ocp( vehicle, horizon, x0, cost3, constraints, - terminal_constraints=terminal, initial_guess=u2, log=False, - solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, - # solve_ivp_kwargs={'method': 'RK23', 'atol': 1e-4, 'rtol': 1e-2}, - minimize_options={'eps': 0.01}) + terminal_constraints=terminal, initial_guess=bend_left, 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)) # If we are running CI tests, make sure we succeeded @@ -241,12 +234,12 @@ def plot_results(t, y, u, figure=None, yf=None): vehicle, horizon, x0, quad_cost, constraints, terminal_constraints=terminal, - initial_guess=u3, + initial_guess=bend_left, basis=flat.BezierFamily(4, T=Tf), - solve_ivp_kwargs={'method': 'RK45', 'atol': 1e-2, 'rtol': 1e-2}, + # 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}, - # method='SLSQP', options={'eps': 0.01} - log=True + log=False ) print("* Total time = %5g seconds\n" % (time.process_time() - start_time)) From c49ee9038e2e2d94f9d1b4937dba5c901687e7ff Mon Sep 17 00:00:00 2001 From: Richard Murray Date: Sun, 28 Feb 2021 21:26:43 -0800 Subject: [PATCH 18/18] updated benchmarks + performance tweaks --- benchmarks/optimal_bench.py | 96 +++++++++++++++++++----------------- control/flatsys/bezier.py | 8 +-- control/iosys.py | 26 ++++++++-- examples/steering-optimal.py | 3 +- 4 files changed, 79 insertions(+), 54 deletions(-) diff --git a/benchmarks/optimal_bench.py b/benchmarks/optimal_bench.py index 329066579..4b34ef04d 100644 --- a/benchmarks/optimal_bench.py +++ b/benchmarks/optimal_bench.py @@ -15,21 +15,7 @@ import time import os -# # Vehicle steering dynamics -# -# The vehicle dynamics are given by a simple bicycle model. We take the state -# of the system as (x, y, theta) where (x, y) is the position of the vehicle -# in the plane and theta is the angle of the vehicle with respect to -# horizontal. The vehicle input is given by (v, phi) where v is the forward -# velocity of the vehicle and phi is the angle of the steering wheel. The -# model includes saturation of the vehicle steering angle. -# -# System state: x, y, theta -# System input: v, phi -# System output: x, y -# System parameters: wheelbase, maxsteer -# def vehicle_update(t, x, u, params): # Get the parameters for the model l = params.get('wheelbase', 3.) # vehicle wheelbase @@ -45,7 +31,6 @@ def vehicle_update(t, x, u, params): (u[0] / l) * math.tan(phi) # thdot = v/l tan(phi) ]) - def vehicle_output(t, x, u, params): return x # return x, y, theta (full state) @@ -53,6 +38,7 @@ def vehicle_output(t, x, u, params): vehicle_update, vehicle_output, states=3, name='vehicle', inputs=('v', 'phi'), outputs=('x', 'y', 'theta')) +# Initial and final conditions x0 = [0., -2., 0.]; u0 = [10., 0.] xf = [100., 2., 0.]; uf = [10., 0.] Tf = 10 @@ -63,7 +49,7 @@ def vehicle_output(t, x, u, params): # Provide an intial guess (will be extended to entire horizon) bend_left = [10, 0.01] # slight left veer -def time_integrated_cost(): +def time_steering_integrated_cost(): # Set up the cost functions Q = np.diag([.1, 10, .1]) # keep lateral error low R = np.diag([.1, 1]) # minimize applied inputs @@ -81,7 +67,7 @@ def time_integrated_cost(): # Only count this as a benchmark if we converged assert res.success -def time_terminal_cost(): +def time_steering_terminal_cost(): # Define cost and constraints traj_cost = opt.quadratic_cost( vehicle, None, np.diag([0.1, 1]), u0=uf) @@ -93,14 +79,35 @@ def time_terminal_cost(): res = opt.solve_ocp( vehicle, horizon, x0, traj_cost, constraints, terminal_cost=term_cost, initial_guess=bend_left, print_summary=False, - # solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, - minimize_method='SLSQP', minimize_options={'eps': 0.01} + solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, + # minimize_method='SLSQP', minimize_options={'eps': 0.01} + minimize_method='trust-constr', + minimize_options={'finite_diff_rel_step': 0.01}, ) - # Only count this as a benchmark if we converged assert res.success -def time_terminal_constraint(): +# Define integrator and minimizer methods and options/keywords +integrator_table = { + 'RK23_default': ('RK23', {'atol': 1e-4, 'rtol': 1e-2}), + 'RK23_sloppy': ('RK23', {}), + 'RK45_default': ('RK45', {}), + 'RK45_sloppy': ('RK45', {'atol': 1e-4, 'rtol': 1e-2}), +} + +minimizer_table = { + 'trust_default': ('trust-constr', {}), + 'trust_bigstep': ('trust-constr', {'finite_diff_rel_step': 0.01}), + 'SLSQP_default': ('SLSQP', {}), + 'SLSQP_bigstep': ('SLSQP', {'eps': 0.01}), +} + + +def time_steering_terminal_constraint(integrator_name, minimizer_name): + # Get the integrator and minimizer parameters to use + integrator = integrator_table[integrator_name] + minimizer = minimizer_table[minimizer_name] + # Input cost and terminal constraints R = np.diag([1, 1]) # minimize applied inputs cost = opt.quadratic_cost(vehicle, np.zeros((3,3)), R, u0=uf) @@ -111,58 +118,59 @@ def time_terminal_constraint(): res = opt.solve_ocp( vehicle, horizon, x0, cost, constraints, terminal_constraints=terminal, initial_guess=bend_left, log=False, - solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2}, - # solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, - minimize_method='trust-constr', - # minimize_method='SLSQP', minimize_options={'eps': 0.01} + solve_ivp_method=integrator[0], solve_ivp_kwargs=integrator[1], + minimize_method=minimizer[0], minimize_options=minimizer[1], ) - # Only count this as a benchmark if we converged assert res.success # Reset the timeout value to allow for longer runs -time_terminal_constraint.timeout = 120 +time_steering_terminal_constraint.timeout = 120 + +# Parameterize the test against different choices of integrator and minimizer +time_steering_terminal_constraint.param_names = ['integrator', 'minimizer'] +time_steering_terminal_constraint.params = ( + ['RK23_default', 'RK23_sloppy', 'RK45_default', 'RK45_sloppy'], + ['trust_default', 'trust_bigstep', 'SLSQP_default', 'SLSQP_bigstep'] +) -def time_optimal_basis_vehicle(): +def time_steering_bezier_basis(nbasis, ntimes): # Set up costs and constriants Q = np.diag([.1, 10, .1]) # keep lateral error low R = np.diag([1, 1]) # minimize applied inputs cost = opt.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf) constraints = [ opt.input_range_constraint(vehicle, [0, -0.1], [20, 0.1]) ] terminal = [ opt.state_range_constraint(vehicle, xf, xf) ] - bend_left = [10, 0.05] # slight left veer - near_optimal = [ - [ 1.15073736e+01, 1.16838616e+01, 1.15413395e+01, - 1.11585544e+01, 1.06142537e+01, 9.98718468e+00, - 9.35609454e+00, 8.79973057e+00, 8.39684004e+00, - 8.22617023e+00], - [ -9.99830506e-02, 8.98139594e-03, 5.26385615e-02, - 4.96635954e-02, 1.87316470e-02, -2.14821345e-02, - -5.23025996e-02, -5.50545990e-02, -1.10629834e-02, - 9.83473965e-02] ] # Set up horizon - horizon = np.linspace(0, Tf, 10, endpoint=True) + horizon = np.linspace(0, Tf, ntimes, endpoint=True) # Set up the optimal control problem res = opt.solve_ocp( vehicle, horizon, x0, cost, constraints, terminal_constraints=terminal, - initial_guess=near_optimal, - basis=flat.BezierFamily(4, T=Tf), + initial_guess=bend_left, + basis=flat.BezierFamily(nbasis, T=Tf), + # solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, minimize_method='trust-constr', + minimize_options={'finite_diff_rel_step': 0.01}, # minimize_method='SLSQP', minimize_options={'eps': 0.01}, - solve_ivp_kwargs={'atol': 1e-4, 'rtol': 1e-2}, return_states=True, print_summary=False ) t, u, x = res.time, res.inputs, res.states # Make sure we found a valid solution assert res.success - np.testing.assert_almost_equal(x[:, -1], xf, decimal=4) -def time_mpc_iosystem(): +# Reset the timeout value to allow for longer runs +time_steering_bezier_basis.timeout = 120 + +# Set the parameter values for the number of times and basis vectors +time_steering_bezier_basis.param_names = ['nbasis', 'ntimes'] +time_steering_bezier_basis.params = ([2, 4, 6], [5, 10, 20]) + +def time_aircraft_mpc(): # model of an aircraft discretized with 0.2s sampling time # Source: https://www.mpt3.org/UI/RegulationProblem A = [[0.99, 0.01, 0.18, -0.09, 0], diff --git a/control/flatsys/bezier.py b/control/flatsys/bezier.py index 8cb303312..1eb7a549f 100644 --- a/control/flatsys/bezier.py +++ b/control/flatsys/bezier.py @@ -15,16 +15,16 @@ # # 1. Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. -# +# # 2. Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in the # documentation and/or other materials provided with the distribution. -# +# # 3. Neither the name of the California Institute of Technology nor # the names of its contributors may be used to endorse or promote # products derived from this software without specific prior # written permission. -# +# # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS @@ -48,7 +48,7 @@ class BezierFamily(BasisFamily): This class represents the family of polynomials of the form .. math:: - \phi_i(t) = \sum_{i=0}^n {n \choose i} (T - t)^{n-i} t^i + \phi_i(t) = \sum_{i=0}^n {n \choose i} (T - t)^{n-i} t^i """ def __init__(self, N, T=1): diff --git a/control/iosys.py b/control/iosys.py index e75108e33..7ed4c8b05 100644 --- a/control/iosys.py +++ b/control/iosys.py @@ -1522,9 +1522,27 @@ def input_output_response( # Update the parameter values sys._update_params(params) + # + # Define a function to evaluate the input at an arbitrary time + # + # This is equivalent to the function + # + # ufun = sp.interpolate.interp1d(T, U, fill_value='extrapolate') + # + # but has a lot less overhead => simulation runs much faster + def ufun(t): + # Find the value of the index using linear interpolation + idx = np.searchsorted(T, t, side='left') + if idx == 0: + # For consistency in return type, multiple by a float + return U[..., 0] * 1. + else: + dt = (t - T[idx-1]) / (T[idx] - T[idx-1]) + return U[..., idx-1] * (1. - dt) + U[..., idx] * dt + # Create a lambda function for the right hand side - u = sp.interpolate.interp1d(T, U, fill_value="extrapolate") - def ivp_rhs(t, x): return sys._rhs(t, x, u(t)) + def ivp_rhs(t, x): + return sys._rhs(t, x, ufun(t)) # Perform the simulation if isctime(sys): @@ -1574,10 +1592,10 @@ def ivp_rhs(t, x): return sys._rhs(t, x, u(t)) for i in range(len(T)): # Store the current state and output soln.y.append(x) - y.append(sys._out(T[i], x, u(T[i]))) + y.append(sys._out(T[i], x, ufun(T[i]))) # Update the state for the next iteration - x = sys._rhs(T[i], x, u(T[i])) + x = sys._rhs(T[i], x, ufun(T[i])) # Convert output to numpy arrays soln.y = np.transpose(np.array(soln.y)) diff --git a/examples/steering-optimal.py b/examples/steering-optimal.py index f6dfe8ac9..5661e0f38 100644 --- a/examples/steering-optimal.py +++ b/examples/steering-optimal.py @@ -145,8 +145,7 @@ def plot_results(t, y, u, figure=None, yf=None): # inputs). Instead, we can penalize the final state and impose a higher # cost on the inputs, resuling in a more graduate lane change. # -# We also set the solver explicitly (its actually the default one, but shows -# how to do this). +# We also set the solver explicitly. # print("Approach 2: input cost and constraints plus terminal cost")