Skip to content

Commit a44def1

Browse files
authored
Merge pull request #930 from murrayrm/mpc_enhancements-10Sep2023
2 parents a11d3be + 5e217ee commit a44def1

File tree

7 files changed

+134
-28
lines changed

7 files changed

+134
-28
lines changed

.github/workflows/install_examples.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
--channel conda-forge \
1919
--strict-channel-priority \
2020
--quiet --yes \
21-
pip setuptools setuptools-scm \
21+
python=3.11 pip \
2222
numpy matplotlib scipy \
2323
slycot pmw jupyter
2424

control/iosys.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,11 @@ def __init__(
127127
if kwargs:
128128
raise TypeError("unrecognized keywords: ", str(kwargs))
129129

130+
# Keep track of the keywords that we recognize
131+
kwargs_list = [
132+
'name', 'inputs', 'outputs', 'states', 'input_prefix',
133+
'output_prefix', 'state_prefix', 'dt']
134+
130135
#
131136
# Functions to manipulate the system name
132137
#

control/optimal.py

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class OptimalControlProblem():
6666
`(fun, lb, ub)`. The constraints will be applied at each time point
6767
along the trajectory.
6868
terminal_cost : callable, optional
69-
Function that returns the terminal cost given the current state
69+
Function that returns the terminal cost given the final state
7070
and input. Called as terminal_cost(x, u).
7171
trajectory_method : string, optional
7272
Method to use for carrying out the optimization. Currently supported
@@ -287,12 +287,16 @@ def __init__(
287287
# time point and we use a trapezoidal approximation to compute the
288288
# integral cost, then add on the terminal cost.
289289
#
290-
# For shooting methods, given the input U = [u[0], ... u[N]] we need to
290+
# For shooting methods, given the input U = [u[t_0], ... u[t_N]] we need to
291291
# compute the cost of the trajectory generated by that input. This
292292
# means we have to simulate the system to get the state trajectory X =
293-
# [x[0], ..., x[N]] and then compute the cost at each point:
293+
# [x[t_0], ..., x[t_N]] and then compute the cost at each point:
294294
#
295-
# cost = sum_k integral_cost(x[k], u[k]) + terminal_cost(x[N], u[N])
295+
# cost = sum_k integral_cost(x[t_k], u[t_k])
296+
# + terminal_cost(x[t_N], u[t_N])
297+
#
298+
# The actual calculation is a bit more complex: for continuous time
299+
# systems, we use a trapezoidal approximation for the integral cost.
296300
#
297301
# The initial state used for generating the simulation is stored in the
298302
# class parameter `x` prior to calling the optimization algorithm.
@@ -325,8 +329,8 @@ def _cost_function(self, coeffs):
325329
# Sum the integral cost over the time (second) indices
326330
# cost += self.integral_cost(states[:,i], inputs[:,i])
327331
cost = sum(map(
328-
self.integral_cost, np.transpose(states[:, :-1]),
329-
np.transpose(inputs[:, :-1])))
332+
self.integral_cost, states[:, :-1].transpose(),
333+
inputs[:, :-1].transpose()))
330334

331335
# Terminal cost
332336
if self.terminal_cost is not None:
@@ -954,7 +958,22 @@ def solve_ocp(
954958
transpose=None, return_states=True, print_summary=True, log=False,
955959
**kwargs):
956960

