diff --git a/mavion/mavion.py b/mavion/mavion.py new file mode 100644 index 0000000000000000000000000000000000000000..65326433f6b440945254606b0f3c974515a60895 --- /dev/null +++ b/mavion/mavion.py @@ -0,0 +1,433 @@ +import matplotlib.pyplot as plt +import numpy as np +import gymnasium as gym +from gymnasium.envs.classic_control import utils +import yaml +from scipy.optimize import fsolve +from scipy.integrate import solve_ivp + + +deg2rad = 2 * np.pi / 360 + +def hat(x): + mat = np.array([[0, -x[2], x[1]], [x[2], 0, -x[0]], [-x[1], x[0], 0]]) + return mat + +def quat2dcm(q): + dcm = np.array( + [ + [ + q[0] ** 2 + q[1] ** 2 - q[2] ** 2 - q[3] ** 2, + 2 * (q[1] * q[2] + q[0] * q[3]), + 2 * (q[1] * q[3] - q[0] * q[2]), + ], + [ + 2 * (q[1] * q[2] - q[0] * q[3]), + q[0] ** 2 - q[1] ** 2 + q[2] ** 2 - q[3] ** 2, + 2 * (q[2] * q[3] - q[0] * q[1]), + ], + [ + 2 * (q[1] * q[3] + q[0] * q[2]), + 2 * (q[2] * q[3] - q[0] * q[1]), + q[0] ** 2 - q[1] ** 2 - q[2] ** 2 + q[3] ** 2, + ], + ] + ) + return dcm + +def eul2quat(a): + c = np.cos(a/2) + s = np.sin(a/2) + q = np.array( + [ + c[0] * c[1] * c[2] + s[0] * s[1] * s[2], + -c[0] * s[1] * s[2] + c[0] * c[1] * s[2], + c[0] * s[1] * c[2] + s[0] * c[1] * s[2], + c[0] * c[1] * s[2] - s[0] * s[1] * c[2], + ] + ) + return q + +def quat2eul(q): + phi = np.arctan2(2 * (q[0] * q[1] + q[2] * q[3]), 1 - 2 * (q[1] * q[1] + q[2] * q[2])) + theta = np.arcsin(2 * (q[0] * q[2] - q[1] * q[3])) + psi = np.arctan2(2 * (q[0] * q[3] + q[1] * q[2]), 1 - 2 * (q[3] * q[3] + q[2] * q[2])) + return np.array([phi, theta, psi]) + +def eul2dcm(a): + Rx = np.array([[1, 0, 0], + [0, np.cos(a[0]), np.sin(a[0])], + [0, -np.sin(a[0]), np.cos(a[0])]]) + + Ry = np.array([[np.cos(a[1]), 0, -np.sin(a[1])], + [0, 1, 0], + [np.sin(a[1]), 0, np.cos(a[1])]]) + + Rz = np.array([[np.cos(a[2]), np.sin(a[2]), 0], + [-np.sin(a[2]), np.cos(a[2]), 0], + [0, 0, 1]]) + + return Rx @ Ry @ Rz + + +def s2q(x): + quat = eul2quat(x[3:6]) + x = np.concatenate([x[0:3], quat, x[6:12]]) + return x + +def q2s(x): + eul = quat2eul(x[3:7]) + x = np.concatenate([x[0:3], eul, x[7:13]]) + return x + + +class Mavion: + + def __init__(self, file="mavion.yaml"): + stream = open(file, "r") + drone = yaml.load(stream, Loader=yaml.Loader) + + self.NAME = drone['NAME'] + self.RHO = drone['RHO'] + self.G = drone['G'] + self.MASS = drone['MASS'] + self.CHORD = drone['CHORD'] + self.WINGSPAN = drone['WINGSPAN'] + self.WET_SURFACE = drone['WET_SURFACE'] + self.DRY_SURFACE = drone['DRY_SURFACE'] + self.PROP_RADIUS = drone['PROP_RADIUS'] + + self.Cd0 = 0.1 + self.Cy0 = 0.1 + self.dR = -0.1 * self.CHORD + self.PHI_fv = np.diag([self.Cd0, self.Cy0, (2 * np.pi + self.Cd0)]) + self.PHI_mv = np.array( + [ + [0, 0, 0], + [0, 0, -1 / self.CHORD * self.dR * (2 * np.pi + self.Cd0)], + [0, 1 / self.WINGSPAN * self.dR * self.Cy0, 0], + ] + ) + self.PHI_mw = 1 / 2 * np.diag([0.5, 0.5, 0.5]) + self.PHI = np.block([[self.PHI_fv, self.PHI_mv], [self.PHI_mv, self.PHI_mw]]) + self.PHI_n = drone["PHI_n"] + + # acording to back-of-the-envelope computations + self.INERTIA = np.diag(drone["INERTIA"]) + + # geometric parameters + self.P_P1_CG = np.array(drone["P_P1_CG"]) * self.CHORD + self.P_P2_CG = np.array(drone["P_P2_CG"]) * self.CHORD + self.P_A1_CG = np.array(drone["P_A1_CG"]) * self.CHORD + self.P_A2_CG = np.array(drone["P_A2_CG"]) * self.CHORD + + self.ELEVON_MEFFICIENCY = np.array(drone['ELEVON_MEFFICIENCY']) + self.ELEVON_FEFFICIENCY = np.array(drone['ELEVON_FEFFICIENCY']) + self.PROP_KP = drone['PROP_KP'] + self.PROP_KM = drone['PROP_KM'] + self.INERTIA_PROP_X = drone['INERTIA_PROP_X'] + self.INERTIA_PROP_N = drone['INERTIA_PROP_N'] + + self.pos = np.zeros(3) + self.vel = np.zeros(3) + self.ang = np.zeros(3) + self.rot = np.zeros(3) + self.quat = eul2quat(self.ang) + + def thrust(self, dx): + """ + THRUST thrust forces and moments modeling + this function computes the propeller thrust by means of simple + prop formulas commonly applied in quadrotor literature. + + INPUTS: + motor rot. : dx in R(1x1) + + required self specs: + |---- PROP_KP in R(1x1) + |---- PROP_KM in R(1x1) + + OUTPUTS: + Prop force : T (N) (body axis) in R(3x1) + Prop moment: N (Nm) (body axis) in R(3x1) + + vl: vehicle velocity in NED axis (m/s) [3x1 Real] + wb: vehicle angular velocity in body axis (rad/s) [3x1 Real] + q: quaternion attitude (according to MATLAB convention) [4x1 Real] + + NOTE1: notice that motor rotation's sign depend on which section we are due to + counter-rotating propellers; + NOTE2: elevon sign convention is positive pictch-up deflections. + NOTE3: gyroscopic effects are implemented in the caller function! + + refer to [1] for further information. + + REFERENCES: + [1] Lustosa L.R., Defay F., Moschetta J.-M., "The Phi-theory + approach to flight control design of tail-sitter vehicles" + @ http://lustosa-leandro.github.io + + """ + + kp = self.PROP_KP + km = self.PROP_KM + + # prop forces computation / notice that negative thrust is not implemented + T = kp * dx**2 * np.array([1, 0, 0]) + + # prop moments computation + N = np.sign(dx) * km * dx**2 * np.array([1, 0, 0]) + + return [T, N] + + def aero(self, quat, vel, rot, T, de, w): + """ + AERO aerodynamic forces and moments computation (in a wing section!) + this function computes the aerodynamic forces and moments in body axis + in view of Phi-theory in a wing section. it includes propwash effects + due to thrust. + NOTE2: elevon sign convention is positive pictch-up deflections. + """ + + # data extraction from self struct + PHI_fv = self.PHI_fv + PHI_mv = self.PHI_mv + PHI_mw = self.PHI_mw + phi_n = self.PHI_n + RHO = self.RHO + Swet = self.WET_SURFACE + Sdry = self.DRY_SURFACE + chord = self.CHORD + ws = self.WINGSPAN + Prop_R = self.PROP_RADIUS + Thetam = self.ELEVON_MEFFICIENCY + Thetaf = self.ELEVON_FEFFICIENCY + + # derivative data + Sp = np.pi * Prop_R**2 + + # DCM computation + D = quat2dcm(quat) + + # freestream velocity computation in body frame + vinf = D @ (vel - w) + + # computation of total wing section area + S = Swet + Sdry + + # computation of chord matrix + B = np.diag([ws, chord, ws]) + + # eta computation + eta = np.sqrt(np.linalg.norm(vinf)**2 + phi_n * np.linalg.norm(B@rot)**2) + + # Force computation + # airfoil contribution + Fb = -1/2*RHO*S*eta * PHI_fv@vinf - 1/2*RHO*S*eta * PHI_mv@B@rot - 1/2*Swet/Sp * PHI_fv@T + # elevon contribution + Fb = Fb + 1/2*RHO*S*eta * PHI_fv@np.cross(de*Thetaf, vinf) \ + + 1/2*RHO*S*eta * PHI_mv@B@np.cross(de*Thetaf, rot) \ + + 1/2*Swet/Sp * PHI_fv@np.cross(de*Thetaf, T) + + # Moment computation + # airfoil contribution + Mb = -1/2*RHO*S*eta * B@PHI_mv@vinf - 1/2*RHO*S*eta * B@PHI_mw@B@rot - 1/2*Swet/Sp * B@PHI_mv@T + # elevon contribution + Mb = Mb + 1/2*RHO*S*eta * B@PHI_mv@np.cross(de*Thetam, vinf) \ + + 1/2*RHO*S*eta * B@PHI_mw@B@np.cross(de*Thetam, rot) \ + + 1/2*Swet/Sp * B@PHI_mv@np.cross(de*Thetam, T) + + return [Fb, Mb] + + def dyn(self, x, u, w): + + G = self.G + MASS = self.MASS + INERTIA = self.INERTIA + + # position of propellers wrt center of gravity + P_P1_CG = self.P_P1_CG + P_P2_CG = self.P_P2_CG + # position of aerodynamic wrenches wrt center of gravity + P_A1_CG = self.P_A1_CG + P_A2_CG = self.P_A2_CG + # propeller blade inertia + INERTIA_PROP_X = self.INERTIA_PROP_X + INERTIA_PROP_N = self.INERTIA_PROP_N + + # state demultiplexing + quat = x[3:7] + vel = x[7:10] + rot = x[10:13] + + # input demultiplexing + dx1 = u[0] + dx2 = u[1] + de1 = u[2] + de2 = u[3] + + # linear velocity derivative + D = quat2dcm(quat) + + [T1, N1] = self.thrust(dx1) + [T2, N2] = self.thrust(dx2) + [A1, M1] = self.aero(quat, vel, rot, T1, de1, w) + [A2, M2] = self.aero(quat, vel, rot, T2, de2, w) + + Fb = T1 + T2 + A1 + A2 + dvdt = np.array([0, 0, G]) + 1/MASS * D.T@Fb + + # angular velocity derivative + Ma = M1 + M2 + np.cross(P_A1_CG, A1) + np.cross(P_A2_CG, A2) + + P = rot[0] + Q = rot[1] + R = rot[2] + Jpx = INERTIA_PROP_X + Jpn = INERTIA_PROP_N + tau1 = N1 - (P + dx1) * (Jpx - Jpn) * np.array([0, R, -Q]) + tau2 = N2 - (P + dx2) * (Jpx - Jpn) * np.array([0, R, -Q]) + Mg = tau1 + tau2 + np.cross(P_P1_CG, T1) + np.cross(P_P2_CG, T2) + + Mb = Ma + Mg + drdt = np.linalg.inv(INERTIA) @ (Mb - np.cross(rot, INERTIA@rot)) + + # kinematic equations + dpdt = vel + + dqdt = np.zeros(4) + dqdt[0] = -1/2 * np.dot(rot, quat[1:4]) + dqdt[1:4] = 1/2 * (quat[0]*rot - np.cross(rot, quat[1:4])) + + return np.concatenate([dpdt, dqdt, dvdt, drdt]) + + def trim(self, vh, vz): + x = np.zeros(13) + x[7] = vh + x[9] = vz + + def func(y): + dx, de, theta = y + eul = np.array([0, theta, 0]) + quat = eul2quat(eul) + x[3:7] = quat + u = np.array([-dx, dx, de, de]) + dx_dt = self.dyn(x, u, np.zeros(3)) + return dx_dt[7], dx_dt[9], dx_dt[11] + + y0 = np.array([100, 0, 0]) + y = fsolve(func, y0) + return y + + def step(self, x, u, w, dt): + func = lambda t, s: self.dyn(s, u, w) + sol = solve_ivp(func, (0, dt), x, method='RK45') + return sol.y.T[-1] + + def sim(self, t_span, x0, fctrl, fwind): + func = lambda t, s: self.dyn(s, fctrl(t, s, w), fwind(t, s)) + sol = solve_ivp(func, t_span, x0, method='RK45', max_step=0.01) + return sol + + +class MavionEnv(gym.Env): + + def __init__(self, mavion=Mavion(), render_mode=None): + + self.tau = 0.02 # seconds between state updates + + # Angle at which to fail the episode + self.pos_threshold = np.array([2.4, 2.4, 2.4]) + self.ang_threshold = np.array([15, 15, 15]) * deg2rad + self.vel_threshold = np.finfo(np.float32).max * np.ones(3) + self.rot_threshold = np.finfo(np.float32).max * np.ones(3) + + high = np.concatenate( + [ + self.pos_threshold * 2, + self.ang_threshold * 2, + self.vel_threshold, + self.rot_threshold + ], + dtype=np.float32, + ) + self.observation_space = gym.spaces.Box(-high, high, dtype=np.float32) + + self.action_space = gym.spaces.Box(0, 1, shape=(4,), dtype=np.float32) + + self.target = None + self.obs = None + self.steps_beyond_terminated = None + + self.mavion = mavion + + def reset(self, seed=None, options=None): + super().reset(seed=seed) + + # Note that if you use custom reset bounds, it may lead to out-of-bound + # state/observations. + low, high = utils.maybe_parse_reset_bounds( + options, -0.05, 0.05 # default low + ) # default high + self.target = np.zeros(12) + self.obs = self.np_random.uniform(low=low, high=high, size=(12,)) + self.state = self.obs + self.target + self.steps_beyond_terminated = None + + return self.obs, {} + + def step(self, action): + x = s2q(self.state) + u = action + w = np.zeros(3) + new_state = self.mavion.step(x, u, w, self.tau) + self.state = q2s(new_state) + terminated = np.array_equal(self.state, self.target) + reward = 1 if terminated else 0 # Binary sparse rewards + self.obs = self.state - self.target + return self.obs, reward, terminated, False, {} + + +if __name__ == "__main__": + mavion = Mavion() + + vh = 5 + vz = 0 + dx, de, theta = mavion.trim(vh, vz) + theta = (theta + np.pi) % (2*np.pi) - np.pi + print(dx, de, theta*57.3) + + pos = np.array([0, 0, -10]) + eul = np.array([0, theta, 0]) + vel = np.array([vh, 0, vz]) + rot = np.array([0, 0, 0]) + st = np.concatenate([pos, eul, vel, rot]) + x = s2q(st) + + u = np.array([-dx, dx, de, de]) + w = np.zeros(3) + + def ctl(t, s, w): return u + def wnd(t, s): return w + + sim = mavion.sim((0, 20), x, ctl, wnd) + + fig, axs = plt.subplots(2, 2, layout='constrained') + ax = axs[0][0] + ax.plot(sim.t, sim.y[0:3].T) + ax = axs[1][0] + ax.plot(sim.t, sim.y[3:7].T) + ax = axs[0][1] + ax.plot(sim.t, sim.y[7:10].T) + ax = axs[1][1] + ax.plot(sim.t, sim.y[10:13].T) + plt.show() + + + # mavion_env = MavionEnv(mavion) + + # st1 = mavion_env.reset()[0] + # print("reset :", st1) + + # mavion_env.state = st + # st2 = mavion_env.step(u)[0] + # print("new state :", st2)