diff --git a/python/iLQR_walking_CoM.py b/python/iLQR_walking_CoM.py new file mode 100644 index 0000000000000000000000000000000000000000..43c59230983ce96205f1685763bb70fe46f1f3a1 --- /dev/null +++ b/python/iLQR_walking_CoM.py @@ -0,0 +1,503 @@ +''' + iLQR Problem + batch iLQR applied on a robot system, that is representing walking movement + by considering center of mass (CoM) and the end-effector + + Copyright (c) 2021 Idiap Research Institute, http://www.idiap.ch/ + Written by Adie Niederberger <aniederberger@idiap.ch> + + This file is part of RCFS. + + RCFS is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License version 3 as + published by the Free Software Foundation. + + RCFS is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with RCFS. If not, see <http://www.gnu.org/licenses/>. +''' + + +import numpy as np +import numpy.matlib +import matplotlib as mpl +import matplotlib.pyplot as plt +import matplotlib.patches as patches + + +# Helper functions +# =============================== + +# Logarithmic map for R^2 x S^1 manifold +def logmap(f, f0, bounds_o=False): # f0 --> target + position_error = f[:, :2] - f0[:, :2] # error of (x_current -x_tartget) + orientation_error = np.imag(np.log(np.exp(f0[:, -1] * 1j).conj().T * + np.exp(f[:, -1] * 1j).T)).conj().reshape((-1, 1)) + # oritentation error isn't considered if within the tolerance + if bounds_o: + tolerance = np.pi / 3 + f_b = f_bounds(f[:, -1], bounds=[f0[0, -1] - tolerance, f0[0, -1] + tolerance])[:, np.newaxis] + orientation_error *= np.int64(np.invert(np.isclose(np.abs(f_b), 0, atol=1e-07))) + error = np.hstack((position_error, orientation_error)) + return error # shape(1, 3) columns (0-> x_error, y_error, o_error) + +# Forward kinematics for end-effector (in robot coordinate system) +def fkine0(x, ls): + x = x.T + A = np.tril(np.ones([len(ls), len(ls)])) + f = np.vstack((ls @ np.cos(A @ x), + ls @ np.sin(A @ x), + np.mod(np.sum(x, 0) + np.pi, + 2 * np.pi) - np.pi)) # x1,x2,o + return f.T # returns x,y, o (o--> orientation as single euler) + +# Bounded task space +def f_bounds(q_, **kwargs): + q = q_ + if "bounds" not in kwargs: + bounds_ = [1e-8, 200] + else: + bounds_ = kwargs.get("bounds") + res = ((q - bounds_[0]) * (q < bounds_[0]) + \ + (q - bounds_[1]) * (q > bounds_[1])) + return res + +# reverse the the whole system (joint space) i.e. q_0 -> q_n; q_1 -> q_n-1 +def reverse_system(x): + model["linkLengths"] = model["linkLengths"][::-1] + orien = np.mod(np.sum(x, 0) + np.pi, 2 * np.pi) - np.pi + dim = model["nbVarX"] + 1 + A = np.zeros((dim, dim + 1), float) + diag_v = np.full(shape=dim, fill_value=-1, dtype=np.float64) + diag_v[(0, -1),] = 1 + np.fill_diagonal(np.flipud(A), diag_v) + A[(0, -1), -1] = 1 + x = np.append(x, [orien, np.pi]) + # last entry of x_bar represent the orientation + x_bar = A @ x + return x_bar[:-1] + +# Forward Kinematics for all joints +def fkine00(x, reverse_links=False): + T = np.tril(np.ones([model["nbVarX"], model["nbVarX"]])) + if reverse_links: + T2 = np.tril(np.matlib.repmat(model["linkLengths"][::-1], len(x), 1)) + else: + T2 = np.tril(np.matlib.repmat(model["linkLengths"], len(x), 1)) + f = np.vstack(( + T2 @ np.cos(T @ x), + T2 @ np.sin(T @ x) + )).T + f = np.vstack(( + np.zeros(2), + f + )) + return f + +# Jacobian with analytical computation (for single time step) +def jacob0(x, ls): + T = np.tril(np.ones((len(ls), len(ls)))) + J = np.vstack(( + -np.sin(T @ x).T @ np.diag(ls) @ T, + np.cos(T @ x).T @ np.diag(ls) @ T, + np.ones(len(ls)) + )) + return J + +# Jacobian for the center of mass with analytical computation (for single time step) +def jacob0_com(x): + J_ = np.zeros((model["nbVarF"], model["nbVarX"])) + J_com = J_.copy() + m = model["mass_i"] + + for i in range(len(model["linkLengths"])): + J_i = jacob0(x[:i + 1], model["linkLengths"][:i + 1]) * m[i] + J_[:J_i.shape[0], :J_i.shape[1]] = J_i + J_com += J_ + J_com /= np.sum(np.r_[m, m[0]]) + J_com[-1, :] = 0 # orientation has no impact on the com + return J_com + +# Residual (task space) and Jacobian +def f_reach(x, target=0, bounds_o=False): + f = logmap(fkine0(x, model["linkLengths"]), model["mu"][target, np.newaxis], bounds_o) + J = np.zeros((len(x) * model["nbVarF"], len(x) * model["nbVarX"])) + for t in range(x.shape[0]): + Jtmp = jacob0(x[t], model["linkLengths"]) + if bounds_o: + Jtmp[-1, :] *= np.int64(np.invert(np.isclose(np.abs(f[t, -1]), 0, atol=1e-07))) + J[t * model["nbVarF"]:(t + 1) * model["nbVarF"], t * model["nbVarX"]:(t + 1) * model["nbVarX"]] = Jtmp + return f, J + +# Residual (joint space) and Jacobian for q0 +def f_reach_q0(x): + # Within the tolerance, there's no residual + q_target = np.pi / 2 + q0_error = np.imag(np.log(np.exp(q_target * 1j).conj().T * + np.exp(x[:, 0] * 1j).T)).conj().reshape((-1, 1)) + tolerance = np.pi / 5 + f_b = f_bounds(x[:, 0], bounds=[q_target - tolerance, q_target + tolerance])[:, np.newaxis] + q0_error *= np.int64(np.invert(np.isclose(np.abs(f_b), 0, atol=1e-07))) + error = np.zeros((x.shape[0], model["nbVarF"])) + error[:, -1] = q0_error[:, 0] + J = np.zeros((len(x) * model["nbVarF"], len(x) * model["nbVarX"])) + Jt = np.zeros((model["nbVarF"], model["nbVarX"])) + Jt[-1, 0] = 1 + for t in range(x.shape[0]): + Jtmp = Jt.copy() + Jtmp[-1, :] *= np.int64(np.invert(np.isclose(np.abs(error[t, -1]), 0, atol=1e-07))) + J[t * model["nbVarF"]:(t + 1) * model["nbVarF"], t * model["nbVarX"]:(t + 1) * model["nbVarX"]] = Jtmp + return error, J + +# Residual (of com) and its Jacobian +def f_reach_com(x, target=0, use_mu_ee=False): + # target ensures to use current target in the array defined in param.mu + x = x[np.newaxis, :] if x.ndim == 1 else x + n = int(x.size / model["nbVarX"]) + f_t = model["mu"][target, np.newaxis].copy() + fcom_val = f_com(x) + f_b = f_bounds(fcom_val[:, 1], bounds=[0, 200]) + if use_mu_ee: + f = (1) * (np.c_[fcom_val, np.full((n, 1), 0)] - f_t) + else: + f = (1) * (np.c_[fcom_val, np.full((n, 1), 0)] - model["mu_com"][target, np.newaxis]) + f[:, 1] = f_b.copy() + f[:, -1] *= 0 # ensure orientation error is zero + J = np.zeros((len(x) * model["nbVarF"], len(x) * model["nbVarX"])) + for t in range(x.shape[0]): + Jtmp = jacob0_com(x[t]) + Jtmp[1, :] *= np.int64(np.invert(np.isclose(np.abs(f_b[t]), 0, atol=1e-07))) + J[t * model["nbVarF"]:(t + 1) * model["nbVarF"], t * model["nbVarX"]:(t + 1) * model["nbVarX"]] = Jtmp + return f, J + +# Center of Mass computation +def f_com(x, onedim=False): + x = x[np.newaxis, :] if x.ndim == 1 else x + com = np.array([fkine00(np.squeeze(x[i]))[1:].T @ model["linkLengths"] / + np.sum(np.r_[model["linkLengths"], model["linkLengths"][0]]) + for i in range(x.shape[0])]) + # if the com is calculated for just one configuration, return the array in a one dimension format + if onedim: + com = np.squeeze(com, 0) + return com + +# Residual (for all articualtions, if one of them is below zero) and its Jacobian +def f_reach_c2(x, target=0): + x = x[np.newaxis, :] if x.ndim == 1 else x + J_out = np.empty((0, len(x) * model["nbVarX"]), int) + f_out = np.empty((0, model["nbVarF"]), int) + f_values = np.array([fkine00(np.squeeze(x[i])) for i in range(x.shape[0])]) + for k in range(1, model["nbVarX"] + 1): + f_y = f_values[:, k, 1] + f = np.zeros((model["nbData"], model["nbVarF"])) + f_y = f_bounds(f_y, bounds=[-0.04, 200]) + f[:, 1] = f_y + J_c2 = np.zeros((len(x) * model["nbVarF"], len(x) * model["nbVarX"])) + J_ = np.zeros((model["nbVarF"], model["nbVarX"])) + for t in range(x.shape[0]): + J_i = jacob0(x[t, :k], model["linkLengths"][:k]) + J_[1, :J_i.shape[1]] = J_i[1] * int(np.invert(np.isclose(np.abs(f_y[t]), 0, atol=1e-07))) + J_c2[t * model["nbVarF"]:(t + 1) * model["nbVarF"], t * model["nbVarX"]:(t + 1) * model["nbVarX"]] = J_ + # stack rowwise + J_out = np.append(J_out, J_c2, axis=0) + f_out = np.append(f_out, f, axis=0) + return f_out, J_out + +# shift an array by a given input (x,y) +def shift(array, to_shift): + one_dim = False + if array.ndim == 1: + one_dim = True + array_ = array.copy() + array_ = array_[np.newaxis, :] + else: + array_ = array.copy() + array_[:, :2] += to_shift + if one_dim: + array_ = np.squeeze(array_, axis=0) + + return array_ + +# Calculate the range for plot +# apply this function on each row +def expand_range(x): + l_tot = np.linalg.norm(x, 1) + cnt = x[0] - l_tot / 2 + expand_fct = 1.4 + return np.array([cnt - (l_tot * expand_fct / 2), cnt + (l_tot * expand_fct / 2)]) + + + # General model parameters +# =============================== +model = { +"dt":1e-2, +"nbData":30, +"nbIter":30, +"nbPoints":1, +"nbVarX":9, +"nbVarU":9, +"nbVarF":3, +"nbFootSteps":3, +"linkLengths":[1.8,2., 2., 2., 2., 2., 2., 2., 1.8], +"Q_track":1e4, +"Q_com":1e1, +"r": 1e0 *2. # before this was 10 and lower it was set to 2.1 +} +model["mass_i"] = [i*1 for i in model["linkLengths"]] + +# Task parameters +# =============================== + +# Targets +model["mu"] = np.asarray([ +[-2.,0.0,-np.pi/2], +[-2., 0.0,-np.pi/2], + [-2.,0.0, -np.pi/2]])# x , y , orientation +# further target: [4,-1,0] not direct reachable() + +# target of the CoM during first phase of movement +model["mu_com"] = np.asarray([ +[0.0,0.0,0.0], +[0.0,0.0,0.0], + [0.0,0.0,0.0]]) + +# Regularization matrix +R = np.identity( (model["nbData"]-1) * model["nbVarU"] ) * model["r"] + +# System parameters +# =============================== + +# Time occurence of viapoints +tl = np.linspace(0,model["nbData"],model["nbPoints"]+1) +tl = np.rint(tl[1:]).astype(np.int64)-1 +idx = np.array([ i + np.arange(0,model["nbVarX"],1) for i in (tl* model["nbVarX"])]) + +u = np.zeros( model["nbVarU"] * (model["nbData"]-1) ) # Initial control command +x0 = np.array( [np.pi/10 , np.pi/9 , np.pi/9, np.pi/9, np.pi/9,np.pi/9,np.pi/9, np.pi/9, np.pi/9] ) # Initial state (in joint space) +# Transfer matrices (for linear system as single integrator) +Su0 = np.vstack([np.zeros((model["nbVarX"], model["nbVarX"]*(model["nbData"]-1))), +np.tril(np.kron(np.ones((model["nbData"]-1, model["nbData"]-1)), np.eye(model["nbVarX"])*model["dt"]))]) +Sx0 = np.kron( np.ones(model["nbData"]) , np.identity(model["nbVarX"]) ).T +Su = Su0[idx.flatten()] # We remove the lines that are out of interest + +init_setup = {"u": u, + "x0": x0, + "Su0": Su0, + "Sx0": Sx0, + "Su" : Su, + "R" : R + } + +# function for one ilqr iteration +def iLQR(init_setup, step): + u = init_setup["u"] + x0 = init_setup["x0"] + Su0 = init_setup["Su0"] + Sx0 = init_setup["Sx0"] + Su = init_setup["Su"] + R = init_setup["R"] + tl_step = 11 + t_olap = 6 # overlap of part in trajectory, while com at init pos and ee-target reached + # (0 => no overlap + Sut_a = Su0[:model["nbVarX"] * (tl_step + t_olap), :] + + for i in range(model["nbIter"]): + x = Su0 @ u + Sx0 @ x0 # x (150, ) + x = x.reshape((model["nbData"], model["nbVarX"])) + f_pos0, J_pos0 = f_reach(x[tl_step:tl_step + t_olap], target=step, bounds_o=True) + f_pos, J_pos = f_reach(x, target=step, bounds_o=True) + f_pos[:f_pos0.shape[0], :f_pos0.shape[1]] = f_pos0 + J_pos[:J_pos0.shape[0], :J_pos0.shape[1]] = J_pos0 + + f_com0, J_com0 = f_reach_com(x[:tl_step + t_olap], target=step, use_mu_ee=False) + f_com1, J_com1 = f_reach_com(x[-1], target=step, use_mu_ee=True) + f_c2, J_c2 = f_reach_c2(x, step) + f_q0, J_q0 = f_reach_q0(x) + Qq0 = 1e3 * .3 + Q_com = np.diag(np.array([model["Q_com"] * 10, model["Q_com"] * 0.01, 0])) + Q_com_ = np.kron(np.eye(tl_step + t_olap), Q_com) + Q_com *= 10 + + Q_bounds = np.diag(np.array([0, model["Q_bounds"] * 1, 0])) + # same as np.kron() + Q_bounds_ = np.einsum('ij,kl->ikjl', np.eye(model["nbVarX"] * model["nbData"]), Q_bounds, \ + ).reshape(-1, (model["nbVarX"]) * model["nbData"] * model["nbVarF"]) + Q_q0 = np.kron(np.eye(model["nbData"]), np.diag(np.array([0, 0, 1]))) * Qq0 + + Q_pos = np.diag(np.array([model["Q_track"], model["Q_track"] , model["Q_track"] ])) + Q_pos_ = np.kron(np.eye(model["nbData"] - tl_step), Q_pos) + # When the target is reached, cost on orientation are higher + Q_pos_[:Q_pos.shape[0], :Q_pos.shape[1]] = np.eye(model["nbVarF"]) * model["Q_track"] * 1e2 + + Q_pos_ = np.kron(np.eye(model["nbData"]), np.eye(model["nbVarF"]) * model["Q_track"]) + Q_pos_[-3:] *= 1e1 + + du = np.linalg.inv(Su0.T @ J_pos.T @ Q_pos_ @ J_pos @ Su0 + + Sut_a.T @ J_com0.T @ Q_com_ @ J_com0 @ Sut_a + + Su.T @ J_com1.T @ Q_com @ J_com1 @ Su + + Su0.T @ (J_c2.T @ Q_bounds_) @ J_c2 @ Su0 + + Su0.T @ J_q0.T @ Q_q0 @ J_q0 @ Su0 + R) @ \ + (- Su0.T @ J_pos.T @ Q_pos_ @ f_pos.flatten() \ + - Su0.T @ J_c2.T @ Q_bounds_ @ f_c2.flatten() \ + - Sut_a.T @ J_com0.T @ Q_com_ @ f_com0.flatten() \ + - Su0.T @ J_q0.T @ Q_q0 @ f_q0.flatten() \ + - Su.T @ J_com1.T @ Q_com @ f_com1.flatten() - R@u) + + alpha = 1 + cost_bounds = np.matrix(f_c2.flatten() @ Q_bounds_) @ np.matrix(f_c2.flatten()).T + cost_com = (np.matrix(f_com0.flatten() @ Q_com_) @ np.matrix(f_com0.flatten()).T) + cost_com += (np.matrix(f_com1.flatten() @ Q_com) @ np.matrix(f_com1.flatten()).T) + cost_track = np.matrix(f_pos.flatten() @ Q_pos_).flatten() @ np.matrix(f_pos.flatten()).T + cost_q0 = np.matrix(f_q0.flatten() @ Q_q0).flatten() @ np.matrix(f_q0.flatten()).T + + cost0 = cost_track + cost_com + \ + cost_bounds + cost_q0 + np.linalg.norm(u) * model["r"] + + while True: + utmp = u + du * alpha + xtmp = Su0 @ utmp + Sx0 @ x0 + xtmp = xtmp.reshape((model["nbData"], model["nbVarX"])) + + fcomtmp0, _ = f_reach_com(xtmp[:tl_step + t_olap], target=step, use_mu_ee=False) + fcomtmp1, _ = f_reach_com(xtmp[-1], target=step, use_mu_ee=True) + fpostmp0, _ = f_reach(xtmp[tl_step:tl_step + t_olap], target=step, bounds_o=False) + fpostmp, _ = f_reach(xtmp, target=step, bounds_o=True) + fpostmp[:fpostmp0.shape[0], :fpostmp0.shape[1]] = fpostmp0 + fc2tmp, _ = f_reach_c2(xtmp, step) + fq0tmp, _ = f_reach_q0(xtmp[tuple(list(range(tl_step, tl_step + t_olap)) + [-1]), :]) + fq0tmp, _ = f_reach_q0(xtmp) + + cbtmp = (np.matrix(fc2tmp.flatten() @ Q_bounds_) @ np.matrix(fc2tmp.flatten()).T).item() + cposttmp = (np.matrix(fpostmp.flatten() @ Q_pos_).flatten() @ np.matrix(fpostmp.flatten()).T).item() + ccomtemp = (np.matrix(fcomtmp0.flatten() @ Q_com_) @ np.matrix( + fcomtmp0.flatten()).T).item() + ccomtemp += (np.matrix(fcomtmp1.flatten() @ Q_com) @ np.matrix( + fcomtmp1.flatten()).T).item() + cq0tmp = (np.matrix(fq0tmp.flatten() @ Q_q0).flatten() @ np.matrix(fq0tmp.flatten()).T).item() + cost = cposttmp + ccomtemp + np.linalg.norm(utmp) * model["r"] + cbtmp + cq0tmp + + if cost < cost0 or alpha < 1e-3 * 0.5: + u = utmp + print("Iteration {}, cost: {}, alpha: {}".format(i, cost, alpha)) + break + alpha /= 2 + + if np.linalg.norm(alpha * du) < 1e-2 or cost > cost0 and alpha < 1e-3 * 0.5: # Early stop condition + break + return x + +# Main program +# =============================== + +coord_shift =[] +x_track = [] + +for t in range(model["nbFootSteps"]): + print("**Step: {}**".format(t+1)) + #whole path, because we need to consider the bounds (no articulations below 0) + #model['r'] = 1e0*2.1 + model["Q_com"]=1e1*1.9 + model["Q_bounds"] = 1e4*2 + model["Q_track"] = 1e2 *1.1 + x = iLQR(init_setup = init_setup, step = t) + x_track.append(x) + coord_shift.append(fkine0(x[-1],model["linkLengths"])) + print("------------------ reverse System") + x0 = reverse_system(x[-1]) + init_setup["x0"] = x0 + +# we have odd number of steps and change the link position back to the initial position for reconstruction +model["linkLengths"] = model["linkLengths"][::-1] +model["nbData"] = int(model["nbData"] * .5) + +coord_shift0 = np.cumsum(np.array(coord_shift).reshape((-1,3)),0) +f_steps = np.zeros((model["nbFootSteps"], model["nbData"] * 2, 3)) +f_steps_ = np.zeros((model["nbFootSteps"], model["nbData"] * 2, model["nbVarX"])) +fT_steps = np.zeros((model["nbFootSteps"] + 1, model["nbVarX"] + 1, 2)) +fT_all = np.zeros((model["nbFootSteps"], model["nbData"] * 2, model["nbVarX"] + 1, 2)) ##(3, 50, 4, 2) +coms = np.zeros((model["nbFootSteps"] + 1, 2)) +f_coms = np.zeros((model["nbFootSteps"], model["nbData"] * 2, 2)) + +# i= 2,1,0 +for i in range(len(x_track) - 1, -1, -1): + f_t = fkine0(x_track[i], model["linkLengths"]) + fT_t = fkine00(x_track[i][-1]) + fT_0to50 = np.array([fkine00(x_track[i][k]) for k in range(x_track[i].shape[0])]) + shape_ = fT_0to50.shape + f_com_t = f_com(x_track[i][-1])[np.newaxis, :] + fT_com_T = f_com(x_track[i]) + + if i == 0: + fT_t2 = fkine00(x_track[i][-1]) + if i != 0: + f_steps[i, ...] = shift(f_t, coord_shift0[i - 1, :2]) + fT_all[i, ...] = shift(fT_0to50.reshape(-1, 2), coord_shift0[i - 1, :2]).reshape(shape_) + fT_steps[i + 1, ...] = shift(fT_t, coord_shift0[i - 1, :2]) + coms[i + 1, ...] = shift(f_com_t, coord_shift0[i - 1, :2]) + f_coms[i, ...] = shift(fT_com_T, coord_shift0[i - 1, :2]) + else: + f_steps[i, ...] = f_t + fT_steps[i, ...] = fT_t + fT_all[i, ...] = fT_0to50 + fT_steps[i + 1, ...] = fkine00(x_track[i][-1]) + fT_steps[i, ...] = fkine00(x_track[i][0]) + coms[i + 1, ...] = f_com_t + coms[i, ...] = f_com(x_track[i][0])[np.newaxis, :] + f_coms[i, ...] = fT_com_T + +f_steps = f_steps[:, :, :2] + +f_max = np.amax(f_steps.reshape((model["nbData"] * model["nbFootSteps"]), -1), axis=0) +f_min = np.amin(f_steps.reshape((model["nbData"] * model["nbFootSteps"]), -1), axis=0) +f_max = np.amax(fT_all.reshape(-1, 2), axis=0) +f_min = np.amin(fT_all.reshape(-1, 2), axis=0) + +bounds = np.c_[f_max, f_min] # column0 -> max_val + + + + +bounds = np.apply_along_axis(expand_range, 1, bounds) +fT_all = fT_all.reshape(-1, model["nbVarX"]+1,2) +f_coms = f_coms.reshape(-1,2) +f_coms_ground = f_coms.copy() +f_coms_ground[...,1] = 0 + + + +fig = plt.figure(figsize = (8,6)) +ax = fig.add_subplot(1, 1, 1) +ax.set_xlabel('x [m]') +ax.set_ylabel('y [m]') +ax.set_xlim(bounds[0]) +ax.set_ylim(bounds[1]) + +ax.set_aspect('equal') +plt.axis('off') +plt.gca().set_aspect('equal', adjustable='box') + +links, = ax.plot( fT_steps[0,:,0] , fT_steps[0,:,1],c='black',linewidth=4,alpha=.6) +coms = ax.scatter(f_coms[0,0] , f_coms[0,1], s=100,marker="o",c="lightblue") +coms_ground = ax.scatter(f_coms[0,0] , 0, s=100,marker="o",c='black', alpha = .2) +ax = plt.gca() + +plt.plot( fT_steps[0,:,0] , fT_steps[0,:,1],c='black',linewidth=5,alpha=.2) + +# update the animation +def update(n): + # update the position of each link end. + links.set_data(fT_all[n,:,0] , fT_all[n,:,1]) + coms.set_offsets(f_coms[n]) + coms_ground.set_offsets(f_coms_ground[n]) + return links, coms_ground, coms, + +# Create animation object +ani = mpl.animation.FuncAnimation(fig, update, frames=model["nbData"]*model["nbFootSteps"]*2, + interval=120, blit=True, repeat = True) +ani.running = True +ani.direction = +1 +plt.show() \ No newline at end of file