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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