1616
1717from .timeresp import _process_time_response
1818
19- class ModelPredictiveController ():
20- """The :class:`ModelPredictiveController` class is a front end for computing
21- an optimal control input for a nonilinear system with a user-defined cost
19+ #
20+ # OptimalControlProblem class
21+ #
22+ # The OptimalControlProblem class holds all of the information required to
23+ # specify and optimal control problem: the system dynamics, cost function,
24+ # and constraints. As much as possible, the information used to specify an
25+ # optimal control problem matches the notation and terminology of the SciPy
26+ # `optimize.minimize` module, with the hope that this makes it easier to
27+ # remember how to describe a problem.
28+ #
29+ # The approach that we use here is to set up an optimization over the
30+ # inputs at each point in time, using the integral and terminal costs as
31+ # well as the trajectory and terminal constraints. The main function of
32+ # this class is to create an optimization problem that can be solved using
33+ # scipy.optimize.minimize().
34+ #
35+ # The `cost_function` method takes the information stored here and computes
36+ # the cost of the trajectory generated by the proposed input. It does this
37+ # by calling a user-defined function for the integral_cost given the
38+ # current states and inputs at each point along the trajetory and then
39+ # adding the value of a user-defined terminal cost at the final pint in the
40+ # trajectory.
41+ #
42+ # The `constraint_function` method evaluates the constraint functions along
43+ # the trajectory generated by the proposed input. As in the case of the
44+ # cost function, the constraints are evaluated at the state and input along
45+ # each point on the trjectory. This information is compared against the
46+ # constraint upper and lower bounds. The constraint function is processed
47+ # in the class initializer, so that it only needs to be computed once.
48+ #
49+ class OptimalControlProblem ():
50+ """The :class:`OptimalControlProblem` class is a front end for computing an
51+ optimal control input for a nonilinear system with a user-defined cost
2252 function and state and input constraints.
2353
2454 """
2555 def __init__ (
2656 self , sys , time , integral_cost , trajectory_constraints = [],
2757 terminal_cost = None , terminal_constraints = []):
28-
58+ # Save the basic information for use later
2959 self .system = sys
3060 self .time_vector = time
3161 self .integral_cost = integral_cost
@@ -34,20 +64,28 @@ def __init__(
3464 self .terminal_constraints = terminal_constraints
3565
3666 #
37- # The approach that we use here is to set up an optimization over the
38- # inputs at each point in time, using the integral and terminal costs
39- # as well as the trajectory and terminal constraints. The main work
40- # of this method is to create the optimization problem that can be
41- # solved with scipy.optimize.minimize().
67+ # Compute and store constraints
68+ #
69+ # While the constraints are evaluated during the execution of the
70+ # SciPy optimization method itself, we go ahead and pre-compute the
71+ # `scipy.optimize.NonlinearConstraint` function that will be passed to
72+ # the optimizer on initialization, since it doesn't change. This is
73+ # mainly a matter of computing the lower and upper bound vectors,
74+ # which we need to "stack" to account for the evaluation at each
75+ # trajectory time point plus any terminal constraints (in a way that
76+ # is consistent with the `constraint_function` that is used at
77+ # evaluation time.
4278 #
43-
44- # Gather together all of the constraints
4579 constraint_lb , constraint_ub = [], []
80+
81+ # Go through each time point and stack the bounds
4682 for time in self .time_vector :
4783 for constraint in self .trajectory_constraints :
4884 type , fun , lb , ub = constraint
4985 constraint_lb .append (lb )
5086 constraint_ub .append (ub )
87+
88+ # Add on the terminal constraints
5189 for constraint in self .terminal_constraints :
5290 type , fun , lb , ub = constraint
5391 constraint_lb .append (lb )
@@ -60,8 +98,15 @@ def __init__(
6098 # Create the new constraint
6199 self .constraints = sp .optimize .NonlinearConstraint (
62100 self .constraint_function , self .constraint_lb , self .constraint_ub )
63-
101+
102+ #
64103 # Initial guess
104+ #
105+ # We store an initial guess (zero input) in case it is not specified
106+ # later.
107+ #
108+ # TODO: add the ability to overwride this when calling the optimizer.
109+ #
65110 self .initial_guess = np .zeros (
66111 self .system .ninputs * self .time_vector .size )
67112
@@ -75,14 +120,18 @@ def __init__(
75120 #
76121 # Cost = sum_k integral_cost(x[k], u[k]) + terminal_cost(x[N], u[N])
77122 #
123+ # The initial state is for generating the simulation is store in the class
124+ # parameter `x` prior to calling the optimization algorithm.
125+ #
78126 def cost_function (self , inputs ):
79- # Reshape the input vector
127+ # Retrieve the initial state and reshape the input vector
128+ x = self .x
80129 inputs = inputs .reshape (
81130 (self .system .ninputs , self .time_vector .size ))
82131
83132 # Simulate the system to get the state
84133 _ , _ , states = ct .input_output_response (
85- self .system , self .time_vector , inputs , self . x , return_x = True )
134+ self .system , self .time_vector , inputs , x , return_x = True )
86135
87136 # Trajectory cost
88137 # TODO: vectorize
@@ -104,72 +153,94 @@ def cost_function(self, inputs):
104153 # constraints, which each take inputs [x, u] and evaluate the
105154 # constraint. How we handle these depends on the type of constraint:
106155 #
107- # We have stored the form of the constraint at a single point, but we
108- # now need to extend this to apply to each point in the trajectory.
109- # This means that we need to create N constraints, each of which holds
110- # at a specific point in time, and implements the original constraint.
156+ # * For linear constraints (LinearConstraint), a combined vector of the
157+ # state and input is multiplied by the polytope A matrix for
158+ # comparison against the upper and lower bounds.
159+ #
160+ # * For nonlinear constraints (NonlinearConstraint), a user-specific
161+ # constraint function having the form
162+ #
163+ # constraint_fun(x, u)
164+ #
165+ # is called at each point along the trajectory and compared against the
166+ # upper and lower bounds.
167+ #
168+ # In both cases, the constraint is specified at a single point, but we
169+ # extend this to apply to each point in the trajectory. This means
170+ # that for N time points with m trajectory constraints and p terminal
171+ # constraints we need to compute N*m + p constraints, each of which
172+ # holds at a specific point in time, and implements the original
173+ # constraint.
111174 #
112175 # To do this, we basically create a function that simulates the system
113- # dynamics and returns a vector of values corresponding to the value
114- # of the function at each time. We also replicate the upper and lower
115- # bounds for each point in time.
176+ # dynamics and returns a vector of values corresponding to the value of
177+ # the function at each time. The class initialization methods takes
178+ # care of replicating the upper and lower bounds for each point in time
179+ # so that the SciPy optimization algorithm can do the proper
180+ # evaluation.
181+ #
182+ # In addition, since SciPy's optimization function does not allow us to
183+ # pass arguments to the constraint function, we have to store the initial
184+ # state prior to optimization and retrieve it here.
116185 #
117-
118- # Define a function to evaluate all of the constraints
119186 def constraint_function (self , inputs ):
120- # Reshape the input vector
187+ # Retrieve the initial state and reshape the input vector
188+ x = self .x
121189 inputs = inputs .reshape (
122190 (self .system .ninputs , self .time_vector .size ))
123191
124192 # Simulate the system to get the state
125193 _ , _ , states = ct .input_output_response (
126- self .system , self .time_vector , inputs , self . x , return_x = True )
194+ self .system , self .time_vector , inputs , x , return_x = True )
127195
196+ # Evaluate the constraint function along the trajectory
128197 value = []
129198 for i , time in enumerate (self .time_vector ):
130199 for constraint in self .trajectory_constraints :
131200 type , fun , lb , ub = constraint
132201 if type == opt .LinearConstraint :
202+ # `fun` is the A matrix associated with the polytope...
133203 value .append (
134204 np .dot (fun , np .hstack ([states [:,i ], inputs [:,i ]])))
205+ elif type == opt .NonlinearConstraint :
206+ value .append (
207+ fun (np .hstack ([states [:,i ], inputs [:,i ]])))
135208 else :
136209 raise TypeError ("unknown constraint type %s" %
137210 constraint [0 ])
138211
212+ # Evaluate the terminal constraint functions
139213 for constraint in self .terminal_constraints :
140214 type , fun , lb , ub = constraint
141215 if type == opt .LinearConstraint :
142216 value .append (
143217 np .dot (fun , np .hstack ([states [:,i ], inputs [:,i ]])))
218+ elif type == opt .NonlinearConstraint :
219+ value .append (
220+ fun (np .hstack ([states [:,i ], inputs [:,i ]])))
144221 else :
145222 raise TypeError ("unknown constraint type %s" %
146223 constraint [0 ])
147224
148225 # Return the value of the constraint function
149226 return np .hstack (value )
150227
151- def __call__ (self , x ):
228+ # Allow optctrl(x) as a replacement for optctrl.mpc(x)
229+ def __call__ (self , x , squeeze = None ):
152230 """Compute the optimal input at state x"""
153- # Store the starting point
154- # TODO: call compute_trajectory?
155- self .x = x
156-
157- # Call ScipPy optimizer
158- res = sp .optimize .minimize (
159- self .cost_function , self .initial_guess ,
160- constraints = self .constraints )
231+ return self .mpc (x , squeeze = squeeze )
161232
162- # Return the result
163- if res .success :
164- return res .x [0 ]
165- else :
166- warnings .warn (res .message )
167- return None
233+ # Compute the current input to apply from the current state (MPC style)
234+ def mpc (self , x , squeeze = None ):
235+ """Compute the optimal input at state x"""
236+ _ , inputs = self .compute_trajectory (x , squeeze = squeeze )
237+ return None if inputs is None else inputs .transpose ()[0 ]
168238
239+ # Compute the optimal trajectory from the current state
169240 def compute_trajectory (
170241 self , x , squeeze = None , transpose = None , return_x = None ):
171242 """Compute the optimal input at state x"""
172- # Store the starting point
243+ # Store the initial state (for use in constraint_function)
173244 self .x = x
174245
175246 # Call ScipPy optimizer
@@ -185,11 +256,33 @@ def compute_trajectory(
185256 # Reshape the input vector
186257 inputs = res .x .reshape (
187258 (self .system .ninputs , self .time_vector .size ))
259+
260+ if return_x :
261+ # Simulate the system if we need the state back
262+ _ , _ , states = ct .input_output_response (
263+ self .system , self .time_vector , inputs , x , return_x = True )
264+ else :
265+ states = None
188266
189267 return _process_time_response (
190- self .system , self .time_vector , inputs , None ,
268+ self .system , self .time_vector , inputs , states ,
191269 transpose = transpose , return_x = return_x , squeeze = squeeze )
192270
271+
272+ #
273+ # Create a polytope constraint on the system state
274+ #
275+ # As in the cost function evaluation, the main "trick" in creating a constrain
276+ # on the state or input is to properly evaluate the constraint on the stacked
277+ # state and input vector at the current time point. The constraint itself
278+ # will be called at each poing along the trajectory (or the endpoint) via the
279+ # constrain_function() method.
280+ #
281+ # Note that these functions to not actually evaluate the constraint, they
282+ # simply return the information required to do so. We use the SciPy
283+ # optimization methods LinearConstraint and NonlinearConstraint as "types" to
284+ # keep things consistent with the terminology in scipy.optimize.
285+ #
193286def state_poly_constraint (sys , polytope ):
194287 """Create state constraint from polytope"""
195288 # TODO: make sure the system and constraints are compatible
@@ -200,7 +293,7 @@ def state_poly_constraint(sys, polytope):
200293 [polytope .A , np .zeros ((polytope .A .shape [0 ], sys .ninputs ))]),
201294 np .full (polytope .A .shape [0 ], - np .inf ), polytope .b )
202295
203-
296+ # Create a constraint polytope on the system input
204297def input_poly_constraint (sys , polytope ):
205298 """Create input constraint from polytope"""
206299 # TODO: make sure the system and constraints are compatible
@@ -212,6 +305,48 @@ def input_poly_constraint(sys, polytope):
212305 np .full (polytope .A .shape [0 ], - np .inf ), polytope .b )
213306
214307
308+ #
309+ # Create a constraint polytope on the system output
310+ #
311+ # Unlike the state and input constraints, for the output constraint we need to
312+ # do a function evaluation before applying the constraints.
313+ #
314+ # TODO: for the special case of an LTI system, we can avoid the extra function
315+ # call by multiplying the state by the C matrix for the system and then
316+ # imposing a linear constraint:
317+ #
318+ # np.hstack(
319+ # [polytope.A @ sys.C, np.zeros((polytope.A.shape[0], sys.ninputs))])
320+ #
321+ def output_poly_constraint (sys , polytope ):
322+ """Create output constraint from polytope"""
323+ # TODO: make sure the system and constraints are compatible
324+
325+ #
326+ # Function to create the output
327+ def _evaluate_output_constraint (x ):
328+ # Separate the constraint into states and inputs
329+ states = x [:sys .nstates ]
330+ inputs = x [sys .nstates :]
331+ outputs = sys ._out (0 , states , inputs )
332+ return polytope .A @ outputs
333+
334+ # Return a nonlinear constraint object based on the polynomial
335+ return (opt .NonlinearConstraint ,
336+ _evaluate_output_constraint ,
337+ np .full (polytope .A .shape [0 ], - np .inf ), polytope .b )
338+
339+
340+ #
341+ # Quadratic cost function
342+ #
343+ # Since a quadratic function is common as a cost function, we provide a
344+ # function that will take a Q and R matrix and return a callable that
345+ # evaluates to associted quadratic cost. This is compatible with the way that
346+ # the `cost_function` evaluates the cost at each point in the trajectory.
347+ #
215348def quadratic_cost (sys , Q , R ):
216349 """Create quadratic cost function"""
350+ Q = np .atleast_2d (Q )
351+ R = np .atleast_2d (R )
217352 return lambda x , u : x @ Q @ x + u @ R @ u
0 commit comments