Skip to content

fix(matrix): clamp asin() domain in Euler(Dcm) to prevent NaN pitch near gimbal lock#27872

Open
94xhn wants to merge 3 commits into
PX4:mainfrom
94xhn:fix/matrix-euler-dcm-asin-nan-gimbal-lock
Open

fix(matrix): clamp asin() domain in Euler(Dcm) to prevent NaN pitch near gimbal lock#27872
94xhn wants to merge 3 commits into
PX4:mainfrom
94xhn:fix/matrix-euler-dcm-asin-nan-gimbal-lock

Conversation

@94xhn

@94xhn 94xhn commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Problem

Euler(const Dcm<Type> &dcm) computes pitch as:

theta() = std::asin(-dcm(2, 0));

where dcm(2, 0) = 2 * (b*d - a*c) is built from a unit quaternion in Dcm(const Quaternion<Type>&) (src/lib/matrix/matrix/Dcm.hpp). In exact arithmetic -dcm(2, 0) is always within [-1, 1] for a proper rotation matrix, but float rounding from quaternion normalization (and other operations) can push it a ULP or two outside that range, which makes asin() return NaN. A targeted perturbation sweep around a 90-degree-pitch attitude (small random perturbations of a unit quaternion, renormalized exactly as Quaternion::normalize() would) showed this happens for roughly 15% of such perturbations — i.e. this is not a rare corner case near the pitch singularity, it's a fairly common one.

Once theta() is NaN, it also defeats the near-gimbal-lock special case immediately below it: both fabs(theta() - M_PI/2) < 1e-3 and fabs(theta() + M_PI/2) < 1e-3 are false when theta() is NaN (NaN comparisons are always false), so phi()/psi() silently fall through to the general-case atan2() formulas instead of the intended gimbal-lock handling.

Real-world consequence: src/modules/commander/failure_detector/FailureDetector.cpp reads pitch via Eulerf(Quatf(attitude.q)).theta() and compares fabsf(pitch) > max_pitch to drive the FD_FAIL_P attitude failure detector. If pitch is NaN, that comparison is always false, so a genuinely extreme pitch sample can silently fail to trip the failure detector. The same file explicitly composes a 90-degree pitch rotation for tailsitter handling a few lines below, confirming near-gimbal-lock attitudes are a normal operating condition for a PX4-supported vehicle class (tailsitter VTOL, every hover<->forward-flight transition), not just a theoretical edge case. src/modules/airspeed_selector/AirspeedValidator.cpp also reads matrix::Eulerf(att_q).theta() for its first-principle airspeed check; it already has a defensive PX4_ISFINITE(pitch) bailout, so the severity there is lower, but it's still an accuracy gap right at extreme pitch.

Solution

Clamp the asin() argument to [-1, 1] before calling it, mirroring the existing acos() guard already used for exactly this reason in MapProjection::project() (src/lib/geo/geo.cpp):

const Type sin_theta = std::fmin(std::fmax(-dcm(2, 0), Type(-1)), Type(1));
theta() = std::asin(sin_theta);

This is the identity for any legitimately in-range input, so it changes nothing for normal attitudes — it only fixes the out-of-domain rounding case. Added #include <cmath> since the header now uses std::fmin/std::fmax explicitly rather than relying on transitive includes.

Test plan

Added MatrixAttitudeTest.EulerFromDcmAsinDomainNearGimbalLock to src/lib/matrix/test/MatrixAttitudeTest.cpp: constructs a unit quaternion representing a small, physically realistic perturbation away from an exact 90-degree-pitch attitude (the kind of floating-point noise ordinary quaternion normalization produces), asserts the resulting dcm(2, 0) does exceed unit magnitude (so the test actually exercises the domain edge instead of passing vacuously), then asserts Euler(dcm).theta() is finite and equals pi/2.