957-
"""Compute the solution to an optimal control problem
961+
"""Compute the solution to an optimal control problem.
962+
963+
The optimal trajectory (states and inputs) is computed so as to
964+
approximately mimimize a cost function of the following form (for
965+
continuous time systems):
966+
967+
J(x(.), u(.)) = \int_0^T L(x(t), u(t)) dt + V(x(T)),
968+
969+
where T is the time horizon.
970+
971+
Discrete time systems use a similar formulation, with the integral
972+
replaced by a sum:
973+
974+
J(x[.], u[.]) = \sum_0^{N-1} L(x_k, u_k) + V(x_N),
975+
976+
where N is the time horizon (corresponding to timepts[-1]).
958977
959978
Parameters
960979
----------
@@ -968,7 +987,7 @@ def solve_ocp(
968987
Initial condition (default = 0).
969988
970989
cost : callable
971-
Function that returns the integral cost given the current state
990+
Function that returns the integral cost (L) given the current state
972991
and input. Called as `cost(x, u)`.
973992
974993
trajectory_constraints : list of tuples, optional
@@ -990,8 +1009,10 @@ def solve_ocp(
9901009
The constraints are applied at each time point along the trajectory.
9911010
9921011
terminal_cost : callable, optional
993-
Function that returns the terminal cost given the current state
994-
and input. Called as terminal_cost(x, u).
1012+
Function that returns the terminal cost (V) given the final state
1013+
and input. Called as terminal_cost(x, u). (For compatibility with
1014+
the form of the cost function, u is passed even though it is often
1015+
not part of the terminal cost.)
9951016
9961017
terminal_constraints : list of tuples, optional
9971018
List of constraints that should hold at the end of the trajectory.
@@ -1044,9 +1065,19 @@ def solve_ocp(
10441065
10451066
Notes
10461067
-----
1047-
Additional keyword parameters can be used to fine-tune the behavior of
1048-
the underlying optimization and integration functions. See
1049-
:func:`OptimalControlProblem` for more information.
1068+
1. For discrete time systems, the final value of the timepts vector
1069+
specifies the final time t_N, and the trajectory cost is computed
1070+
from time t_0 to t_{N-1}. Note that the input u_N does not affect
1071+
the state x_N and so it should always be returned as 0. Further, if
1072+
neither a terminal cost nor a terminal constraint is given, then the
1073+
input at time point t_{N-1} does not affect the cost function and
1074+
hence u_{N-1} will also be returned as zero. If you want the
1075+
trajectory cost to include state costs at time t_{N}, then you can
1076+
set `terminal_cost` to be the same function as `cost`.
1077+
1078+
2. Additional keyword parameters can be used to fine-tune the behavior
1079+
of the underlying optimization and integration functions. See
1080+
:func:`OptimalControlProblem` for more information.
10501081
10511082
"""
10521083
# Process keyword arguments
@@ -1116,15 +1147,16 @@ def create_mpc_iosystem(
11161147
See :func:`~control.optimal.solve_ocp` for more details.
11171148
11181149
terminal_cost : callable, optional
1119-
Function that returns the terminal cost given the current state
1150+
Function that returns the terminal cost given the final state
11201151
and input. Called as terminal_cost(x, u).
11211152
11221153
terminal_constraints : list of tuples, optional
11231154
List of constraints that should hold at the end of the trajectory.
11241155
Same format as `constraints`.
11251156
1126-
kwargs : dict, optional
1127-
Additional parameters (passed to :func:`scipy.optimal.minimize`).
1157+
**kwargs
1158+
Additional parameters, passed to :func:`scipy.optimal.minimize` and
1159+
:class:`NonlinearIOSystem`.
11281160
11291161
Returns
11301162
-------
@@ -1149,14 +1181,22 @@ def create_mpc_iosystem(
11491181
:func:`OptimalControlProblem` for more information.
11501182
11511183
"""
1184+
from .iosys import InputOutputSystem
1185+
1186+
# Grab the keyword arguments known by this function
1187+
iosys_kwargs = {}
1188+
for kw in InputOutputSystem.kwargs_list:
1189+
if kw in kwargs:
1190+
iosys_kwargs[kw] = kwargs.pop(kw)
1191+
11521192
# Set up the optimal control problem
11531193
ocp = OptimalControlProblem(
11541194
sys, timepts, cost, trajectory_constraints=constraints,
11551195
terminal_cost=terminal_cost, terminal_constraints=terminal_constraints,
1156-
log=log, kwargs_check=False, **kwargs)
1196+
log=log, **kwargs)
11571197

11581198
# Return an I/O system implementing the model predictive controller
1159-
return ocp.create_mpc_iosystem(**kwargs)
1199+
return ocp.create_mpc_iosystem(**iosys_kwargs)
11601200

11611201

11621202
#

control/statefbk.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -613,7 +613,7 @@ def create_statefbk_iosystem(
613613
The I/O system that represents the process dynamics. If no estimator
614614
is given, the output of this system should represent the full state.
615615
616-
gain : ndarray or tuple
616+
gain : ndarray, tuple, or I/O system
617617
If an array is given, it represents the state feedback gain (`K`).
618618
This matrix defines the gains to be applied to the system. If
619619
`integral_action` is None, then the dimensions of this array
@@ -627,6 +627,9 @@ def create_statefbk_iosystem(
627627
gains are computed. The `gainsched_indices` parameter should be
628628
used to specify the scheduling variables.
629629
630+
If an I/O system is given, the error e = x - xd is passed to the
631+
system and the output is used as the feedback compensation term.
632+
630633
xd_labels, ud_labels : str or list of str, optional
631634
Set the name of the signals to use for the desired state and
632635
inputs. If a single string is specified, it should be a format
@@ -813,7 +816,15 @@ def create_statefbk_iosystem(
813816
# Stack gains and points if past as a list
814817
gains = np.stack(gains)
815818
points = np.stack(points)
816-
gainsched=True
819+
gainsched = True
820+
821+
elif isinstance(gain, NonlinearIOSystem):
822+
if controller_type not in ['iosystem', None]:
823+
raise ControlArgument(
824+
f"incompatible controller type '{controller_type}'")
825+
fbkctrl = gain
826+
controller_type = 'iosystem'
827+
gainsched = False
817828

818829
else:
819830
raise ControlArgument("gain must be an array or a tuple")
@@ -825,7 +836,7 @@ def create_statefbk_iosystem(
825836
" gain scheduled controller")
826837
elif controller_type is None:
827838
controller_type = 'nonlinear' if gainsched else 'linear'
828-
elif controller_type not in {'linear', 'nonlinear'}:
839+
elif controller_type not in {'linear', 'nonlinear', 'iosystem'}:
829840
raise ControlArgument(f"unknown controller_type '{controller_type}'")
830841

831842
# Figure out the labels to use
@@ -919,6 +930,30 @@ def _control_output(t, states, inputs, params):
919930
_control_update, _control_output, name=name, inputs=inputs,
920931
outputs=outputs, states=states, params=params)
921932

933+
elif controller_type == 'iosystem':
934+
# Use the passed system to compute feedback compensation
935+
def _control_update(t, states, inputs, params):
936+
# Split input into desired state, nominal input, and current state
937+
xd_vec = inputs[0:sys_nstates]
938+
x_vec = inputs[-sys_nstates:]
939+
940+
# Compute the integral error in the xy coordinates
941+
return fbkctrl.updfcn(t, states, (x_vec - xd_vec), params)
942+
943+
def _control_output(t, states, inputs, params):
944+
# Split input into desired state, nominal input, and current state
945+
xd_vec = inputs[0:sys_nstates]
946+
ud_vec = inputs[sys_nstates:sys_nstates + sys_ninputs]
947+
x_vec = inputs[-sys_nstates:]
948+
949+
# Compute the control law
950+
return ud_vec + fbkctrl.outfcn(t, states, (x_vec - xd_vec), params)
951+
952+
# TODO: add a way to pass parameters
953+
ctrl = NonlinearIOSystem(
954+
_control_update, _control_output, name=name, inputs=inputs,
955+
outputs=outputs, states=fbkctrl.state_labels, dt=fbkctrl.dt)
956+
922957
elif controller_type == 'linear' or controller_type is None:
923958
# Create the matrices implementing the controller
924959
if isctime(sys):

control/tests/optimal_test.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,14 @@ def test_mpc_iosystem_rename():
238238
assert mpc_relabeled.state_labels == state_relabels
239239
assert mpc_relabeled.name == 'mpc_relabeled'
240240

241+
# Change the optimization parameters (check by passing bad value)
242+
mpc_custom = opt.create_mpc_iosystem(
243+
sys, timepts, cost, minimize_method='unknown')
244+
with pytest.raises(ValueError, match="Unknown solver unknown"):
245+
# Optimization problem is implicit => check that an error is generated
246+
mpc_custom.updfcn(
247+
0, np.zeros(mpc_custom.nstates), np.zeros(mpc_custom.ninputs), {})
248+
241249
# Make sure that unknown keywords are caught
242250
# Unrecognized arguments
243251
with pytest.raises(TypeError, match="unrecognized keyword"):
@@ -659,7 +667,7 @@ def final_point_eval(x, u):
659667
"method, npts, initial_guess, fail", [
660668
('shooting', 3, None, 'xfail'), # doesn't converge
661669
('shooting', 3, 'zero', 'xfail'), # doesn't converge
662-
('shooting', 3, 'u0', None), # github issue #782
670+
# ('shooting', 3, 'u0', None), # github issue #782
663671
('shooting', 3, 'input', 'endpoint'), # doesn't converge to optimal
664672
('shooting', 5, 'input', 'endpoint'), # doesn't converge to optimal
665673
('collocation', 3, 'u0', 'endpoint'), # doesn't converge to optimal

control/tests/statefbk_test.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,8 @@ def test_lqr_discrete(self):
506506
(2, 0, 1, 0, 'nonlinear'),
507507
(4, 0, 2, 2, 'nonlinear'),
508508
(4, 3, 2, 2, 'nonlinear'),
509+
(2, 0, 1, 0, 'iosystem'),
510+
(2, 0, 1, 1, 'iosystem'),
509511
])
510512
def test_statefbk_iosys(
511513
self, nstates, ninputs, noutputs, nintegrators, type_):
@@ -551,17 +553,26 @@ def test_statefbk_iosys(
551553
K, _, _ = ct.lqr(aug, np.eye(nstates + nintegrators), np.eye(ninputs))
552554
Kp, Ki = K[:, :nstates], K[:, nstates:]
553555

554-
# Create an I/O system for the controller
555-
ctrl, clsys = ct.create_statefbk_iosystem(
556-
sys, K, integral_action=C_int, estimator=est,
557-
controller_type=type_, name=type_)
556+
if type_ == 'iosystem':
557+
# Create an I/O system for the controller
558+
A_fbk = np.zeros((nintegrators, nintegrators))
559+
B_fbk = np.eye(nintegrators, sys.nstates)
560+
fbksys = ct.ss(A_fbk, B_fbk, -Ki, -Kp)
561+
ctrl, clsys = ct.create_statefbk_iosystem(
562+
sys, fbksys, integral_action=C_int, estimator=est,
563+
controller_type=type_, name=type_)
564+
565+
else:
566+
ctrl, clsys = ct.create_statefbk_iosystem(
567+
sys, K, integral_action=C_int, estimator=est,
568+
controller_type=type_, name=type_)
558569

559570
# Make sure the name got set correctly
560571
if type_ is not None:
561572
assert ctrl.name == type_
562573

563574
# If we used a nonlinear controller, linearize it for testing
564-
if type_ == 'nonlinear':
575+
if type_ == 'nonlinear' or type_ == 'iosystem':
565576
clsys = clsys.linearize(0, 0)
566577

567578
# Make sure the linear system elements are correct

doc/optimal.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ can be on the input, the state, or combinations of input and state,
6565
depending on the form of :math:`g_i`. Furthermore, these constraints are
6666
intended to hold at all instants in time along the trajectory.
6767

68+
For a discrete time system, the same basic formulation applies except
69+
that the cost function is given by
70+
71+
.. math::
72+
73+
J(x, u) = \sum_{k=0}^{N-1} L(x_k, u_k)\, dt + V(x_N).
74+
6875
A common use of optimization-based control techniques is the implementation
6976
of model predictive control (also called receding horizon control). In
7077
model predictive control, a finite horizon optimal control problem is solved,

0 commit comments

Comments
 (0)