Quadrotor / multirotor UAV — Nonlinear 6-DoF dynamics¶
Rigid-body quadrotor model in the full 6-DoF formulation: 12 states (position, velocity, attitude, angular rates), 4 control inputs (collective thrust + three body-frame torques). Implemented in pure Python/NumPy with Euler and RK4 integrators. Suitable for PID stabilisation, MPC trajectory tracking, and adaptive RL critics (iADP, AIDI, AA-INDI) — especially for rotor-failure scenarios.
-
Quick start
Run a 10-second hover in three lines.
-
Model API
NonlinearQuadrotor— the 6-DoF dynamics class. -
Mathematics
Equations of motion, NED / body frame conventions.
-
Validation tests
Hover, free fall, gyroscopic coupling.
Reference platform parameters¶
The defaults match a typical 1.5 kg research quadrotor (AscTec
Hummingbird / Pelican class), arm length 22.5 cm. These are the
defaults from QuadrotorParameters.default_parameters(); override
them for other platforms (DJI F450, Crazyflie, X4-frame, etc.).
| Parameter | Value |
|---|---|
| Mass \(m\), kg | 1.5 |
| Inertia \(J_x = J_y\), kg·m² | 0.0211 |
| Inertia \(J_z\), kg·m² | 0.0366 |
| Arm length, m | 0.225 |
| Linear body-drag \(k_{dx}=k_{dy}\), N·s/m | 0.10 |
| Linear body-drag \(k_{dz}\), N·s/m | 0.20 |
| Maximum collective thrust, N | 30 (≈ 2:1 thrust-to-weight) |
| Maximum torque per axis, N·m | 1.5 |
Coordinate systems and state¶
The model uses two right-handed orthogonal frames:
- Body-fixed: \(x\) forward, \(y\) right, \(z\) down — coincides with NED when level.
- Earth (NED, north-east-down): \(x\) north, \(y\) east, \(z\) down. Gravity is along \(+z\).
Body-to-earth transformation is the standard ZYX (321) Euler rotation matrix (yaw → pitch → roll).
State vector and control:
- \(x_e, y_e, z_e\) — earth-frame position (NED), m.
- \(u_b, v_b, w_b\) — body-frame velocity, m/s.
- \(\phi, \theta, \psi\) — roll, pitch, yaw (ZYX Euler), rad.
- \(p, q, r\) — body-frame angular rates, rad/s.
- \(T\) — collective thrust along body \(-z\) (up when level), N.
- \(\tau_x, \tau_y, \tau_z\) — body-frame torques (roll, pitch, yaw), N·m.
The control vector \(\mathbf{u}\) is already the output of the motor-mixing / allocation block. Mapping the 4 rotor speeds \(\omega_i\) to \((T, \tau)\) depends on the configuration (X / +) and is not part of this model — it lives in a separate helper or inside the RL agent.
Euler-angle singularity
At \(|\theta| = \pi/2\) the kinematic mapping diverges through the \(1/\cos\theta\) term (gimbal lock). Use a quaternion variant for aggressive acrobatic manoeuvres — currently not implemented.
Mathematical model¶
The combined ODE has four blocks: position kinematics, velocity dynamics, Euler-angle kinematics, angular-rate dynamics.
1. Position kinematics¶
where \(R_{eb}\) is the body-to-earth rotation (ZYX 321):
(shorthand \(c_\bullet = \cos\bullet\), \(s_\bullet = \sin\bullet\)).
2. Velocity dynamics (body frame)¶
Newton's equation in the body frame, including rotation, gravity, thrust, and linear aerodynamic drag:
with:
- \(\mathbf{F}_{\text{grav},e} = [0, 0, m g]^\top\) — gravity in NED (z-down),
- \(\mathbf{F}_{\text{thrust},b} = [0, 0, -T]^\top\) — thrust along body \(-z\),
- \(D = \mathrm{diag}(k_{dx}, k_{dy}, k_{dz})\) — diagonal drag matrix,
- \(\boldsymbol\omega = [p, q, r]^\top\).
3. Euler-angle kinematics (ZYX 321)¶
with \(t_\theta = \tan\theta\).
4. Angular-rate dynamics (Newton-Euler)¶
With diagonal inertia \(\mathbf{J} = \mathrm{diag}(J_x, J_y, J_z)\):
The \((J_i - J_j)\,\omega_i\,\omega_j\) terms are the gyroscopic cross-axis coupling (Euler cross-product terms).
Quick start¶
import numpy as np
from tensoraerospace.aerospacemodel.quadrotor.nonlinear import NonlinearQuadrotor
# Hover at the origin
m = NonlinearQuadrotor(
x0=np.zeros(12),
dt=0.01,
integrator="rk4",
)
# T = m·g holds the vehicle in equilibrium
u_hover = np.array([m.hover_thrust, 0.0, 0.0, 0.0])
for _ in range(1000): # 10 seconds
m.run_step(u_hover)
print(f"Final state (max |x|): {np.max(np.abs(m.current_state)):.2e}")
# → ≈ 0 (exact equilibrium with RK4)
Hover with a perturbation and a small roll-torque¶
import numpy as np
from tensoraerospace.aerospacemodel.quadrotor.nonlinear import (
NonlinearQuadrotor,
set_initial_state,
)
# Start with a slight 3-degree roll
m = NonlinearQuadrotor(
x0=set_initial_state(phi=np.deg2rad(3.0)),
dt=0.005,
integrator="rk4",
)
# Hover thrust + small negative roll command (P-feedback)
T_hover = m.hover_thrust
for k in range(2000):
tau_x = -0.05 * m.current_state[6] # proportional feedback on phi
u = np.array([T_hover, tau_x, 0.0, 0.0])
m.run_step(u)
phi_final = np.rad2deg(m.current_state[6])
print(f"Final roll angle: {phi_final:.4f}°")
Initial-state helper¶
from tensoraerospace.aerospacemodel.quadrotor.nonlinear import set_initial_state
# Start: 5 m altitude (NED z = -5), 5° pitch
x0 = set_initial_state(z_e=-5.0, theta=np.deg2rad(5.0))
Validation¶
ODE correctness is verified by five analytical tests in
tests/aerospacemodel/quadrotor_test.py:
| Test | Check | Tolerance |
|---|---|---|
| Hover equilibrium | \(T = m\,g\), zero torque → state unchanged over 10 s | \(\max\|x\| < 10^{-9}\) |
| Free fall, no drag | \(z_e(t) = \tfrac{1}{2}g\,t^2\) | \(\delta < 10^{-3}\) |
| Free fall with drag | \(z(t) = v_\infty t + v_\infty\tau(e^{-t/\tau} - 1)\) | \(\delta < 10^{-3}\) |
| Single-step roll-torque | \(p \approx \tau_x \cdot dt / J_x\) | \(\delta < 10^{-9}\) |
| Gyroscopic coupling | \(\dot p = (J_y-J_z)\,q\,r / J_x\) at \(q=r=1\) | \(\delta < 10^{-12}\) |
Plus 6 sanity checks: input dimensions, history accumulation, both integrator backends (Euler + RK4), invalid-integrator-name handling.
Allocation (mixer): bridge between virtual control and rotor speeds¶
The bare NonlinearQuadrotor model takes the virtual vector
\((T, \tau_x, \tau_y, \tau_z)\) — the level at which PID/MPC/RL
controllers naturally operate. To reproduce rotor-level failures
(needed for the FTC scenarios in Lu 2019, Wang 2019, Lanzon 2015), a
bidirectional XConfigAllocator is provided:
with \(a = L/\sqrt 2\), \(L\) the arm length, \(k_T\) the thrust coefficient, and \(k_M\) the yaw-torque coefficient. The matrix is full-rank, so the inverse is well-defined.
from tensoraerospace.aerospacemodel.quadrotor import default_allocator
alloc = default_allocator() # k_T=7.5e-6, k_M≈0.016·k_T, arm=0.225
v = alloc.mix(omega_squared) # 4 ω² → [T, τ]
omega2 = alloc.unmix(v) # [T, τ] → 4 ω² (may be < 0 for non-realisable v)
omega2 = alloc.saturate(omega2, 0, 1000) # clip to physical bounds
The NonlinearQuadrotor-v0 env supports two action modes:
# Mode "virtual" (default): action = [T, τ_x, τ_y, τ_z]
env = gym.make("NonlinearQuadrotor-v0", initial_state=np.zeros(12),
number_time_steps=1000, action_space="virtual")
# Mode "rotor": action = [ω₁², ω₂², ω₃², ω₄²]; env applies allocator internally
env = gym.make("NonlinearQuadrotor-v0", initial_state=np.zeros(12),
number_time_steps=1000, action_space="rotor")
Without damage_profile both modes are equivalent (round-trip mix↔unmix).
Damage subsystem (rotor-level events)¶
To reproduce the canonical FTC scenarios from the literature, the env ships with an event-driven rotor-level failure system. Each rotor has an effectiveness coefficient \(\mu_i \in [0, 1]\), and the env applies \(\omega^2_{i,\text{eff}} = \mu_i \cdot \omega^2_{i,\text{cmd}}\) before mixing back to \((T, \tau)\).
Three event types:
| Event | Semantics | Source |
|---|---|---|
RotorDamageEvent(rotor_id, mu) |
Instantaneous effectiveness loss on rotor \(i\) | Lu et al. 2019 |
RotorLossEvent(rotor_id) |
Complete failure (\(\mu = 0\)) | Lanzon et al. 2015 |
MotorEfficiencyDecay(rotor_id, tau, mu_floor) |
Exponential wear \(\dot\mu = -(1/\tau)(\mu - \mu_\text{floor})\) | gradual wear |
Three ready-made presets: LANZON_M1_LOSS, LU_M1_50PCT_LOSS,
WEAR_DEGRADATION_M3. Import from
tensoraerospace.aerospacemodel.quadrotor.damage.
from tensoraerospace.aerospacemodel.quadrotor.damage import LANZON_M1_LOSS
env = gym.make("NonlinearQuadrotor-v0", initial_state=np.zeros(12),
number_time_steps=2000, damage_profile=LANZON_M1_LOSS)
obs, _ = env.reset()
T_hover = 1.5 * 9.81
for k in range(2000):
obs, r, term, trunc, info = env.step(np.array([T_hover, 0, 0, 0]))
if "damage_events_triggered" in info:
print(f"t={k*0.01}s: {info['damage_events_triggered']}")
Without damage_profile the env is bit-identical to the no-damage
baseline (rotor-effectiveness \(\mu = 1\) on all four motors).
Current limitations¶
- ZYX 321 Euler angles — the model has gimbal lock at \(|\theta| = \pi/2\). Acrobatic manoeuvres (loops, flips) need a quaternion variant — future extension.
- Linear drag only — no quadratic-in-velocity term (significant above ~10 m/s cruise) and no blade-flap aerodynamics.
- Symmetric X-frame in the allocator — asymmetric frames (Y, H, hex/octocopter) need their own allocator class.
- Element-wise saturation only — no thrust-priority allocation (Faessler 2017) for control-saturation regimes where one channel should be preserved over another.
References¶
Dynamics and coordinate systems¶
- Stevens, B. L., Lewis, F. L., Johnson, E. N. (2015). Aircraft Control and Simulation, 3rd ed. Wiley. — methodological foundation for the 6-DoF rigid-body equations in the body-fixed frame (NED, ZYX Euler convention).
- PX4 Airframe Reference: Quadrotor X
— motor numbering and rotation-direction convention used in
XConfigAllocator.
Reference platform parameters¶
- AscTec Hummingbird / Pelican research-class quadrotors — typical values \(m \approx 1.5\) kg, \(J_x = J_y \approx 0.021\) kg·m², \(J_z \approx 0.037\) kg·m², 22.5 cm arm. Widely used in academic UAV FTC papers.
FTC scenarios (sources for the damage subsystem and presets)¶
- Lu, P., Yu, B., van Kampen, E.-J., Chu, Q. P. (2019).
Quadrotor Fault Tolerant Incremental Sliding Mode Control driven by
Sliding Mode Disturbance Observers. Aerospace Science and Technology,
87:417–430. DOI: 10.1016/j.ast.2019.03.001
— multiplicative rotor effectiveness loss; basis for the
LU_M1_50PCT_LOSSpreset and theRotorDamageEventclass. 129 citations. - Wang, X., van Kampen, E.-J., Chu, Q. P. (2019). Quadrotor fault-tolerant incremental nonsingular terminal sliding mode control. Aerospace Science and Technology, 95:105514. DOI: 10.1016/j.ast.2019.105514 — parallel work using terminal sliding mode; same fault model.
- Lanzon, A., Freddi, A., Longhi, S. (2015). Active fault-tolerant
control for quadrotors subjected to a complete rotor failure.
IEEE/RSJ IROS 2015. DOI: 10.1109/IROS.2015.7354046
— extreme case of complete rotor failure (spin-mode recovery);
basis for
LANZON_M1_LOSSand theRotorLossEventclass.
Related TensorAeroSpace topics¶
- Aircraft damage modeling (F-16) — a more developed damage subsystem for fixed-wing aircraft, with strip-theory and Huygens-Steiner inertia recompute.
- AIDI — adaptive INDI-style architecture applicable to this quadrotor model for fault-tolerant control.
- iADP, IM-GDHP — online adaptive critics that recover from plant changes within tens of milliseconds.
Python API¶
NonlinearQuadrotor(x0, selected_state_output=None, t0=0, dt=0.01, integrator='rk4')
¶
Bases: ModelBase
Pure-numpy 6-DoF rigid-body quadrotor in NED frame.
Action: [T, tau_x, tau_y, tau_z] — collective thrust (N) and
body-frame torques (N·m). The model is "abstract" in the actuation:
motor mixing / RPM allocation is not part of this class. Use the
helper in utils.py (TODO) or the env layer for that.
State: see module docstring of __init__.py for the 12-element
layout.
QuadrotorParameters(m=1.5, g=9.81, Jx=0.0211, Jy=0.0211, Jz=0.0366, arm_length=0.225, kdx=0.1, kdy=0.1, kdz=0.2, thrust_max=30.0, thrust_min=0.0, torque_max=1.5)
dataclass
¶
Inertial + geometric parameters of a single rigid-body quadrotor.
quadrotor_ode_6dof(x, u, t, params)
¶
Right-hand side of the 6-DoF quadrotor ODE.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
ndarray
|
state vector (12,) — see module docstring for layout. |
required |
u
|
ndarray
|
control vector (4,) — |
required |
t
|
float
|
current time, s (unused — model is autonomous). |
required |
params
|
QuadrotorParameters
|
inertial / geometric parameters. |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
|
XConfigAllocator(k_T=7.5e-06, k_M=1.2e-07, arm_length=0.225)
dataclass
¶
Bidirectional X-quad rotor↔virtual allocator.
The allocation matrix :math:M is defined by
.. math::
\begin{bmatrix} T \\ \tau_x \\ \tau_y \\ \tau_z \end{bmatrix}
= M \begin{bmatrix} \omega_1^2 \\ \omega_2^2 \\ \omega_3^2 \\ \omega_4^2 \end{bmatrix},
where (with :math:a = L/\sqrt 2)
.. math::
M = \begin{bmatrix}
k_T & k_T & k_T & k_T \\
-k_T a & k_T a& k_T a& -k_T a \\
k_T a & -k_T a& k_T a& -k_T a \\
k_M & k_M & -k_M & -k_M
\end{bmatrix}.
The matrix is full-rank for non-degenerate parameters, so the
inverse :func:unmix is well-defined.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
k_T
|
float
|
thrust coefficient (N per :math: |
7.5e-06
|
k_M
|
float
|
yaw-torque coefficient (N·m per :math: |
1.2e-07
|
arm_length
|
float
|
distance from CG to each rotor, m. |
0.225
|
matrix
property
¶
Read-only 4×4 forward allocation matrix M.
matrix_inverse
property
¶
Read-only 4×4 inverse matrix M⁻¹.
mix(omega_squared)
¶
Map :math:(\omega_1^2, \ldots, \omega_4^2) → :math:(T, \tau_x, \tau_y, \tau_z).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
omega_squared
|
ndarray
|
shape |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
Virtual control vector |
unmix(virtual)
¶
Map :math:(T, \tau_x, \tau_y, \tau_z) → :math:(\omega_1^2, \ldots, \omega_4^2).
Note: the result may contain negative entries when the
commanded virtual triple is not realisable by 4 unidirectional
rotors (e.g. requesting :math:T = 0 with non-zero pitch torque).
The caller should pass the result through :func:saturate before
feeding it into :func:mix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
virtual
|
ndarray
|
shape |
required |
Returns:
| Type | Description |
|---|---|
ndarray
|
Rotor speeds-squared |
ndarray
|
Possibly negative — pass through :func: |
saturate(omega_squared, omega_min=0.0, omega_max=1000.0)
¶
Clip rotor speeds-squared into :math:[\omega_\min^2, \omega_\max^2].
Element-wise clip — does not preserve any virtual quantity. For thrust-priority allocation strategies, see the literature (e.g. Faessler 2017); this method is the simplest sane default.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
omega_squared
|
ndarray
|
shape |
required |
omega_min
|
float
|
lower rotor-speed bound, rad/s. Default 0 (perpetual zero allowed — useful for rotor-loss scenarios). Must be non-negative. |
0.0
|
omega_max
|
float
|
upper rotor-speed bound, rad/s. Default 1000 (≈ 9550 RPM). |
1000.0
|
Returns:
| Type | Description |
|---|---|
ndarray
|
Clipped |
RotorDamageEvent(trigger_time, rotor_id, label=None, mu=1.0)
dataclass
¶
Bases: DamageEvent
Instantaneous multiplicative effectiveness loss on rotor rotor_id.
mu is the surviving fraction (1.0 = healthy, 0.0 = total loss).
Mirrors the convention in Lu 2019 (ISMC FTC):
:math:\omega^2_{i,\text{eff}} = \mu \cdot \omega^2_{i,\text{cmd}}.
RotorLossEvent(trigger_time, rotor_id, label=None)
dataclass
¶
Bases: DamageEvent
Complete rotor stop — mu = 0 (Lanzon 2015 catastrophic case).
MotorEfficiencyDecay(trigger_time, rotor_id, label=None, tau=1.0, mu_floor=0.0)
dataclass
¶
Bases: DamageEvent
Exponential effectiveness decay starting from trigger_time.
From the trigger onward, mu[i] evolves as
.. math:: \dot \mu_i = -(1/\tau)(\mu_i - \mu_\text{floor}),
so mu exponentially approaches mu_floor with time-constant
tau. Applies a one-shot setter at trigger time; the actual
decay is integrated by :meth:RotorDamageState.step_decay on each
env step.
RotorDamageManager(profile=None)
¶
Owns :class:RotorDamageState and replays events over the episode.
Used by the env layer: on every integrator tick the env calls
:meth:update with the current and previous timestamps; events
that fall in that window are applied to the state. Time-decay
rotors are advanced one Euler step.
reset(*, seed=None)
¶
Clear all damage and re-baseline (called by env.reset).
seed is accepted for compatibility with gym.Env.reset.
inject_event(event)
¶
Add a one-shot event for this episode (single-fire).
update(t_current, t_previous, dt)
¶
Apply events in (t_previous, t_current] and advance decay.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
t_current
|
float
|
time at the END of the integrator step. |
required |
t_previous
|
float
|
time at the START of the integrator step. |
required |
dt
|
float
|
integrator step size (used by |
required |
Returns:
| Type | Description |
|---|---|
list[DamageEvent]
|
List of events that fired during this step (for logging). |
NonlinearQuadrotorEnv(initial_state, number_time_steps, *, dt=0.01, integrator='rk4', action_space='virtual', allocator=None, damage_profile=None, damage_event_callback=None, omega_min=0.0, omega_max=1000.0)
¶
Bases: Env
Gymnasium env over the pure-numpy nonlinear 6-DoF quadrotor.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
initial_state
|
ndarray
|
12-element initial state. See
:mod: |
required |
number_time_steps
|
int
|
Episode length cap (steps). |
required |
dt
|
float
|
Discretisation step (s). Default 0.01. |
0.01
|
integrator
|
Literal['euler', 'rk4']
|
|
'rk4'
|
action_space
|
Literal['virtual', 'rotor']
|
|
'virtual'
|
allocator
|
Optional[XConfigAllocator]
|
X-config allocator to use when |
None
|
damage_profile
|
Optional[DamageProfile]
|
Optional :class: |
None
|
damage_event_callback
|
Optional[Callable[[Any, Any], None]]
|
Optional |
None
|
omega_min
|
float
|
Lower rotor-speed bound, rad/s. Used in the env's saturation step before mixing. Default 0. |
0.0
|
omega_max
|
float
|
Upper rotor-speed bound, rad/s. Default 1000. |
1000.0
|