I compiled and ran the real, unmodified test file directly against the standalone src/lib/matrix headers with system gtest 1.14 (WSL Ubuntu 24.04, g++ 13.3.0), stubbing only the generated px4_platform_common/defines.h dependency (a handful of float Pi literals copied verbatim from the real header, needed only because it otherwise pulls in a generated board-config file a standalone build doesn't produce):

  • Against the pre-fix Euler.hpp: the new regression test fails with theta() == nan (Value of: std::isfinite(euler.theta()) / Actual: false / Expected: true), while the existing MatrixAttitudeTest.Attitude test still passes — confirming the bug was previously uncovered by the existing suite.
  • Against the fix: both tests pass, and theta() == 1.5707963... (pi/2) as expected.

I also built a standalone reproduction program (g++ 8.1.0 MinGW, C++17) linking the real, unmodified matrix/*.hpp headers directly, that reproduces the FailureDetector-style comparison for a unit quaternion at a genuinely realistic ~90-degree pitch:

  • Pre-fix: dcm(2,0) = -1.000000119 (magnitude > 1.0), euler.theta() = nan, phi() = -0.58 (wrong — general-case fallthrough instead of the intended forced-zero gimbal-lock branch), and fabsf(pitch) > max_pitch evaluates to false (failure not flagged) despite the genuinely extreme pitch.
  • Post-fix: euler.theta() = 1.570796371 (pi/2, correct), phi() correctly forced to 0 (gimbal-lock branch taken), and the check correctly evaluates to true (failure flagged).

Disclosure

Generative AI (Claude) was used to help investigate this issue, implement, and verify this fix. All changes were reviewed by me before submission.

…ear gimbal lock

Euler(const Dcm<Type> &dcm) computes theta() as:

    theta() = std::asin(-dcm(2, 0));

In exact arithmetic, -dcm(2, 0) is always within [-1, 1] for a proper
rotation matrix. In floating point, quaternion normalization and other
rounding can push it a ULP or two outside that range, which makes
asin() return NaN. This is most likely to happen near the +-90 degree
pitch singularity (gimbal lock) -- an attitude tailsitter VTOL
vehicles fly through on every hover<->forward-flight transition, and
one that can also occur transiently for any vehicle during aggressive
maneuvers.

This is the same class of issue already guarded against for acos() in
MapProjection::project() (src/lib/geo/geo.cpp), which clamps its
argument to [-1, 1] before calling acos() for exactly this reason;
Euler(Dcm)'s asin() call had no equivalent guard.

Once theta() is NaN, it also defeats the near-gimbal-lock special
case just below it: both `fabs(theta() - pi/2) < 1e-3` and
`fabs(theta() + pi/2) < 1e-3` are false for a NaN theta() (NaN
comparisons are always false), so phi()/psi() silently fall through
to the general-case atan2() formulas instead of the intended
gimbal-lock handling.

Real-world consequence: src/modules/commander/failure_detector/
FailureDetector.cpp reads pitch via Eulerf(Quatf(attitude.q)).theta()
and compares fabsf(pitch) > max_pitch to decide whether to trigger the
FD_FAIL_P attitude failure detector. If pitch is NaN, that comparison
is always false, so a genuinely extreme pitch sample can silently fail
to trip the failure detector. The same file explicitly composes a 90
degree pitch rotation for its tailsitter handling a few lines below,
confirming near-gimbal-lock attitudes are a normal operating condition
for a vehicle class PX4 supports, not just a theoretical edge case.

Fix: clamp the asin() argument to [-1, 1] before calling asin(),
mirroring the existing acos() guard in project().

Test plan:
- Added MatrixAttitudeTest.EulerFromDcmAsinDomainNearGimbalLock to
  src/lib/matrix/test/MatrixAttitudeTest.cpp: constructs a unit
  quaternion representing a small, physically realistic perturbation
  away from an exact 90 degree pitch attitude (the kind of floating
  point noise ordinary quaternion normalization produces), asserts the
  resulting DCM(2,0) element does exceed unit magnitude (so the test
  actually exercises the domain edge instead of passing vacuously),
  then asserts Euler(dcm).theta() is finite and equals pi/2.
- Compiled and ran the real, unmodified test file directly against
  the standalone src/lib/matrix headers with system gtest (Ubuntu
  24.04, g++ 13.3.0), stubbing only the generated
  px4_platform_common/defines.h dependency (a handful of float Pi
  literals, copied verbatim from platforms/common/include/
  px4_platform_common/defines.h, needed only because that header
  otherwise pulls in a board-config file this standalone build does
  not generate):
    - Against the pre-fix Euler.hpp: the new regression test FAILS
      with theta() == nan, and the existing MatrixAttitudeTest.Attitude
      test still passes (confirming the bug was previously uncovered
      by the existing suite).
    - Against the fix: both tests PASS, and theta() == 1.5707963...
      (pi/2) as expected.
- Also verified with a standalone reproduction program that links the
  real (unmodified) src/lib/matrix headers directly and reproduces the
  FailureDetector-style comparison: before the fix, fabsf(nan) >
  max_pitch evaluates to false (failure NOT flagged) for a vehicle at
  a genuinely extreme ~90 degree pitch; after the fix it correctly
  evaluates to true (failure flagged).

Signed-off-by: yi chen <94xhn1@gmail.com>
@github-actions github-actions Bot added kind:bug Something is broken or behaving incorrectly. kind:test Adds or improves tests. scope:testing Unit, integration, fuzzing, or test data. labels Jul 12, 2026
The previous commit's asin() domain clamp used std::fmin(std::fmax(...)),
which does not compile on the NuttX cross-compiler toolchain (its
stripped libcxx headers under -nostdinc++ do not provide std::fmin/
std::fmax), breaking every NuttX-targeted CI build.

Use the library's own matrix::constrain() helper (already used
throughout mathlib for the same min/upper-bound clamping pattern)
instead, which avoids the extra <cmath> functions entirely.

Signed-off-by: yi chen <94xhn1@gmail.com>
Comment thread src/lib/matrix/matrix/Euler.hpp Outdated
// e.g. tailsitter VTOL vehicles fly through during every transition.
// This mirrors the equivalent acos() guard already used for the same
// reason in MapProjection::project() (src/lib/geo/geo.cpp).
const Type sin_theta = constrain(-dcm(2, 0), Type(-1), Type(1));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[error] clang-diagnostic-error [error]
no matching function for call to constrain

….hpp

The previous commit (a0c9882) replaced std::fmin/std::fmax with an
unqualified constrain() call, assuming it lived directly in
namespace matrix. It actually lives one level deeper, in
matrix::typeFunction (see Matrix.hpp's own typeFunction::max/min usage
and MatrixAssignmentTest.cpp), so the unqualified call did not resolve
and broke every CI build target with the same compile error.

Euler.hpp also did not include Matrix.hpp itself, relying on whatever
aggregator (math.hpp) happened to include it — math.hpp includes
Euler.hpp before Matrix.hpp, so even a qualified call could depend on
include order in other translation units that pull in Euler.hpp
directly.

Fix: qualify the call as typeFunction::constrain(...), matching the
existing convention, and include "Matrix.hpp" directly so the fix does
not depend on transitive include order.

Verified locally with a standalone harness (matrix/math.hpp +
Quaternion -> Dcm -> Euler construction, including the near +-90
degree pitch singularity branch) compiled with g++ -std=c++17.

Signed-off-by: yi chen <94xhn1@gmail.com>
@@ -83,7 +87,17 @@ class Euler : public Vector<Type, 3>
*/
Euler(const Dcm<Type> &dcm)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[error] clang-diagnostic-error [error]
no template named Dcm

@github-actions

Copy link
Copy Markdown
Contributor

🔎 FLASH Analysis

px4_fmu-v5x [Total VM Diff: 96 byte (0 %)]
    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.0%     +96  +0.0%     +96    .text
    +7.7%    +108  +7.7%    +108    EKF2::PublishLocalPosition()
     +20%    +104   +20%    +104    math::Utilities::updateYawInRotMat<>()
    [NEW]     +84  [NEW]     +84    CSWTCH.174
    +3.3%     +80  +3.3%     +80    Sih::print_status()
    [NEW]     +60  [NEW]     +60    CSWTCH.1937
    +8.8%     +52  +8.8%     +52    Sih::publish_ground_truth()
    [NEW]     +48  [NEW]     +48    CSWTCH.90
    [NEW]     +42  [NEW]     +42    CSWTCH.2551
   -98.3%     +31 -98.3%     +31    [11 Others]
    [NEW]     +24  [NEW]     +24    CSWTCH.66
    +3.0%     +24  +3.0%     +24    EKFGSF_yaw::ahrsAlignYaw()
    [DEL]     -15  [DEL]     -15    CSWTCH.3467
    [DEL]     -24  [DEL]     -24    CSWTCH.63
    -3.7%     -28  -3.7%     -28    AirspeedValidator::check_first_principle()
    [DEL]     -42  [DEL]     -42    CSWTCH.2548
    [DEL]     -48  [DEL]     -48    CSWTCH.86
   -16.2%     -48 -16.2%     -48    HomePosition::fillLocalHomePos()
    [DEL]     -60  [DEL]     -60    CSWTCH.1934
    [DEL]     -84  [DEL]     -84    CSWTCH.167
   -12.1%     -88 -12.1%     -88    FixedWingModeManager::vehicle_attitude_poll()
   -19.4%    -124 -19.4%    -124    FwLateralLongitudinalControl::updateAttitude()
  +0.1% +1.81Ki  [ = ]       0    .debug_abbrev
  +0.1%    +104  [ = ]       0    .debug_aranges
  -0.0%    -136  [ = ]       0    .debug_frame
  +0.1% +22.2Ki  [ = ]       0    .debug_info
  +0.1% +6.24Ki  [ = ]       0    .debug_line
    +100%      +3  [ = ]       0    [Unmapped]
    +0.1% +6.24Ki  [ = ]       0    [section .debug_line]
  +0.5% +19.1Ki  [ = ]       0    .debug_loclists
  +0.4% +2.06Ki  [ = ]       0    .debug_rnglists
  +0.0%      +4  [ = ]       0    .debug_str
  -0.0%     -32  [ = ]       0    .symtab
    [DEL]     -32  [ = ]       0    C.48.0
    [NEW]     +32  [ = ]       0    C.51.0
    [DEL]     -32  [ = ]       0    CSWTCH.167
    [NEW]     +32  [ = ]       0    CSWTCH.174
    [DEL]     -32  [ = ]       0    CSWTCH.1934
    [NEW]     +32  [ = ]       0    CSWTCH.1937
    [DEL]     -32  [ = ]       0    CSWTCH.2548
    [NEW]     +32  [ = ]       0    CSWTCH.2551
    [DEL]     -48  [ = ]       0    CSWTCH.3467
    [NEW]     +48  [ = ]       0    CSWTCH.3469
    [DEL]     -32  [ = ]       0    CSWTCH.63
    [NEW]     +32  [ = ]       0    CSWTCH.66
    [DEL]     -48  [ = ]       0    CSWTCH.86
    [NEW]     +48  [ = ]       0    CSWTCH.90
     +33%     +16  [ = ]       0    Commander::handleCommandActuatorTest()
   -50.0%     -16  [ = ]       0    Commander::updateParameters()
   -50.0%     -16  [ = ]       0    FwLateralLongitudinalControl::checkLowHeightConditions()
     +33%     +16  [ = ]       0    FwLateralLongitudinalControl::updateAirspeed()
     +33%     +16  [ = ]       0    FwLateralLongitudinalControl::updateAttitude()
   -50.0%     -16  [ = ]       0    FwLateralLongitudinalControl::update_control_state()
   -103.1%     -32  [ = ]       0    [8 Others]
  -1.1%     -96  [ = ]       0    [Unmapped]
  +0.1% +51.3Ki  +0.0%     +96    TOTAL

px4_fmu-v6x [Total VM Diff: -40 byte (-0 %)]
    FILE SIZE        VM SIZE    
 --------------  -------------- 
  +0.1% +1.72Ki  [ = ]       0    .debug_abbrev
  +0.1%     +96  [ = ]       0    .debug_aranges
  -0.0%    -124  [ = ]       0    .debug_frame
  +0.1% +20.7Ki  [ = ]       0    .debug_info
  +0.1% +5.72Ki  [ = ]       0    .debug_line
    [DEL]      -7  [ = ]       0    [Unmapped]
    +0.1% +5.73Ki  [ = ]       0    [section .debug_line]
  +0.5% +17.3Ki  [ = ]       0    .debug_loclists
  +0.3% +1.86Ki  [ = ]       0    .debug_rnglists
    +200%      +2  [ = ]       0    [Unmapped]
    +0.3% +1.85Ki  [ = ]       0    [section .debug_rnglists]
  +0.0%      +4  [ = ]       0    .debug_str
  -0.0%     -32  [ = ]       0    .symtab
    [DEL]     -32  [ = ]       0    C.48.0
    [NEW]     +32  [ = ]       0    C.51.0
    [DEL]     -32  [ = ]       0    CSWTCH.167
    [NEW]     +32  [ = ]       0    CSWTCH.174
    [DEL]     -32  [ = ]       0    CSWTCH.1934
    [NEW]     +32  [ = ]       0    CSWTCH.1937
    [DEL]     -32  [ = ]       0    CSWTCH.2548
    [NEW]     +32  [ = ]       0    CSWTCH.2551
    [DEL]     -48  [ = ]       0    CSWTCH.3467
    [NEW]     +48  [ = ]       0    CSWTCH.3469
    [DEL]     -32  [ = ]       0    CSWTCH.63
    [NEW]     +32  [ = ]       0    CSWTCH.66
    [DEL]     -48  [ = ]       0    CSWTCH.86
    [NEW]     +48  [ = ]       0    CSWTCH.90
     +33%     +16  [ = ]       0    Commander::handleCommandActuatorTest()
   -50.0%     -16  [ = ]       0    Commander::updateParameters()
   -50.0%     -16  [ = ]       0    FwLateralLongitudinalControl::checkLowHeightConditions()
     +33%     +16  [ = ]       0    FwLateralLongitudinalControl::updateAirspeed()
     +33%     +16  [ = ]       0    FwLateralLongitudinalControl::updateAttitude()
   -50.0%     -16  [ = ]       0    FwLateralLongitudinalControl::update_control_state()
   -103.8%     -32  [ = ]       0    [4 Others]
  +0.8%     +40  [ = ]       0    [Unmapped]
  -0.0%     -40  -0.0%     -40    .text
    +7.7%    +108  +7.7%    +108    EKF2::PublishLocalPosition()
     +20%    +104   +20%    +104    math::Utilities::updateYawInRotMat<>()
    [NEW]     +84  [NEW]     +84    CSWTCH.174
    [NEW]     +60  [NEW]     +60    CSWTCH.1937
    [NEW]     +48  [NEW]     +48    CSWTCH.90
    [NEW]     +42  [NEW]     +42    CSWTCH.2551
    [NEW]     +24  [NEW]     +24    CSWTCH.66
    +3.0%     +24  +3.0%     +24    EKFGSF_yaw::ahrsAlignYaw()
   -98.4%     +24 -98.4%     +24    [8 Others]
    [NEW]     +15  [NEW]     +15    CSWTCH.3469
    [DEL]     -12  [DEL]     -12    C.48.0
    [DEL]     -15  [DEL]     -15    CSWTCH.3467
    [DEL]     -24  [DEL]     -24    CSWTCH.63
    -3.7%     -28  -3.7%     -28    AirspeedValidator::check_first_principle()
    [DEL]     -42  [DEL]     -42    CSWTCH.2548
    [DEL]     -48  [DEL]     -48    CSWTCH.86
   -16.2%     -48 -16.2%     -48    HomePosition::fillLocalHomePos()
    [DEL]     -60  [DEL]     -60    CSWTCH.1934
    [DEL]     -84  [DEL]     -84    CSWTCH.167
   -12.1%     -88 -12.1%     -88    FixedWingModeManager::vehicle_attitude_poll()
   -19.4%    -124 -19.4%    -124    FwLateralLongitudinalControl::updateAttitude()
  +0.1% +47.3Ki  -0.0%     -40    TOTAL

Updated: 2026-07-12T18:38:12

@mbjd

mbjd commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

#27873 (comment)

Making shorter, to the point, well thought out comments (or just none where they are not needed) and PR description will not only be appreciated by anyone who reads it but also build your own understanding much more effectively than copying claude output

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind:bug Something is broken or behaving incorrectly. kind:test Adds or improves tests. scope:testing Unit, integration, fuzzing, or test data.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants