From dcd553b97a2ea4fb9cb6708852832b33c7b26395 Mon Sep 17 00:00:00 2001 From: andersct <andersct@umich.edu> Date: Sat, 26 Sep 2020 23:01:59 -0400 Subject: [PATCH] Added controllers based on control lyapunov functions - refactor state space models to return function info as dictionaries - move riccati solver to misc - add derivative calculation to empirical drag model - add min norm controller for relative degree 1 control (accel -> v) - add min norm controller for relative degree 2 control (accel -> pos) - add documentation for the min norm controllers --- controls/control_affine_clf.py | 188 ++++++++++++++++++ controls/sdre.py | 22 +- controls/stepping.py | 10 +- controls/time_based.py | 9 +- examples/display_control_affine_clf.py | 77 +++++++ misc/riccati.py | 21 ++ state_space_models/empirical_drag.py | 40 ++-- .../simple_planar_integrator.py | 57 ++---- vis/vis_mounted_thruster_model.py | 2 +- 9 files changed, 345 insertions(+), 81 deletions(-) create mode 100644 controls/control_affine_clf.py create mode 100644 examples/display_control_affine_clf.py create mode 100644 misc/riccati.py diff --git a/controls/control_affine_clf.py b/controls/control_affine_clf.py new file mode 100644 index 0000000..2522e59 --- /dev/null +++ b/controls/control_affine_clf.py @@ -0,0 +1,188 @@ +""" +Implements control laws formed by control lyapunov functions (CLF) +for processes that are affine in the control: + +x_dot = f(x) + g(x)u + +(Let x \in \reals^m denote state, u \in \reals^n denote control, +so g(x) \in \reals^{m,n}). +The CLFs are based on feedback linearization, which looks at the +output we seek to control, say, + +drive y(x) = Cx - d -> 0 + +and chooses the control u based on the the output's derivative + +y_dot = L_f y(x) + L_g y(x) u + +where L_f is the Lie derivative wrt f(x); L_f y(x) = dy(x)/dx f(x). +The control is chosen like u = [L_g y(x)]^-1 (-L_f y(x) + mu) to obtain + +y_dot = mu + +This mu can then be chosen to satisfy desirable CLF properties such +as exponential stabilization. +""" +import numpy as np +from misc import riccati +from scipy import optimize as opt + + +class MinNormRd1ClfController(object): + """ + Let x \in \reals^m denote state, y \in \reals^r be an output we + seek to control. + Use a CLF to drive + y = Cx - d -> 0 + at a given exponential rate, selecting the minimum norm control. + This controller applies only for y with a relative degree 1, + i.e., if u = acceleration, y must be velocities. + + Specifically, the set of controls satisfying the rate is taken as: + {u \in \reals^n | L_f V(x) + L_g V(x) u + eps V(x) <= 0} + = + {u \in \reals^n | phi_0(x) + phi_1(x)' u <= 0} + where + V(x) = y(x)'y(x) + phi_0(x) = L_f V(x) + eps V(x) + = 2y'Cf(x) + eps y'y + phi_1(x) = (L_g V(x) )' + = [2y'Cg(x)]' + The minimum norm control is given by choosing the minimum norm element + of the set. The minimum norm control is + u = { 0 if phi_0(x) <= 0 + -phi_0(x)phi_1(x)/||phi_1(x)||^2_2 else + + This implementation closely follows material from: + A. D. Ames, J. W. Grizzle, and P. Tabuada. "Control barrier function based + quadratic programs with application to adaptive cruise control," in + IEEE Conference on Decision and Control (CDC), 2014, pp. 6271-6278. + """ + + def __init__(self, eps_rate): + """ + :param eps_rate: rate for exponential convergence to setpoint + """ + self.eps_rate = eps_rate + self.C = np.zeros(()) + self.d = np.zeros(()) + + def set_setpoint(self, C, d): + self.C = C + self.d = d + + def __call__(self, x, t, fx=None, gx=None, **kwargs): + y = self.C.dot(x) - self.d + phi_zero = 2 * y.T.dot(self.C).dot(fx) + self.eps_rate * y.dot(y) # scalar + phi_one = 2 * y.T.dot(self.C).dot(gx) # n, + if phi_zero <= 0: + return 0 * phi_one + bot = np.linalg.norm(phi_one) ** 2 + u = -phi_zero * phi_one / max(bot, 1e-8) + return u + + +class MinNormRd2ClfController(object): + """ + Let x \in \reals^m denote state, y \in \reals^r be an output we + seek to control. + Use a CLF to drive + y = Cx - d -> 0 + at a given exponential rate, selecting the minimum norm control. + This controller applies only for y with a relative degree 2, + i.e., if u = acceleration, y must be position/heading. + + Compared to the case of relative degree 1, we need to take one + more derivative to get to the form y_dot = mu. + + This implementation closely follows material from: + A. D. Ames and M. Powell. "Towards the unification of locomotion and + manipulation through control lyapunov functions and quadratic programs," + in Control of Cyber-Physical Systems. Springer, 2013, pp. 219-240. + """ + + def __init__(self, eps_rate, c_delta, u_min, u_max): + """ + :param eps_rate: rate for exponential convergence to setpoint + :param c_delta: penalty for violating CLF constraint + :param u_min: n, | minimum input values + :param u_max: n, | maximum input values + """ + self.eps_rate = eps_rate + self.c_delta = c_delta + self.u_min = u_min + self.u_max = u_max + self.C = np.zeros(()) + self.d = np.zeros(()) + self.C_dot = np.zeros(()) # for extracting y_dot(x_t) from x_t + self.F = np.zeros(()) + self.G = np.zeros(()) + self.Q = np.zeros(()) + self.P = np.zeros(()) + + def set_setpoint(self, C, d, C_dot, Q=()): + self.C = C + self.d = d + self.C_dot = C_dot + r = C.shape[0] + self.F = np.zeros((2*r, 2*r)) + self.F[:r, r:] = np.eye(r) + self.G = np.zeros((2*r, r)) + self.G[r:, :r] = np.eye(r) + self.Q = np.eye(2*r) if len(Q) == 0 else Q # equal weighting + self.P = riccati.solve_cts_riccati_schur( + self.F, self.G, self.Q, np.eye(r)) # 2r, 2r + + def __call__(self, x, t, fx=None, gx=None, dfxdx=None, **kwargs): + # CLF + y = self.C.dot(x) - self.d + y_dot = self.C_dot.dot(x) + eta = np.hstack((y, y_dot)) + V = eta.dot(self.P).dot(eta) + PF = self.P.dot(self.F) + LfV = eta.dot((PF.T + PF).dot(eta)) + LgV = 2*eta.dot(self.P).dot(self.G) + phi_zero = LfV + self.eps_rate * V + phi_one = LgV + # input constraints + A = self.C.dot(dfxdx).dot(gx) + # at minimum need r <= m,n for right inverse, so u is defined + Ainv = np.linalg.pinv(A) + Lf = self.C.dot(dfxdx).dot(fx) + AinvLf = Ainv.dot(Lf) + # solve the QP, writing constraints as Ac z <= bc + r = y.size + z0 = np.zeros((r+1,)) # z = [mu, delta] + hess_vec = np.ones((r+1,)) + hess_vec[-1] = self.c_delta + Ac = np.vstack((phi_one, Ainv, -Ainv)) + Ac = np.hstack((Ac, 0 * Ac[:, [0]])) # for delta + Ac[0, -1] = -1 + bc = np.hstack((-phi_zero, self.u_max + AinvLf, -self.u_min - AinvLf)) + linear_constraint = opt.LinearConstraint(Ac, bc - np.inf, bc) + res = opt.minimize( + lambda z: 0.5 * (self.c_delta * z[-1]**2 + np.linalg.norm(z[:-1])**2), + z0, method='trust-constr', + jac=lambda z, *args: hess_vec * z, + hessp=lambda x, p, *args: hess_vec * p, + constraints=[linear_constraint, ], + ) + z_star = res.x + mu_star = z_star[:-1] + u_star = Ainv.dot(-Lf + mu_star) + return u_star + + +def make_C_matrix(select_inds, m): + """ + Build C matrix for common choices of y = Cx - d where + y is a selection of x's values (such as position) and + d = y_desired (so Cx - d = 0 implies we are at the desired position) + :param select_inds: list of indices of state x + :param m: dimension of state + :return: r, n | C matrix + """ + r = len(select_inds) + C = np.zeros((r, m)) + C[np.arange(r), select_inds] = 1. + return C diff --git a/controls/sdre.py b/controls/sdre.py index 69a2de7..77256b6 100644 --- a/controls/sdre.py +++ b/controls/sdre.py @@ -19,7 +19,7 @@ J. R. Cloutier, "State-dependent riccati equation techniques: an overview," in Proceedings of the American Control Conference, 1997, pp. 932-936. """ import numpy as np -from scipy.linalg import schur +from misc import riccati class SdreController(object): @@ -38,25 +38,7 @@ class SdreController(object): def __call__(self, x, t, A=(), B=(), **kwargs): Rinv = np.linalg.inv(self.R) - P = solve_cts_riccati_schur(A, B, self.Q, Rinv) + P = riccati.solve_cts_riccati_schur(A, B, self.Q, Rinv) u = -Rinv.dot(B.T.dot(P.dot(x - self.x_goal))) return u - -def solve_cts_riccati_schur(A, B, Q, Rinv): - """ - Solve via Schur decomposition. - :param A: - :param B: - :param Q: - :param Rinv: - :return: - """ - m12 = -B.dot(Rinv).dot(B.T) - M = np.block([[A, m12], [-Q, -A.T]]) - T, U = schur(M, sort='lhp')[:2] - n = A.shape[0] - U11 = U.T[:n, :n] - U12 = U.T[:n, n:] - P = U12.T.dot(np.linalg.inv(U11).T) - return P diff --git a/controls/stepping.py b/controls/stepping.py index 5c7e05b..a34a946 100644 --- a/controls/stepping.py +++ b/controls/stepping.py @@ -20,8 +20,8 @@ def solve_discrete_difference_eqns(discrete_state_model, x0, u_fcn, DT, T): u = np.zeros((n, t.size)) x[:, 0] = x0.copy() for i in range(0, t.size-1): - discrete_state_model.update_AB(x[:, i]) - u[:, i] = u_fcn(x[:, i], i*DT) + operators = discrete_state_model.get_process_operators(x[:, i]) + u[:, i] = u_fcn(x[:, i], i*DT, **operators) x[:, i+1] = discrete_state_model.step(x[:, i], u[:, i]) return x, u @@ -45,10 +45,8 @@ def solve_cts_eqns(cts_state_model, x0, u_fcn, DT, T, cts_sim_model=None): u = np.zeros((n, t.size)) x[:, 0] = x0.copy() for i in range(0, t.size - 1): - cts_state_model.update_AB(x[:, i]) - cts_sim_model.update_AB(x[:, i]) - A, B = cts_state_model.get_AB() - u[:, i] = u_fcn(x[:, i], i * DT, A=A, B=B) + operators = cts_state_model.get_process_operators(x[:, i]) + u[:, i] = u_fcn(x[:, i], i * DT, **operators) t_span = (i*DT, (i+1 + 0.5)*DT) t_eval = (i+1)*DT, sol = itg.solve_ivp( diff --git a/controls/time_based.py b/controls/time_based.py index d29e3ba..1cb4db2 100644 --- a/controls/time_based.py +++ b/controls/time_based.py @@ -4,19 +4,20 @@ import numpy as np def make_control_signal(time_list, u_list): """ - :param time_list: [t1, t2, ..., tn] | times specifying intervals for controls + :param time_list: [t1, t2, ..., tk] | times specifying intervals for controls - time assumed to start at 0, first interval being t \in [0, t1) - after T, u = 0 is supplied - :param u_list: m, n | ith column is control corresponding to ith time interval + :param u_list: n, k | ith column is control corresponding to ith time interval :return: + u_fcn: (x, t, **kwargs) -> n, | control input """ n = len(time_list) assert u_list.shape[1] == n - def u(x, t): + def u_fcn(x, t, **kwargs): if t < 0 or time_list[-1] <= t: return 0 * u_list[:, 0] i = np.digitize(t, time_list) return u_list[:, i] - return u + return u_fcn diff --git a/examples/display_control_affine_clf.py b/examples/display_control_affine_clf.py new file mode 100644 index 0000000..d3df04a --- /dev/null +++ b/examples/display_control_affine_clf.py @@ -0,0 +1,77 @@ +import numpy as np +from rigid_body_models import boat +from state_space_models import empirical_drag +from controls import stepping +from controls import control_affine_clf +from vis import vis_mounted_thruster_model + + +def main_clf_rd1(): + DT = 0.1 + boat_model = boat.DiamondMountedThrusters() + cts_sim_model = empirical_drag.EmpiricalDragCts( + boat_model, + np.array([.1, .5]) * 1., + np.array([.5, .1]) * 1., + ) + x0 = np.array([0., 0, 1, 3, np.pi/2, np.pi/4 * 1]) + x1 = np.array([0, 0, 0, 0, -np.pi/2, 0]) + m = x0.size + selection_inds = [2, 3, 5] + C = control_affine_clf.make_C_matrix(selection_inds, m) + eps_rate = 3. + min_norm_controller = control_affine_clf.MinNormRd1ClfController(eps_rate) + min_norm_controller.set_setpoint(C, x1[selection_inds]) + T = 30 + x, u = stepping.solve_cts_eqns( + cts_sim_model, x0, min_norm_controller, DT, T, + cts_sim_model=cts_sim_model, + ) + np.set_printoptions(suppress=True) + print(x.T.round(2)) + print(u.T.round(2)) + vis_mounted_thruster_model.loop_sim( + boat_model, x.T[:, [0, 1]], x.T[:, 4], u.T, DT) + + +def main_clf_rd2(): + DT = 0.1 + boat_model = boat.DiamondMountedThrusters() + cts_sim_model = empirical_drag.EmpiricalDragCts( + boat_model, + np.array([.1, .5]) * 1., + np.array([.5, .1]) * 1., + ) + x0 = np.array([0., 0, 1, 2, np.pi/2, np.pi/4 * 1]) + x1 = np.array([0, 0, 0, 0, -np.pi/2, 0]) + + eps_rate = 1. + c_delta = 1. + n = boat_model.f.shape[1] + u_min = np.ones((n,)) * -2. + u_max = np.ones((n,)) * 2. + min_norm_controller = control_affine_clf.MinNormRd2ClfController( + eps_rate, c_delta, u_min, u_max, + ) + m = x0.size + pos_selection_inds = [0, 1, 4] + vel_selection_inds = [2, 3, 5] + C = control_affine_clf.make_C_matrix(pos_selection_inds, m) + C_dot = control_affine_clf.make_C_matrix(vel_selection_inds, m) + min_norm_controller.set_setpoint(C, x1[pos_selection_inds], C_dot) + + T = 10 + x, u = stepping.solve_cts_eqns( + cts_sim_model, x0, min_norm_controller, DT, T, + cts_sim_model=cts_sim_model, + ) + np.set_printoptions(suppress=True) + print(x.T.round(2)) + print(u.T.round(2)) + vis_mounted_thruster_model.loop_sim( + boat_model, x.T[:, [0, 1]], x.T[:, 4], u.T, DT) + + +if __name__ == '__main__': + # main_clf_rd1() + main_clf_rd2() diff --git a/misc/riccati.py b/misc/riccati.py new file mode 100644 index 0000000..c4c4b11 --- /dev/null +++ b/misc/riccati.py @@ -0,0 +1,21 @@ +import numpy as np +from scipy.linalg import schur + + +def solve_cts_riccati_schur(A, B, Q, Rinv): + """ + Solve via Schur decomposition. + :param A: + :param B: + :param Q: + :param Rinv: + :return: + """ + m12 = -B.dot(Rinv).dot(B.T) + M = np.block([[A, m12], [-Q, -A.T]]) + T, U = schur(M, sort='lhp')[:2] + n = A.shape[0] + U11 = U.T[:n, :n] + U12 = U.T[:n, n:] + P = U12.T.dot(np.linalg.inv(U11).T) + return P diff --git a/state_space_models/empirical_drag.py b/state_space_models/empirical_drag.py index 2aaccdd..49b8888 100644 --- a/state_space_models/empirical_drag.py +++ b/state_space_models/empirical_drag.py @@ -72,27 +72,39 @@ class EmpiricalDragCts(object): self.angular_drag_c = angular_drag_c def step(self, x_t, u_t): - fx, gx = self.get_fx_gx(x_t) + operators = self.get_process_operators(x_t) + fx, gx = operators['fx'], operators['gx'] x_dot = fx + gx.dot(u_t) return x_dot - def update_AB(self, x_t): - B_t = mb.rot_2d(x_t[4]).dot(self.b_part) - self.Bc_t[2:4, :] = B_t - - def get_AB(self): - return self.Ac, self.Bc_t - - def get_fx_gx(self, x_t): - A_t, B_t = self.get_AB() + def get_process_operators(self, x_t): + B_t = self.Bc_t + B_t[2:4, :] = mb.rot_2d(x_t[4]).dot(self.b_part) + A = self.Ac f_drag = np.zeros((self.m,)) + norm_v = np.linalg.norm(x_t[2:4]) f_drag[2:4] = (self.drag_c[0] + - self.drag_c[1] * np.linalg.norm(x_t[2:4])) * x_t[2:4] + self.drag_c[1] * norm_v) * x_t[2:4] + abs_omega = np.abs(x_t[5]) f_drag[5] = (self.angular_drag_c[0] + - self.angular_drag_c[1] * np.abs(x_t[5])) * x_t[5] - fx = A_t.dot(x_t) - f_drag + self.angular_drag_c[1] * abs_omega) * x_t[5] + fx = A.dot(x_t) - f_drag gx = B_t - return fx, gx + + Ca = np.zeros((2, self.m)) + dfxdx_drag = self.drag_c[0] * Ca +\ + self.drag_c[1] * Ca * norm_v +\ + self.drag_c[1] * np.outer(x_t[2:4], x_t[2:4].dot(Ca) / max(norm_v, 1e-8)) + Cb = np.zeros((1, self.m)) + dfxdx_angular_drag = \ + self.angular_drag_c[0] * Cb + \ + self.angular_drag_c[1] * Cb * abs_omega + \ + self.angular_drag_c[1] * x_t[5] * x_t[5] * Cb / max(abs_omega, 1e-8) + dfxdx_all_drag = np.zeros((self.m, self.m)) + dfxdx_all_drag[2:4, :] = dfxdx_drag + dfxdx_all_drag[5, :] = dfxdx_angular_drag + dfxdx = A - dfxdx_all_drag + return dict(A=A, B=B_t, fx=fx, gx=gx, dfxdx=dfxdx) def get_mn(self): return self.m, self.n diff --git a/state_space_models/simple_planar_integrator.py b/state_space_models/simple_planar_integrator.py index 6001560..7af129e 100644 --- a/state_space_models/simple_planar_integrator.py +++ b/state_space_models/simple_planar_integrator.py @@ -48,21 +48,20 @@ class NoDragCts(object): mounted_thruster_model.im def step(self, x_t, u_t): - A_t, B_t = self.get_AB() - x_dot = A_t.dot(x_t) + B_t.dot(u_t) + operators = self.get_process_operators(x_t) + A, B_t = operators['A'], operators['B'] + x_dot = A.dot(x_t) + B_t.dot(u_t) return x_dot - def update_AB(self, x_t): + def get_process_operators(self, x_t): """ - Before taking initial or next step, update to new A_t, B_t matrices + Return operators which may be based on current state :param x_t: - :return: + :return: dict{A, B(x_t)} """ - B_t = mb.rot_2d(x_t[4]).dot(self.b_part) - self.Bc_t[2:4, :] = B_t - - def get_AB(self): - return self.Ac, self.Bc_t + B_t = self.Bc_t + B_t[2:4, :] = mb.rot_2d(x_t[4]).dot(self.b_part) + return dict(A=self.Ac, B=B_t) def get_mn(self): return self.m, self.n @@ -83,15 +82,13 @@ class AccelApproxDragCts(object): self.alpha = alpha def step(self, x_t, u_t): - A_t, B_t = self.get_AB() - x_dot = A_t.dot(x_t) + B_t.dot(u_t) + operators = self.get_process_operators(x_t) + A, B_t = operators['A'], operators['B'] + x_dot = A.dot(x_t) + B_t.dot(u_t) return x_dot - def update_AB(self, x_t): - self.no_drag_cts_model.update_AB(x_t) - - def get_AB(self): - return self.no_drag_cts_model.get_AB() + def get_process_operators(self, x_t): + return self.no_drag_cts_model.get_process_operators(x_t) def get_mn(self): return self.no_drag_cts_model.get_mn() @@ -122,33 +119,21 @@ class ZeroOrderHoldDiscrete(object): self.Bd_t = np.zeros(()) def step(self, x_t, u_t): - x_p = self.Ad_t.dot(x_t) + self.Bd_t.dot(u_t) + operators = self.get_process_operators(x_t) + Ad_t, Bd_t = operators['A'], operators['B'] + x_p = Ad_t.dot(x_t) + Bd_t.dot(u_t) return x_p - def update_AB(self, x_t): - self.cts_model.update_AB(x_t) - Ac_t, Bc_t = self.cts_model.get_AB() + def get_process_operators(self, x_t): + operators = self.cts_model.get_process_operators(x_t) + Ac_t, Bc_t = operators['A'], operators['B'] m = Ac_t.shape[0] M = np.block([[Ac_t, np.eye(m)], [np.zeros_like(Ac_t), np.zeros_like(Ac_t)]]) eM = la.expm(M * self.dt) self.Ad_t = eM[0:m, 0:m] self.Bd_t = eM[0:m, m:].dot(Bc_t) - - def get_AB(self): - return self.Ad_t, self.Bd_t + return dict(A=self.Ad_t, B=self.Bd_t) def get_mn(self): return self.cts_model.get_mn() - - - - - - - - - - - - diff --git a/vis/vis_mounted_thruster_model.py b/vis/vis_mounted_thruster_model.py index 1926eed..3cac623 100644 --- a/vis/vis_mounted_thruster_model.py +++ b/vis/vis_mounted_thruster_model.py @@ -80,7 +80,7 @@ def loop_sim(model, xy, theta, u, dt, **kwargs): draw_model(ax, model, xy[i], theta[i], u[i], artists=artists) ani = animation.FuncAnimation( - fig, draw_fcn, k, fargs=(artists,), interval=50, repeat_delay=500, blit=False) + fig, draw_fcn, k, fargs=(artists,), interval=100, repeat_delay=500, blit=False) plt.show() -- GitLab