Skip to content

Commit 6f9c092

Browse files
committed
minor refactoring plus additional comments on method
1 parent 8711900 commit 6f9c092

2 files changed

Lines changed: 186 additions & 49 deletions

File tree

control/obc.py

Lines changed: 178 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,46 @@
1616

1717
from .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+
#
193286
def 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
204297
def 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+
#
215348
def 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

control/tests/obc_test.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,21 @@ def test_finite_horizon_mpc_simple():
3232

3333
# Create a model predictive controller system
3434
time = np.arange(0, 5, 1)
35-
mpc = obc.ModelPredictiveController(sys, time, cost, constraints)
35+
optctrl = obc.OptimalControlProblem(sys, time, cost, constraints)
36+
mpc = optctrl.mpc
3637

3738
# Optimal control input for a given value of the initial state
3839
x0 = [4, 0]
3940
u = mpc(x0)
4041
np.testing.assert_almost_equal(u, -1)
4142

4243
# Retrieve the full open-loop predictions
43-
t, u_openloop = mpc.compute_trajectory(x0, squeeze=True)
44+
t, u_openloop = optctrl.compute_trajectory(x0, squeeze=True)
4445
np.testing.assert_almost_equal(
4546
u_openloop, [-1, -1, 0.1393, 0.3361, -5.204e-16], decimal=4)
4647

4748
# Convert controller to an explicit form (not implemented yet)
48-
# mpc_explicit = mpc.explicit();
49+
# mpc_explicit = obc.explicit_mpc();
4950

5051
# Test explicit controller
5152
# u_explicit = mpc_explicit(x0)
@@ -64,7 +65,7 @@ def test_finite_horizon_mpc_oscillator():
6465

6566
# state and input constraints
6667
trajectory_constraints = [
67-
obc.state_poly_constraint(sys, pc.box2poly([[-10, 10]])),
68+
obc.output_poly_constraint(sys, pc.box2poly([[-10, 10]])),
6869
obc.input_poly_constraint(sys, pc.box2poly([[-1, 1]]))
6970
]
7071

@@ -78,8 +79,9 @@ def test_finite_horizon_mpc_oscillator():
7879
terminal_cost = obc.quadratic_cost(sys, S, 0)
7980

8081
# Formulate finite horizon MPC problem
81-
time = np.arange(0, 5, 5)
82-
mpc = obc.ModelPredictiveController(
82+
time = np.arange(0, 5, 1)
83+
optctrl = obc.OptimalControlProblem(
8384
sys, time, integral_cost, trajectory_constraints, terminal_cost)
8485

8586
# Add tests to make sure everything works
87+
t, u_openloop = optctrl.compute_trajectory([1, 1])

0 commit comments

Comments
 (0)