From 42669d91fb4234bc73144014f824becd9846ead7 Mon Sep 17 00:00:00 2001 From: Zaffer <51871197+Zaffer@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:43:02 -0400 Subject: [PATCH 1/3] Fix gyroscopic precession sign: roll term, not pitch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The propeller gyroscopic torque had the wrong sign on the ROLL axis (row 0 of torque_inertia), in both dynamics (numeric) and symbolic_dynamics. The gyroscopic torque is -ω × h with net rotor angular momentum h = h_z·ẑ, giving (-q·h_z, +p·h_z, 0): the roll and pitch rows must have OPPOSITE signs. The subtlety is that S = Σ mixing_matrix[-1]·ω_rotor uses the yaw-torque-direction row, i.e. the NEGATIVE of the physical spin, so h_z = -prop_inertia·S. Hence: roll (x) = -q·h_z = +prop_inertia·ang_vel[1]·S (code had a leading '-', the bug) pitch (y) = +p·h_z = -prop_inertia·ang_vel[0]·S (code was already correct) The z (spin-up/down reaction) row is already correct, and is in fact what pins the convention: it is consistent only with spin = -mixing_matrix[-1]. Verified against -ω × h to machine precision on all axes; numeric == symbolic still holds for ang_vel_dot. Only matters for non-negligible prop_inertia / larger quads during yaw-type maneuvers; negligible for a Crazyflie. Co-Authored-By: Claude Opus 4.8 (1M context) --- crazyflow/dynamics/first_principles/dynamics.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crazyflow/dynamics/first_principles/dynamics.py b/crazyflow/dynamics/first_principles/dynamics.py index 6f56ab1..03b21b2 100644 --- a/crazyflow/dynamics/first_principles/dynamics.py +++ b/crazyflow/dynamics/first_principles/dynamics.py @@ -139,9 +139,17 @@ def dynamics( rotor_vel_dot_rads = ( rotor_vel_dot * rpm_to_rad if rotor_vel_dot is not None else xp.zeros_like(rotor_vel) ) + # Gyroscopic torque -ω × h, with net rotor angular momentum h = h_z·ẑ. + # -ω × (0,0,h_z) = (-q·h_z, +p·h_z, 0): the roll and pitch rows have OPPOSITE signs. + # Here S = Σ mixing_matrix[-1]·ω_rotor is the yaw-torque-direction sum, i.e. the NEGATIVE of + # the physical spin direction (same row used, correctly, for the drag reaction torque and the + # z row below). Hence h_z = -prop_inertia·S, and substituting: + # roll (x) = -q·h_z = +prop_inertia·ang_vel[1]·S + # pitch (y) = +p·h_z = -prop_inertia·ang_vel[0]·S + # The z row is the spin-up/down reaction torque. torque_inertia = prop_inertia * xp.stack( [ - -ang_vel[..., 1] * xp.sum(mixing_matrix[..., -1, :] * rotor_vel_rads, axis=-1), + ang_vel[..., 1] * xp.sum(mixing_matrix[..., -1, :] * rotor_vel_rads, axis=-1), -ang_vel[..., 0] * xp.sum(mixing_matrix[..., -1, :] * rotor_vel_rads, axis=-1), xp.sum(mixing_matrix[..., -1, :] * rotor_vel_dot_rads, axis=-1), ], @@ -260,8 +268,11 @@ def symbolic_dynamics( rpm_to_rad = 2 * cs.pi / 60 rotor_vel_rads = symbols.rotor_vel * rpm_to_rad rotor_vel_dot_rads = rotor_vel_dot * rpm_to_rad if model_rotor_vel else symbols.rotor_vel * 0.0 + # Gyroscopic torque -ω × h (see dynamics() for the full derivation): the roll row is + # +ang_vel[1]·S and the pitch row is -ang_vel[0]·S (opposite signs), where S uses the + # yaw-torque-direction row (= -physical spin), so h_z = -prop_inertia·S. torque_inertia = prop_inertia * cs.vertcat( - -symbols.ang_vel[1] * cs.sum(mixing_matrix[-1, :] * rotor_vel_rads), + symbols.ang_vel[1] * cs.sum(mixing_matrix[-1, :] * rotor_vel_rads), -symbols.ang_vel[0] * cs.sum(mixing_matrix[-1, :] * rotor_vel_rads), cs.sum(mixing_matrix[-1, :] * rotor_vel_dot_rads), ) From 50866bab85835c929344fe82c0a8dfe04708907d Mon Sep 17 00:00:00 2001 From: Zaffer <51871197+Zaffer@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:32:43 -0400 Subject: [PATCH 2/3] Update dynamics.py --- crazyflow/dynamics/first_principles/dynamics.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/crazyflow/dynamics/first_principles/dynamics.py b/crazyflow/dynamics/first_principles/dynamics.py index 03b21b2..a374cba 100644 --- a/crazyflow/dynamics/first_principles/dynamics.py +++ b/crazyflow/dynamics/first_principles/dynamics.py @@ -139,14 +139,7 @@ def dynamics( rotor_vel_dot_rads = ( rotor_vel_dot * rpm_to_rad if rotor_vel_dot is not None else xp.zeros_like(rotor_vel) ) - # Gyroscopic torque -ω × h, with net rotor angular momentum h = h_z·ẑ. - # -ω × (0,0,h_z) = (-q·h_z, +p·h_z, 0): the roll and pitch rows have OPPOSITE signs. - # Here S = Σ mixing_matrix[-1]·ω_rotor is the yaw-torque-direction sum, i.e. the NEGATIVE of - # the physical spin direction (same row used, correctly, for the drag reaction torque and the - # z row below). Hence h_z = -prop_inertia·S, and substituting: - # roll (x) = -q·h_z = +prop_inertia·ang_vel[1]·S - # pitch (y) = +p·h_z = -prop_inertia·ang_vel[0]·S - # The z row is the spin-up/down reaction torque. + torque_inertia = prop_inertia * xp.stack( [ ang_vel[..., 1] * xp.sum(mixing_matrix[..., -1, :] * rotor_vel_rads, axis=-1), @@ -268,9 +261,7 @@ def symbolic_dynamics( rpm_to_rad = 2 * cs.pi / 60 rotor_vel_rads = symbols.rotor_vel * rpm_to_rad rotor_vel_dot_rads = rotor_vel_dot * rpm_to_rad if model_rotor_vel else symbols.rotor_vel * 0.0 - # Gyroscopic torque -ω × h (see dynamics() for the full derivation): the roll row is - # +ang_vel[1]·S and the pitch row is -ang_vel[0]·S (opposite signs), where S uses the - # yaw-torque-direction row (= -physical spin), so h_z = -prop_inertia·S. + torque_inertia = prop_inertia * cs.vertcat( symbols.ang_vel[1] * cs.sum(mixing_matrix[-1, :] * rotor_vel_rads), -symbols.ang_vel[0] * cs.sum(mixing_matrix[-1, :] * rotor_vel_rads), From 3f8e02bdbd8710bf5d15268b09a9e585f21d14ab Mon Sep 17 00:00:00 2001 From: Zaffer <51871197+Zaffer@users.noreply.github.com> Date: Wed, 8 Jul 2026 01:33:25 -0400 Subject: [PATCH 3/3] Update dynamics.py --- crazyflow/dynamics/first_principles/dynamics.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/crazyflow/dynamics/first_principles/dynamics.py b/crazyflow/dynamics/first_principles/dynamics.py index a374cba..70ee0fd 100644 --- a/crazyflow/dynamics/first_principles/dynamics.py +++ b/crazyflow/dynamics/first_principles/dynamics.py @@ -139,7 +139,6 @@ def dynamics( rotor_vel_dot_rads = ( rotor_vel_dot * rpm_to_rad if rotor_vel_dot is not None else xp.zeros_like(rotor_vel) ) - torque_inertia = prop_inertia * xp.stack( [ ang_vel[..., 1] * xp.sum(mixing_matrix[..., -1, :] * rotor_vel_rads, axis=-1), @@ -261,7 +260,6 @@ def symbolic_dynamics( rpm_to_rad = 2 * cs.pi / 60 rotor_vel_rads = symbols.rotor_vel * rpm_to_rad rotor_vel_dot_rads = rotor_vel_dot * rpm_to_rad if model_rotor_vel else symbols.rotor_vel * 0.0 - torque_inertia = prop_inertia * cs.vertcat( symbols.ang_vel[1] * cs.sum(mixing_matrix[-1, :] * rotor_vel_rads), -symbols.ang_vel[0] * cs.sum(mixing_matrix[-1, :] * rotor_vel_rads),