added Python DigitalTwin code
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
124
MAGLEV_DIGITALTWIN_PYTHON/controller.py
Normal file
124
MAGLEV_DIGITALTWIN_PYTHON/controller.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
Decentralized PID controller for maglev system
|
||||||
|
Ported from decentralizedPIDcontroller.m
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class DecentralizedPIDController:
|
||||||
|
"""
|
||||||
|
Decentralized PID controller for quadrotor/maglev control.
|
||||||
|
Controls altitude, roll, and pitch using gap sensor feedback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Persistent variables (maintain state between calls)
|
||||||
|
self.preverror = np.zeros(4)
|
||||||
|
self.cumerror = np.zeros(4)
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Reset persistent variables"""
|
||||||
|
self.preverror = np.zeros(4)
|
||||||
|
self.cumerror = np.zeros(4)
|
||||||
|
|
||||||
|
def control(self, R, S, P):
|
||||||
|
"""
|
||||||
|
Compute control voltages for each yoke.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
R : dict
|
||||||
|
Reference structure with elements:
|
||||||
|
- rIstark : 3-element array of desired CM position at time tk in I frame (meters)
|
||||||
|
- vIstark : 3-element array of desired CM velocity (meters/sec)
|
||||||
|
- aIstark : 3-element array of desired CM acceleration (meters/sec^2)
|
||||||
|
|
||||||
|
S : dict
|
||||||
|
State structure with element:
|
||||||
|
- statek : dict containing:
|
||||||
|
- rI : 3-element position in I frame (meters)
|
||||||
|
- RBI : 3x3 or 9-element direction cosine matrix
|
||||||
|
- vI : 3-element velocity (meters/sec)
|
||||||
|
- omegaB : 3-element angular rate vector in body frame (rad/sec)
|
||||||
|
|
||||||
|
P : dict
|
||||||
|
Parameters structure with elements:
|
||||||
|
- quadParams : QuadParams object
|
||||||
|
- constants : Constants object
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
ea : ndarray, shape (4,)
|
||||||
|
4-element vector with voltages applied to each yoke
|
||||||
|
"""
|
||||||
|
# Extract current state
|
||||||
|
zcg = S['statek']['rI'][2] # z-component of CG position
|
||||||
|
rl = P['quadParams'].sensor_loc
|
||||||
|
|
||||||
|
# Reshape RBI if needed
|
||||||
|
RBI = S['statek']['RBI']
|
||||||
|
if RBI.shape == (9,):
|
||||||
|
RBI = RBI.reshape(3, 3)
|
||||||
|
|
||||||
|
# Calculate gaps at sensor locations
|
||||||
|
gaps = np.abs(zcg) - np.array([0, 0, 1]) @ RBI.T @ rl
|
||||||
|
gaps = gaps.flatten()
|
||||||
|
|
||||||
|
# Controller gains
|
||||||
|
kp = 10000
|
||||||
|
ki = 0
|
||||||
|
kd = 50000
|
||||||
|
|
||||||
|
# Reference z position
|
||||||
|
refz = R['rIstark'][2]
|
||||||
|
meangap = np.mean(gaps)
|
||||||
|
|
||||||
|
# Transform gaps using corner-based sensing
|
||||||
|
# gaps indices: 0=front, 1=right, 2=back, 3=left
|
||||||
|
gaps_transformed = np.array([
|
||||||
|
gaps[0] + gaps[3] - meangap, # gaps(1)+gaps(4)-meangap in MATLAB
|
||||||
|
gaps[0] + gaps[1] - meangap, # gaps(1)+gaps(2)-meangap
|
||||||
|
gaps[2] + gaps[1] - meangap, # gaps(3)+gaps(2)-meangap
|
||||||
|
gaps[2] + gaps[3] - meangap # gaps(3)+gaps(4)-meangap
|
||||||
|
])
|
||||||
|
|
||||||
|
# Calculate error for each yoke
|
||||||
|
err = -refz - gaps_transformed
|
||||||
|
derr = err - self.preverror
|
||||||
|
|
||||||
|
self.preverror = err
|
||||||
|
self.cumerror = self.cumerror + err
|
||||||
|
|
||||||
|
# Calculate desired voltages
|
||||||
|
eadesired = kp * err + derr * kd + ki * self.cumerror
|
||||||
|
|
||||||
|
# Apply saturation
|
||||||
|
s = np.sign(eadesired)
|
||||||
|
maxea = P['quadParams'].maxVoltage * np.ones(4)
|
||||||
|
|
||||||
|
ea = s * np.minimum(np.abs(eadesired), maxea)
|
||||||
|
|
||||||
|
return ea
|
||||||
|
|
||||||
|
|
||||||
|
def decentralized_pid_controller(R, S, P, controller=None):
|
||||||
|
"""
|
||||||
|
Wrapper function to maintain compatibility with MATLAB-style function calls.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
R, S, P : dict
|
||||||
|
See DecentralizedPIDController.control() for details
|
||||||
|
controller : DecentralizedPIDController, optional
|
||||||
|
Controller instance to use. If None, creates a new one (loses state)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
ea : ndarray, shape (4,)
|
||||||
|
4-element vector with voltages applied to each yoke
|
||||||
|
"""
|
||||||
|
if controller is None:
|
||||||
|
controller = DecentralizedPIDController()
|
||||||
|
|
||||||
|
return controller.control(R, S, P)
|
||||||
113
MAGLEV_DIGITALTWIN_PYTHON/dynamics.py
Normal file
113
MAGLEV_DIGITALTWIN_PYTHON/dynamics.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
ODE function for maglev quadrotor dynamics
|
||||||
|
Ported from quadOdeFunctionHF.m
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from utils import cross_product_equivalent, fmag2
|
||||||
|
|
||||||
|
|
||||||
|
def quad_ode_function_hf(t, X, eaVec, distVec, P):
|
||||||
|
"""
|
||||||
|
Ordinary differential equation function that models quadrotor dynamics
|
||||||
|
-- high-fidelity version. For use with scipy's ODE solvers (e.g., solve_ivp).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
t : float
|
||||||
|
Scalar time input, as required by ODE solver format
|
||||||
|
|
||||||
|
X : ndarray, shape (22,)
|
||||||
|
Quad state, arranged as:
|
||||||
|
X = [rI, vI, RBI(flattened), omegaB, currents]
|
||||||
|
|
||||||
|
rI = position vector in I in meters (3 elements)
|
||||||
|
vI = velocity vector wrt I and in I, in meters/sec (3 elements)
|
||||||
|
RBI = attitude matrix from I to B frame, flattened (9 elements)
|
||||||
|
omegaB = angular rate vector of body wrt I, expressed in B, rad/sec (3 elements)
|
||||||
|
currents = vector of yoke currents, in amperes (4 elements)
|
||||||
|
|
||||||
|
eaVec : ndarray, shape (4,)
|
||||||
|
4-element vector of voltages applied to yokes, in volts
|
||||||
|
|
||||||
|
distVec : ndarray, shape (3,)
|
||||||
|
3-element vector of constant disturbance forces acting on quad's
|
||||||
|
center of mass, expressed in Newtons in I
|
||||||
|
|
||||||
|
P : dict
|
||||||
|
Structure with the following elements:
|
||||||
|
- quadParams : QuadParams object
|
||||||
|
- constants : Constants object
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Xdot : ndarray, shape (22,)
|
||||||
|
Time derivative of the input vector X
|
||||||
|
"""
|
||||||
|
yokeR = P['quadParams'].yokeR # in ohms
|
||||||
|
yokeL = P['quadParams'].yokeL # in henries
|
||||||
|
|
||||||
|
# Extract state variables
|
||||||
|
currents = X[18:22] # indices 19:22 in MATLAB (1-indexed) = 18:22 in Python
|
||||||
|
currentsdot = np.zeros(4)
|
||||||
|
|
||||||
|
R = X[6:15].reshape(3, 3) # indices 7:15 in MATLAB = 6:15 in Python
|
||||||
|
|
||||||
|
w = X[15:18] # indices 16:18 in MATLAB = 15:18 in Python
|
||||||
|
wx = cross_product_equivalent(w)
|
||||||
|
rdot = X[3:6] # indices 4:6 in MATLAB = 3:6 in Python
|
||||||
|
|
||||||
|
z = X[2] # index 3 in MATLAB = 2 in Python (zI of cg)
|
||||||
|
|
||||||
|
rl = P['quadParams'].sensor_loc
|
||||||
|
gaps = np.abs(z) - np.array([0, 0, 1]) @ R.T @ rl
|
||||||
|
gaps = gaps.flatten()
|
||||||
|
|
||||||
|
# Calculate magnetic forces
|
||||||
|
Fm = fmag2(currents, gaps)
|
||||||
|
|
||||||
|
# Handle collision with track
|
||||||
|
if np.any(Fm == -1):
|
||||||
|
rdot = rdot.copy()
|
||||||
|
rdot[2] = 0 # rdot(end) in MATLAB
|
||||||
|
Fm = np.zeros(4)
|
||||||
|
|
||||||
|
sumF = np.sum(Fm)
|
||||||
|
|
||||||
|
# Calculate linear acceleration
|
||||||
|
vdot = (np.array([0, 0, -P['quadParams'].m * P['constants'].g]) +
|
||||||
|
np.array([0, 0, sumF]) +
|
||||||
|
distVec) / P['quadParams'].m
|
||||||
|
|
||||||
|
# Calculate rotation rate
|
||||||
|
Rdot = -wx @ R
|
||||||
|
|
||||||
|
# Calculate torques on body
|
||||||
|
Nb = np.zeros(3)
|
||||||
|
for i in range(4): # loop through each yoke
|
||||||
|
# Voltage-motor modeling
|
||||||
|
currentsdot[i] = (eaVec[i] - currents[i] * yokeR) / yokeL
|
||||||
|
|
||||||
|
NiB = np.zeros(3) # since yokes can't cause moment by themselves
|
||||||
|
FiB = np.array([0, 0, Fm[i]])
|
||||||
|
|
||||||
|
# Accumulate torque (rotate FiB to inertial frame since fmag is always towards track)
|
||||||
|
Nb = Nb + (NiB + cross_product_equivalent(P['quadParams'].rotor_loc[:, i]) @ R.T @ FiB)
|
||||||
|
|
||||||
|
# Calculate angular acceleration
|
||||||
|
wdot = P['quadParams'].invJq @ (Nb - wx @ P['quadParams'].Jq @ w)
|
||||||
|
|
||||||
|
# Enforce constraint if pod is against track
|
||||||
|
if np.any(Fm == -1):
|
||||||
|
vdot[2] = 0
|
||||||
|
|
||||||
|
# Assemble state derivative
|
||||||
|
Xdot = np.concatenate([
|
||||||
|
rdot, # 3 elements
|
||||||
|
vdot, # 3 elements
|
||||||
|
Rdot.flatten(), # 9 elements
|
||||||
|
wdot, # 3 elements
|
||||||
|
currentsdot # 4 elements
|
||||||
|
])
|
||||||
|
|
||||||
|
return Xdot
|
||||||
57
MAGLEV_DIGITALTWIN_PYTHON/parameters.py
Normal file
57
MAGLEV_DIGITALTWIN_PYTHON/parameters.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Quad parameters and constants for maglev simulation
|
||||||
|
Ported from quadParamsScript.m and constantsScript.m
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class QuadParams:
|
||||||
|
"""Quadrotor/maglev pod parameters"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Pod mechanical characteristics
|
||||||
|
frame_l = 0.61
|
||||||
|
frame_w = 0.149 # in meters
|
||||||
|
self.yh = 3 * 0.0254 # yoke height
|
||||||
|
yh = self.yh
|
||||||
|
|
||||||
|
# Yoke/rotor locations (at corners)
|
||||||
|
self.rotor_loc = np.array([
|
||||||
|
[frame_l/2, frame_l/2, -frame_l/2, -frame_l/2],
|
||||||
|
[-frame_w/2, frame_w/2, frame_w/2, -frame_w/2],
|
||||||
|
[yh, yh, yh, yh]
|
||||||
|
])
|
||||||
|
|
||||||
|
# Sensor locations (independent from yoke/rotor locations, at edge centers)
|
||||||
|
self.sensor_loc = np.array([
|
||||||
|
[frame_l/2, 0, -frame_l/2, 0],
|
||||||
|
[0, frame_w/2, 0, -frame_w/2],
|
||||||
|
[yh, yh, yh, yh]
|
||||||
|
])
|
||||||
|
|
||||||
|
self.gap_sigma = 0.5e-3 # usually on micron scale
|
||||||
|
|
||||||
|
# Mass of the quad, in kg
|
||||||
|
self.m = 6
|
||||||
|
|
||||||
|
# The quad's moment of inertia, expressed in the body frame, in kg-m^2
|
||||||
|
self.Jq = np.diag([0.017086, 0.125965, 0.131940])
|
||||||
|
self.invJq = np.linalg.inv(self.Jq)
|
||||||
|
|
||||||
|
# Quad electrical characteristics
|
||||||
|
maxcurrent = 30
|
||||||
|
self.yokeR = 2.2 # in ohms
|
||||||
|
self.yokeL = 5e-3 # in henries (2.5mH per yoke)
|
||||||
|
self.maxVoltage = maxcurrent * self.yokeR # max magnitude voltage supplied to each yoke
|
||||||
|
|
||||||
|
|
||||||
|
class Constants:
|
||||||
|
"""Physical constants"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Acceleration due to gravity, in m/s^2
|
||||||
|
self.g = 9.81
|
||||||
|
|
||||||
|
# Mass density of moist air, in kg/m^3
|
||||||
|
self.rho = 1.225
|
||||||
178
MAGLEV_DIGITALTWIN_PYTHON/simulate.py
Normal file
178
MAGLEV_DIGITALTWIN_PYTHON/simulate.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Simulate maglev control
|
||||||
|
Ported from simulateMaglevControl.m
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.integrate import solve_ivp
|
||||||
|
from utils import euler2dcm, dcm2euler
|
||||||
|
from dynamics import quad_ode_function_hf
|
||||||
|
from controller import DecentralizedPIDController
|
||||||
|
|
||||||
|
|
||||||
|
def simulate_maglev_control(R, S, P):
|
||||||
|
"""
|
||||||
|
Simulates closed-loop control of a maglev quadrotor.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
R : dict
|
||||||
|
Reference structure with the following elements:
|
||||||
|
- tVec : (N,) array of uniformly-sampled time offsets from initial time (seconds)
|
||||||
|
- rIstar : (N, 3) array of desired CM positions in I frame (meters)
|
||||||
|
- vIstar : (N, 3) array of desired CM velocities (meters/sec)
|
||||||
|
- aIstar : (N, 3) array of desired CM accelerations (meters/sec^2)
|
||||||
|
- xIstar : (N, 3) array of desired body x-axis direction (unit vectors)
|
||||||
|
|
||||||
|
S : dict
|
||||||
|
Simulation structure with the following elements:
|
||||||
|
- oversampFact : Oversampling factor (must be >= 1)
|
||||||
|
- state0 : Initial state dict with:
|
||||||
|
- r : (3,) position in world frame (meters)
|
||||||
|
- e : (3,) Euler angles (radians)
|
||||||
|
- v : (3,) velocity (meters/sec)
|
||||||
|
- omegaB : (3,) angular rate vector (rad/sec)
|
||||||
|
- distMat : (N-1, 3) array of disturbance forces (Newtons)
|
||||||
|
|
||||||
|
P : dict
|
||||||
|
Parameters structure with:
|
||||||
|
- quadParams : QuadParams object
|
||||||
|
- constants : Constants object
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Q : dict
|
||||||
|
Output structure with:
|
||||||
|
- tVec : (M,) array of output sample time points
|
||||||
|
- state : dict with:
|
||||||
|
- rMat : (M, 3) position matrix
|
||||||
|
- eMat : (M, 3) Euler angles matrix
|
||||||
|
- vMat : (M, 3) velocity matrix
|
||||||
|
- omegaBMat : (M, 3) angular rate matrix
|
||||||
|
- gaps : (M, 4) gap measurements
|
||||||
|
- currents : (M, 4) yoke currents
|
||||||
|
"""
|
||||||
|
# Extract initial state
|
||||||
|
s0 = S['state0']
|
||||||
|
r0 = s0['r']
|
||||||
|
e0 = s0['e']
|
||||||
|
v0 = s0['v']
|
||||||
|
w0 = s0['omegaB']
|
||||||
|
currents0 = np.zeros(4)
|
||||||
|
|
||||||
|
R0 = euler2dcm(e0).flatten()
|
||||||
|
|
||||||
|
x0 = np.concatenate([r0, v0, R0, w0, currents0])
|
||||||
|
|
||||||
|
# Setup simulation parameters
|
||||||
|
N = len(R['tVec'])
|
||||||
|
dtIn = R['tVec'][1] - R['tVec'][0]
|
||||||
|
dt = dtIn / S['oversampFact']
|
||||||
|
|
||||||
|
p = {
|
||||||
|
'quadParams': P['quadParams'],
|
||||||
|
'constants': P['constants']
|
||||||
|
}
|
||||||
|
|
||||||
|
rl = P['quadParams'].rotor_loc
|
||||||
|
|
||||||
|
# Initialize outputs
|
||||||
|
tOut = []
|
||||||
|
xOut = []
|
||||||
|
gapsOut = []
|
||||||
|
|
||||||
|
xk = x0
|
||||||
|
|
||||||
|
# Create controller instance to maintain state
|
||||||
|
controller = DecentralizedPIDController()
|
||||||
|
|
||||||
|
for k in range(N - 1): # loop through each time step
|
||||||
|
tspan = np.arange(S['tVec'][k], S['tVec'][k+1] + dt/2, dt)
|
||||||
|
|
||||||
|
# Setup reference for this time step
|
||||||
|
Rk = {
|
||||||
|
'rIstark': R['rIstar'][k, :],
|
||||||
|
'vIstark': R['vIstar'][k, :],
|
||||||
|
'aIstark': R['aIstar'][k, :],
|
||||||
|
'xIstark': R['xIstar'][k, :]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup state for this time step
|
||||||
|
Sk = {
|
||||||
|
'statek': {
|
||||||
|
'rI': xk[0:3],
|
||||||
|
'vI': xk[3:6],
|
||||||
|
'RBI': xk[6:15],
|
||||||
|
'omegaB': xk[15:18],
|
||||||
|
'currents': xk[18:22]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compute control voltage with noise
|
||||||
|
eak = controller.control(Rk, Sk, P) + np.random.normal(0, 0.01, 4)
|
||||||
|
|
||||||
|
# Disturbance for this time step
|
||||||
|
dk = S['distMat'][k, :]
|
||||||
|
|
||||||
|
# Integrate ODE
|
||||||
|
sol = solve_ivp(
|
||||||
|
lambda t, X: quad_ode_function_hf(t, X, eak, dk, p),
|
||||||
|
[tspan[0], tspan[-1]],
|
||||||
|
xk,
|
||||||
|
t_eval=tspan,
|
||||||
|
method='RK45',
|
||||||
|
max_step=dt
|
||||||
|
)
|
||||||
|
|
||||||
|
tk = sol.t
|
||||||
|
xk_traj = sol.y.T # Transpose to get (time, state) shape
|
||||||
|
|
||||||
|
# Calculate gaps for all time points in this segment
|
||||||
|
NTK = len(tk)
|
||||||
|
gapsk = np.zeros((NTK, 4))
|
||||||
|
for q in range(NTK):
|
||||||
|
R_q = xk_traj[q, 6:15].reshape(3, 3)
|
||||||
|
gapskq = np.abs(xk_traj[q, 2]) - np.array([0, 0, 1]) @ R_q.T @ rl
|
||||||
|
gapsk[q, :] = gapskq.flatten()
|
||||||
|
|
||||||
|
# Store results (excluding last point to avoid duplication)
|
||||||
|
tOut.append(tk[:-1])
|
||||||
|
xOut.append(xk_traj[:-1, :])
|
||||||
|
gapsOut.append(gapsk[:-1, :])
|
||||||
|
|
||||||
|
# Prepare for next iteration
|
||||||
|
xk = xk_traj[-1, :]
|
||||||
|
|
||||||
|
# Concatenate all segments
|
||||||
|
tOut = np.concatenate(tOut)
|
||||||
|
xOut = np.vstack(xOut)
|
||||||
|
gapsOut = np.vstack(gapsOut)
|
||||||
|
|
||||||
|
# Add final point
|
||||||
|
tOut = np.append(tOut, tk[-1])
|
||||||
|
xOut = np.vstack([xOut, xk_traj[-1, :]])
|
||||||
|
gapsOut = np.vstack([gapsOut, gapsk[-1, :]])
|
||||||
|
|
||||||
|
M = len(tOut)
|
||||||
|
|
||||||
|
# Form Euler angles from rotation matrices
|
||||||
|
eMat = np.zeros((M, 3))
|
||||||
|
for k in range(M):
|
||||||
|
Rk = xOut[k, 6:15].reshape(3, 3)
|
||||||
|
ek = dcm2euler(Rk)
|
||||||
|
eMat[k, :] = np.real(ek)
|
||||||
|
|
||||||
|
# Assemble output structure
|
||||||
|
Q = {
|
||||||
|
'tVec': tOut,
|
||||||
|
'state': {
|
||||||
|
'rMat': xOut[:, 0:3],
|
||||||
|
'vMat': xOut[:, 3:6],
|
||||||
|
'eMat': eMat,
|
||||||
|
'omegaBMat': xOut[:, 15:18],
|
||||||
|
'gaps': gapsOut,
|
||||||
|
'currents': xOut[:, 18:22]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Q
|
||||||
206
MAGLEV_DIGITALTWIN_PYTHON/topSimulate.py
Normal file
206
MAGLEV_DIGITALTWIN_PYTHON/topSimulate.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""
|
||||||
|
Top-level script for calling simulate_maglev_control
|
||||||
|
Ported from topSimulate.m to Python
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from matplotlib.animation import FuncAnimation, PillowWriter
|
||||||
|
from parameters import QuadParams, Constants
|
||||||
|
from utils import euler2dcm, fmag2
|
||||||
|
from simulate import simulate_maglev_control
|
||||||
|
from visualize import visualize_quad
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main simulation script"""
|
||||||
|
|
||||||
|
# SET REFERENCE HERE
|
||||||
|
# Total simulation time, in seconds
|
||||||
|
Tsim = 2
|
||||||
|
|
||||||
|
# Update interval, in seconds. This value should be small relative to the
|
||||||
|
# shortest time constant of your system.
|
||||||
|
delt = 0.005 # sampling interval
|
||||||
|
|
||||||
|
# Maglev parameters and constants
|
||||||
|
quad_params = QuadParams()
|
||||||
|
constants = Constants()
|
||||||
|
|
||||||
|
m = quad_params.m
|
||||||
|
g = constants.g
|
||||||
|
J = quad_params.Jq
|
||||||
|
|
||||||
|
# Time vector, in seconds
|
||||||
|
N = int(np.floor(Tsim / delt))
|
||||||
|
tVec = np.arange(N) * delt
|
||||||
|
|
||||||
|
# Matrix of disturbance forces acting on the body, in Newtons, expressed in I
|
||||||
|
distMat = np.random.normal(0, 10, (N-1, 3))
|
||||||
|
|
||||||
|
# Oversampling factor
|
||||||
|
oversampFact = 10
|
||||||
|
|
||||||
|
# Check nominal gap
|
||||||
|
print(f"Force check: {4*fmag2(0, 11.239e-3) - m*g}")
|
||||||
|
|
||||||
|
ref_gap = 10.830e-3 # from python code
|
||||||
|
z0 = ref_gap + 2e-3
|
||||||
|
|
||||||
|
# Create reference trajectories
|
||||||
|
rIstar = np.zeros((N, 3))
|
||||||
|
vIstar = np.zeros((N, 3))
|
||||||
|
aIstar = np.zeros((N, 3))
|
||||||
|
xIstar = np.zeros((N, 3))
|
||||||
|
|
||||||
|
for k in range(N):
|
||||||
|
rIstar[k, :] = [0, 0, -ref_gap]
|
||||||
|
vIstar[k, :] = [0, 0, 0]
|
||||||
|
aIstar[k, :] = [0, 0, 0]
|
||||||
|
xIstar[k, :] = [0, 1, 0]
|
||||||
|
|
||||||
|
# Setup reference structure
|
||||||
|
R = {
|
||||||
|
'tVec': tVec,
|
||||||
|
'rIstar': rIstar,
|
||||||
|
'vIstar': vIstar,
|
||||||
|
'aIstar': aIstar,
|
||||||
|
'xIstar': xIstar
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initial state
|
||||||
|
state0 = {
|
||||||
|
'r': np.array([0, 0, -(z0 + quad_params.yh)]),
|
||||||
|
'v': np.array([0, 0, 0]),
|
||||||
|
'e': np.array([0.01, 0.01, np.pi/2]), # xyz euler angles
|
||||||
|
'omegaB': np.array([0.00, 0.00, 0])
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup simulation structure
|
||||||
|
S = {
|
||||||
|
'tVec': tVec,
|
||||||
|
'distMat': distMat,
|
||||||
|
'oversampFact': oversampFact,
|
||||||
|
'state0': state0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup parameters structure
|
||||||
|
P = {
|
||||||
|
'quadParams': quad_params,
|
||||||
|
'constants': constants
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run simulation
|
||||||
|
print("Running simulation...")
|
||||||
|
P0 = simulate_maglev_control(R, S, P)
|
||||||
|
print("Simulation complete!")
|
||||||
|
|
||||||
|
# Extract results
|
||||||
|
tVec_out = P0['tVec']
|
||||||
|
state = P0['state']
|
||||||
|
rMat = state['rMat']
|
||||||
|
eMat = state['eMat']
|
||||||
|
vMat = state['vMat']
|
||||||
|
gaps = state['gaps']
|
||||||
|
currents = state['currents']
|
||||||
|
omegaBMat = state['omegaBMat']
|
||||||
|
|
||||||
|
# Calculate forces
|
||||||
|
Fm = fmag2(currents[:, 0], gaps[:, 0])
|
||||||
|
|
||||||
|
# Calculate ex and ey for visualization
|
||||||
|
N_out = len(eMat)
|
||||||
|
ex = np.zeros(N_out)
|
||||||
|
ey = np.zeros(N_out)
|
||||||
|
|
||||||
|
for k in range(N_out):
|
||||||
|
Rk = euler2dcm(eMat[k, :])
|
||||||
|
x = Rk @ np.array([1, 0, 0])
|
||||||
|
ex[k] = x[0]
|
||||||
|
ey[k] = x[1]
|
||||||
|
|
||||||
|
# Visualize the quad motion
|
||||||
|
print("Generating 3D visualization...")
|
||||||
|
S2 = {
|
||||||
|
'tVec': tVec_out,
|
||||||
|
'rMat': rMat,
|
||||||
|
'eMat': eMat,
|
||||||
|
'plotFrequency': 20,
|
||||||
|
'makeGifFlag': True,
|
||||||
|
'gifFileName': 'testGif.gif',
|
||||||
|
'bounds': [-1, 1, -1, 1, -300e-3, 0.000]
|
||||||
|
}
|
||||||
|
visualize_quad(S2)
|
||||||
|
|
||||||
|
# Create plots
|
||||||
|
fig = plt.figure(figsize=(12, 8))
|
||||||
|
|
||||||
|
# Plot 1: Gaps
|
||||||
|
ax1 = plt.subplot(3, 1, 1)
|
||||||
|
plt.plot(tVec_out, gaps * 1e3)
|
||||||
|
plt.axhline(y=ref_gap * 1e3, color='k', linestyle='-', linewidth=1)
|
||||||
|
plt.ylabel('Vertical (mm)')
|
||||||
|
plt.title('Gaps')
|
||||||
|
plt.grid(True)
|
||||||
|
plt.xticks([])
|
||||||
|
|
||||||
|
# Plot 2: Currents
|
||||||
|
ax2 = plt.subplot(3, 1, 2)
|
||||||
|
plt.plot(tVec_out, currents)
|
||||||
|
plt.ylabel('Current (Amps)')
|
||||||
|
plt.title('Power')
|
||||||
|
plt.grid(True)
|
||||||
|
plt.xticks([])
|
||||||
|
|
||||||
|
# Plot 3: Forces
|
||||||
|
ax3 = plt.subplot(3, 1, 3)
|
||||||
|
plt.plot(tVec_out, Fm)
|
||||||
|
plt.xlabel('Time (sec)')
|
||||||
|
plt.ylabel('Fm (N)')
|
||||||
|
plt.title('Forcing')
|
||||||
|
plt.grid(True)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig('simulation_results.png', dpi=150)
|
||||||
|
print("Saved plot to simulation_results.png")
|
||||||
|
|
||||||
|
# Commented out horizontal position plot (as in MATLAB)
|
||||||
|
# fig_horizontal = plt.figure()
|
||||||
|
# plt.plot(rMat[:, 0], rMat[:, 1])
|
||||||
|
# spacing = 300
|
||||||
|
# plt.quiver(rMat[::spacing, 0], rMat[::spacing, 1],
|
||||||
|
# ex[::spacing], -ey[::spacing])
|
||||||
|
# plt.axis('equal')
|
||||||
|
# plt.grid(True)
|
||||||
|
# plt.xlabel('X (m)')
|
||||||
|
# plt.ylabel('Y (m)')
|
||||||
|
# plt.title('Horizontal position of CM')
|
||||||
|
|
||||||
|
# Frequency analysis (mech freq)
|
||||||
|
Fs = 1/delt * oversampFact # Sampling frequency
|
||||||
|
T = 1/Fs # Sampling period
|
||||||
|
L = len(tVec_out) # Length of signal
|
||||||
|
|
||||||
|
Y = np.fft.fft(Fm)
|
||||||
|
frequencies = Fs / L * np.arange(L)
|
||||||
|
|
||||||
|
fig2 = plt.figure(figsize=(10, 6))
|
||||||
|
plt.semilogx(frequencies, np.abs(Y), linewidth=3)
|
||||||
|
plt.title("Complex Magnitude of fft Spectrum")
|
||||||
|
plt.xlabel("f (Hz)")
|
||||||
|
plt.ylabel("|fft(X)|")
|
||||||
|
plt.grid(True)
|
||||||
|
plt.savefig('fft_spectrum.png', dpi=150)
|
||||||
|
print("Saved FFT plot to fft_spectrum.png")
|
||||||
|
|
||||||
|
# Show all plots
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
return P0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
results = main()
|
||||||
|
print("\nSimulation completed successfully!")
|
||||||
|
print(f"Final time: {results['tVec'][-1]:.3f} seconds")
|
||||||
|
print(f"Number of time points: {len(results['tVec'])}")
|
||||||
203
MAGLEV_DIGITALTWIN_PYTHON/utils.py
Normal file
203
MAGLEV_DIGITALTWIN_PYTHON/utils.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Utility functions for maglev simulation
|
||||||
|
Ported from MATLAB to Python
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def cross_product_equivalent(u):
|
||||||
|
"""
|
||||||
|
Outputs the cross-product-equivalent matrix uCross such that for arbitrary
|
||||||
|
3-by-1 vectors u and v, cross(u,v) = uCross @ v.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
u : array_like, shape (3,)
|
||||||
|
3-element vector
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
uCross : ndarray, shape (3, 3)
|
||||||
|
Skew-symmetric cross-product equivalent matrix
|
||||||
|
"""
|
||||||
|
u1, u2, u3 = u[0], u[1], u[2]
|
||||||
|
|
||||||
|
uCross = np.array([
|
||||||
|
[0, -u3, u2],
|
||||||
|
[u3, 0, -u1],
|
||||||
|
[-u2, u1, 0]
|
||||||
|
])
|
||||||
|
|
||||||
|
return uCross
|
||||||
|
|
||||||
|
|
||||||
|
def rotation_matrix(aHat, phi):
|
||||||
|
"""
|
||||||
|
Generates the rotation matrix R corresponding to a rotation through an
|
||||||
|
angle phi about the axis defined by the unit vector aHat. This is a
|
||||||
|
straightforward implementation of Euler's formula for a rotation matrix.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
aHat : array_like, shape (3,)
|
||||||
|
3-by-1 unit vector constituting the axis of rotation
|
||||||
|
phi : float
|
||||||
|
Angle of rotation, in radians
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
R : ndarray, shape (3, 3)
|
||||||
|
Rotation matrix
|
||||||
|
"""
|
||||||
|
aHat = np.array(aHat).reshape(3, 1)
|
||||||
|
R = (np.cos(phi) * np.eye(3) +
|
||||||
|
(1 - np.cos(phi)) * (aHat @ aHat.T) -
|
||||||
|
np.sin(phi) * cross_product_equivalent(aHat.flatten()))
|
||||||
|
|
||||||
|
return R
|
||||||
|
|
||||||
|
|
||||||
|
def euler2dcm(e):
|
||||||
|
"""
|
||||||
|
Converts Euler angles phi = e[0], theta = e[1], and psi = e[2]
|
||||||
|
(in radians) into a direction cosine matrix for a 3-1-2 rotation.
|
||||||
|
|
||||||
|
Let the world (W) and body (B) reference frames be initially aligned. In a
|
||||||
|
3-1-2 order, rotate B away from W by angles psi (yaw, about the body Z
|
||||||
|
axis), phi (roll, about the body X axis), and theta (pitch, about the body Y
|
||||||
|
axis). R_BW can then be used to cast a vector expressed in W coordinates as
|
||||||
|
a vector in B coordinates: vB = R_BW @ vW
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
e : array_like, shape (3,)
|
||||||
|
Vector containing the Euler angles in radians: phi = e[0],
|
||||||
|
theta = e[1], and psi = e[2]
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
R_BW : ndarray, shape (3, 3)
|
||||||
|
Direction cosine matrix
|
||||||
|
"""
|
||||||
|
a1 = np.array([0, 0, 1])
|
||||||
|
a2 = np.array([1, 0, 0])
|
||||||
|
a3 = np.array([0, 1, 0])
|
||||||
|
|
||||||
|
phi = e[0]
|
||||||
|
theta = e[1]
|
||||||
|
psi = e[2]
|
||||||
|
|
||||||
|
R_BW = rotation_matrix(a3, theta) @ rotation_matrix(a2, phi) @ rotation_matrix(a1, psi)
|
||||||
|
|
||||||
|
return R_BW
|
||||||
|
|
||||||
|
|
||||||
|
def dcm2euler(R_BW):
|
||||||
|
"""
|
||||||
|
Converts a direction cosine matrix R_BW to Euler angles phi = e[0],
|
||||||
|
theta = e[1], and psi = e[2] (in radians) for a 3-1-2 rotation.
|
||||||
|
If the conversion to Euler angles is singular (not unique), then this
|
||||||
|
function raises an error.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
R_BW : ndarray, shape (3, 3)
|
||||||
|
Direction cosine matrix
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
e : ndarray, shape (3,)
|
||||||
|
Vector containing the Euler angles in radians: phi = e[0],
|
||||||
|
theta = e[1], and psi = e[2]
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
If gimbal lock occurs (|phi| = pi/2)
|
||||||
|
"""
|
||||||
|
R_BW = np.real(R_BW)
|
||||||
|
|
||||||
|
if R_BW[1, 2] == 1:
|
||||||
|
raise ValueError("Error: Gimbal lock since |phi| = pi/2")
|
||||||
|
|
||||||
|
theta = np.arctan2(-R_BW[0, 2], R_BW[2, 2])
|
||||||
|
phi = np.arcsin(R_BW[1, 2])
|
||||||
|
psi = np.arctan2(-R_BW[1, 0], R_BW[1, 1])
|
||||||
|
|
||||||
|
e = np.array([phi, theta, psi])
|
||||||
|
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def fmag2(i, z):
|
||||||
|
"""
|
||||||
|
Converts a given gap distance, z, and current, i, to yield the attraction
|
||||||
|
force Fm to a steel plate.
|
||||||
|
|
||||||
|
i > 0 runs current in direction to weaken Fm
|
||||||
|
i < 0 runs current to strengthen Fm
|
||||||
|
|
||||||
|
z is positive for valid conditions
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
i : float or ndarray
|
||||||
|
Current in amperes
|
||||||
|
z : float or ndarray
|
||||||
|
Gap distance in meters (must be positive)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Fm : float or ndarray
|
||||||
|
Magnetic force in Newtons (-1 if z < 0, indicating error)
|
||||||
|
"""
|
||||||
|
# Handle scalar and array inputs
|
||||||
|
z_scalar = np.isscalar(z)
|
||||||
|
i_scalar = np.isscalar(i)
|
||||||
|
|
||||||
|
if z_scalar and i_scalar:
|
||||||
|
if z < 0:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
N = 250
|
||||||
|
term1 = (-2 * i * N + 0.2223394555e5)
|
||||||
|
denominator = (0.2466906550e10 * z + 0.6886569338e8)
|
||||||
|
|
||||||
|
Fm = (0.6167266375e9 * term1**2 / denominator**2 -
|
||||||
|
0.3042813963e19 * z * term1**2 / denominator**3)
|
||||||
|
|
||||||
|
return Fm
|
||||||
|
else:
|
||||||
|
# Handle array inputs
|
||||||
|
z = np.asarray(z)
|
||||||
|
i = np.asarray(i)
|
||||||
|
|
||||||
|
# Check for negative z values
|
||||||
|
if np.any(z < 0):
|
||||||
|
# Create output array
|
||||||
|
Fm = np.zeros_like(z, dtype=float)
|
||||||
|
valid_mask = z >= 0
|
||||||
|
|
||||||
|
if np.any(valid_mask):
|
||||||
|
N = 250
|
||||||
|
z_valid = z[valid_mask]
|
||||||
|
i_valid = i if i_scalar else i[valid_mask]
|
||||||
|
|
||||||
|
term1 = (-2 * i_valid * N + 0.2223394555e5)
|
||||||
|
denominator = (0.2466906550e10 * z_valid + 0.6886569338e8)
|
||||||
|
|
||||||
|
Fm[valid_mask] = (0.6167266375e9 * term1**2 / denominator**2 -
|
||||||
|
0.3042813963e19 * z_valid * term1**2 / denominator**3)
|
||||||
|
|
||||||
|
Fm[~valid_mask] = -1
|
||||||
|
return Fm
|
||||||
|
else:
|
||||||
|
N = 250
|
||||||
|
term1 = (-2 * i * N + 0.2223394555e5)
|
||||||
|
denominator = (0.2466906550e10 * z + 0.6886569338e8)
|
||||||
|
|
||||||
|
Fm = (0.6167266375e9 * term1**2 / denominator**2 -
|
||||||
|
0.3042813963e19 * z * term1**2 / denominator**3)
|
||||||
|
|
||||||
|
return Fm
|
||||||
235
MAGLEV_DIGITALTWIN_PYTHON/visualize.py
Normal file
235
MAGLEV_DIGITALTWIN_PYTHON/visualize.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
Visualization function for quadrotor/maglev pod
|
||||||
|
Ported from visualizeQuad.m
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from mpl_toolkits.mplot3d import Axes3D
|
||||||
|
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
||||||
|
from matplotlib.animation import FuncAnimation, PillowWriter
|
||||||
|
from scipy.interpolate import interp1d
|
||||||
|
from utils import euler2dcm
|
||||||
|
from parameters import QuadParams
|
||||||
|
|
||||||
|
|
||||||
|
def visualize_quad(S):
|
||||||
|
"""
|
||||||
|
Takes in an input structure S and visualizes the resulting 3D motion
|
||||||
|
in approximately real-time. Outputs the data used to form the plot.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
S : dict
|
||||||
|
Structure with the following elements:
|
||||||
|
- rMat : (M, 3) matrix of quad positions, in meters
|
||||||
|
- eMat : (M, 3) matrix of quad attitudes, in radians
|
||||||
|
- tVec : (M,) vector of times corresponding to each measurement
|
||||||
|
- plotFrequency : scalar number of frames per second (Hz)
|
||||||
|
- bounds : 6-element list [xmin, xmax, ymin, ymax, zmin, zmax]
|
||||||
|
- makeGifFlag : boolean (if True, export plot to .gif)
|
||||||
|
- gifFileName : string with the file name of the .gif
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
P : dict
|
||||||
|
Structure with the following elements:
|
||||||
|
- tPlot : (N,) vector of time points used in the plot
|
||||||
|
- rPlot : (N, 3) vector of positions used to generate the plot
|
||||||
|
- ePlot : (N, 3) vector of attitudes used to generate the plot
|
||||||
|
"""
|
||||||
|
# UT colors
|
||||||
|
burntOrangeUT = np.array([191, 87, 0]) / 255
|
||||||
|
darkGrayUT = np.array([51, 63, 72]) / 255
|
||||||
|
|
||||||
|
# Get quad parameters
|
||||||
|
quad_params = QuadParams()
|
||||||
|
|
||||||
|
frame_l = 1.2192
|
||||||
|
frame_w = 0.711 # in meters
|
||||||
|
yh = quad_params.yh
|
||||||
|
|
||||||
|
# Parameters for the rotors
|
||||||
|
rotorLocations = np.array([
|
||||||
|
[frame_l/2, frame_l/2, -frame_l/2, -frame_l/2],
|
||||||
|
[frame_w/2, -frame_w/2, frame_w/2, -frame_w/2],
|
||||||
|
[yh, yh, yh, yh]
|
||||||
|
])
|
||||||
|
r_rotor = 0.13
|
||||||
|
|
||||||
|
# Determine the location of the corners of the body box in the body frame
|
||||||
|
bpts = np.array([
|
||||||
|
[frame_l/2, frame_l/2, -frame_l/2, -frame_l/2, frame_l/2, frame_l/2, -frame_l/2, -frame_l/2],
|
||||||
|
[frame_w/2, -frame_w/2, frame_w/2, -frame_w/2, frame_w/2, -frame_w/2, frame_w/2, -frame_w/2],
|
||||||
|
[0.03, 0.03, 0.03, 0.03, -0.030, -0.030, -0.030, -0.030]
|
||||||
|
])
|
||||||
|
|
||||||
|
# Rectangles representing each side of the body box
|
||||||
|
b1 = bpts[:, [0, 4, 5, 1]]
|
||||||
|
b2 = bpts[:, [0, 4, 6, 2]]
|
||||||
|
b3 = bpts[:, [2, 6, 7, 3]]
|
||||||
|
b4 = bpts[:, [0, 2, 3, 1]]
|
||||||
|
b5 = bpts[:, [4, 6, 7, 5]]
|
||||||
|
b6 = bpts[:, [1, 5, 7, 3]]
|
||||||
|
|
||||||
|
# Create a circle for each rotor
|
||||||
|
t_circ = np.linspace(0, 2*np.pi, 20)
|
||||||
|
circpts = np.zeros((3, 20))
|
||||||
|
for i in range(20):
|
||||||
|
circpts[:, i] = r_rotor * np.array([np.cos(t_circ[i]), np.sin(t_circ[i]), 0])
|
||||||
|
|
||||||
|
m = len(S['tVec'])
|
||||||
|
|
||||||
|
# Single epoch plot
|
||||||
|
if m == 1:
|
||||||
|
fig = plt.figure(figsize=(10, 8))
|
||||||
|
ax = fig.add_subplot(111, projection='3d')
|
||||||
|
|
||||||
|
# Extract params
|
||||||
|
RIB = euler2dcm(S['eMat'][0, :]).T
|
||||||
|
r = S['rMat'][0, :]
|
||||||
|
|
||||||
|
# Plot rotors
|
||||||
|
for i in range(4):
|
||||||
|
rotor_circle = r.reshape(3, 1) + RIB @ (circpts + rotorLocations[:, i].reshape(3, 1))
|
||||||
|
ax.plot(rotor_circle[0, :], rotor_circle[1, :], rotor_circle[2, :],
|
||||||
|
color=darkGrayUT, linewidth=2)
|
||||||
|
|
||||||
|
# Plot body
|
||||||
|
plot_body(ax, r, RIB, [b1, b2, b3, b4, b5, b6])
|
||||||
|
|
||||||
|
# Plot body axes
|
||||||
|
plot_axes(ax, r, RIB)
|
||||||
|
|
||||||
|
ax.set_xlim([S['bounds'][0], S['bounds'][1]])
|
||||||
|
ax.set_ylim([S['bounds'][2], S['bounds'][3]])
|
||||||
|
ax.set_zlim([S['bounds'][4], S['bounds'][5]])
|
||||||
|
ax.set_xlabel('X')
|
||||||
|
ax.set_ylabel('Y')
|
||||||
|
ax.set_zlabel('Z')
|
||||||
|
ax.grid(True)
|
||||||
|
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
P = {
|
||||||
|
'tPlot': S['tVec'],
|
||||||
|
'rPlot': S['rMat'],
|
||||||
|
'ePlot': S['eMat']
|
||||||
|
}
|
||||||
|
|
||||||
|
else: # Animation
|
||||||
|
# Create time vectors
|
||||||
|
tf = 1 / S['plotFrequency']
|
||||||
|
tmax = S['tVec'][-1]
|
||||||
|
tmin = S['tVec'][0]
|
||||||
|
tPlot = np.arange(tmin, tmax + tf/2, tf)
|
||||||
|
tPlotLen = len(tPlot)
|
||||||
|
|
||||||
|
# Interpolate to regularize times
|
||||||
|
t2unique, indUnique = np.unique(S['tVec'], return_index=True)
|
||||||
|
|
||||||
|
rPlot_interp = interp1d(t2unique, S['rMat'][indUnique, :], axis=0,
|
||||||
|
kind='linear', fill_value='extrapolate')
|
||||||
|
ePlot_interp = interp1d(t2unique, S['eMat'][indUnique, :], axis=0,
|
||||||
|
kind='linear', fill_value='extrapolate')
|
||||||
|
|
||||||
|
rPlot = rPlot_interp(tPlot)
|
||||||
|
ePlot = ePlot_interp(tPlot)
|
||||||
|
|
||||||
|
# Create figure and axis
|
||||||
|
fig = plt.figure(figsize=(10, 8))
|
||||||
|
ax = fig.add_subplot(111, projection='3d')
|
||||||
|
|
||||||
|
# Storage for plot elements
|
||||||
|
plot_elements = []
|
||||||
|
|
||||||
|
def init():
|
||||||
|
ax.set_xlim([S['bounds'][0], S['bounds'][1]])
|
||||||
|
ax.set_ylim([S['bounds'][2], S['bounds'][3]])
|
||||||
|
ax.set_zlim([S['bounds'][4], S['bounds'][5]])
|
||||||
|
ax.set_xlabel('X')
|
||||||
|
ax.set_ylabel('Y')
|
||||||
|
ax.set_zlabel('Z')
|
||||||
|
ax.view_init(elev=0, azim=0)
|
||||||
|
ax.grid(True)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def update(frame):
|
||||||
|
nonlocal plot_elements
|
||||||
|
|
||||||
|
# Clear previous frame
|
||||||
|
for elem in plot_elements:
|
||||||
|
elem.remove()
|
||||||
|
plot_elements = []
|
||||||
|
|
||||||
|
# Extract data
|
||||||
|
RIB = euler2dcm(ePlot[frame, :]).T
|
||||||
|
r = rPlot[frame, :]
|
||||||
|
|
||||||
|
# Plot rotors
|
||||||
|
for i in range(4):
|
||||||
|
rotor_circle = r.reshape(3, 1) + RIB @ (circpts + rotorLocations[:, i].reshape(3, 1))
|
||||||
|
line, = ax.plot(rotor_circle[0, :], rotor_circle[1, :], rotor_circle[2, :],
|
||||||
|
color=darkGrayUT, linewidth=2)
|
||||||
|
plot_elements.append(line)
|
||||||
|
|
||||||
|
# Plot body
|
||||||
|
body_elem = plot_body(ax, r, RIB, [b1, b2, b3, b4, b5, b6])
|
||||||
|
plot_elements.append(body_elem)
|
||||||
|
|
||||||
|
# Plot axes
|
||||||
|
axis_elems = plot_axes(ax, r, RIB)
|
||||||
|
plot_elements.extend(axis_elems)
|
||||||
|
|
||||||
|
ax.set_title(f'Time: {tPlot[frame]:.3f} s')
|
||||||
|
|
||||||
|
return plot_elements
|
||||||
|
|
||||||
|
anim = FuncAnimation(fig, update, init_func=init, frames=tPlotLen,
|
||||||
|
interval=tf*1000, blit=False, repeat=True)
|
||||||
|
|
||||||
|
# Save as GIF if requested
|
||||||
|
if S.get('makeGifFlag', False):
|
||||||
|
writer = PillowWriter(fps=S['plotFrequency'])
|
||||||
|
anim.save(S['gifFileName'], writer=writer)
|
||||||
|
print(f"Animation saved to {S['gifFileName']}")
|
||||||
|
|
||||||
|
plt.show()
|
||||||
|
|
||||||
|
P = {
|
||||||
|
'tPlot': tPlot,
|
||||||
|
'rPlot': rPlot,
|
||||||
|
'ePlot': ePlot
|
||||||
|
}
|
||||||
|
|
||||||
|
return P
|
||||||
|
|
||||||
|
|
||||||
|
def plot_body(ax, r, RIB, body_faces):
|
||||||
|
"""Plot the body box"""
|
||||||
|
vertices = []
|
||||||
|
for face in body_faces:
|
||||||
|
face_r = r.reshape(3, 1) + RIB @ face
|
||||||
|
vertices.append(face_r.T)
|
||||||
|
|
||||||
|
# Create 3D polygon collection
|
||||||
|
poly = Poly3DCollection(vertices, alpha=0.5, facecolor=[0.5, 0.5, 0.5],
|
||||||
|
edgecolor='black', linewidths=1)
|
||||||
|
ax.add_collection3d(poly)
|
||||||
|
return poly
|
||||||
|
|
||||||
|
|
||||||
|
def plot_axes(ax, r, RIB):
|
||||||
|
"""Plot body axes"""
|
||||||
|
bodyX = 0.5 * RIB @ np.array([1, 0, 0])
|
||||||
|
bodyY = 0.5 * RIB @ np.array([0, 1, 0])
|
||||||
|
bodyZ = 0.5 * RIB @ np.array([0, 0, 1])
|
||||||
|
|
||||||
|
q1 = ax.quiver(r[0], r[1], r[2], bodyX[0], bodyX[1], bodyX[2],
|
||||||
|
color='red', arrow_length_ratio=0.3)
|
||||||
|
q2 = ax.quiver(r[0], r[1], r[2], bodyY[0], bodyY[1], bodyY[2],
|
||||||
|
color='blue', arrow_length_ratio=0.3)
|
||||||
|
q3 = ax.quiver(r[0], r[1], r[2], bodyZ[0], bodyZ[1], bodyZ[2],
|
||||||
|
color='green', arrow_length_ratio=0.3)
|
||||||
|
|
||||||
|
return [q1, q2, q3]
|
||||||
Reference in New Issue
Block a user