Skip to content

Fix sign error in quaternion-to-Euler conversion at Phi = pi - #674

Open
gaoflow wants to merge 2 commits into
pyxem:developfrom
gaoflow:fix-qu2eu-equatorial-euler-sign
Open

Fix sign error in quaternion-to-Euler conversion at Phi = pi#674
gaoflow wants to merge 2 commits into
pyxem:developfrom
gaoflow:fix-qu2eu-equatorial-euler-sign

Conversation

@gaoflow

@gaoflow gaoflow commented Jul 22, 2026

Copy link
Copy Markdown

Description of the change

Rotation.to_euler() returns wrong Bunge Euler angles for 180° rotations about an axis in the xy-plane that is not aligned with x or y (off-axis equatorial two-folds). from_euler(to_euler(r)) then gives a different rotation (the axis is reflected).

The cause is a sign error in the Φ = π singular branch of qu2eu_single (orix/quaternion/_conversions.py), which handles Φ = π ⇔ 180° rotations with w = z = 0:

else:  # Phi = pi
    a = -2 * qu[1] * qu[2]   # wrong sign
    b = qu[1] * qu[1] - qu[2] * qu[2]

Inverting eu2qu at β = π gives qu[1] = -cos(δ), qu[2] = -sin(δ) with δ = (α - γ)/2, so α = arctan2(2·qu[1]·qu[2], qu[1]² - qu[2]²), i.e. a = +2·qu[1]·qu[2]. Both quaternion components carry the minus sign here, so their product is positive — unlike the neighbouring Φ = 0 branch, where only qu[3] is negated and a = -2·qu[0]·qu[3] is correct. This matches Rowenhorst et al. (2015) Eq. A.14. The bug is masked when x or y is zero (2·qu[1]·qu[2] = 0), which is the only equatorial case the existing tests covered.

I audited the other singular branches of the conversion suite (ax2qu/qu2ax at ω = 0, π, om2qu for 180° rotations, ho2ax, homochoric round-trips, and the Φ = 0 branch of qu2eu) via round-trip consistency and found them clean — this is a single localized sign.

Minimal example of the bug fix

>>> import numpy as np
>>> from orix.quaternion import Rotation
>>> r = Rotation.from_axes_angles([1, 1, 0], np.pi)   # 180° about [1, 1, 0]
>>> r2 = Rotation.from_euler(r.to_euler())
>>> np.allclose(r.to_matrix(), r2.to_matrix())
True    # was False before this fix

Notes

  • Added a round-trip regression test for equatorial two-fold rotations in test_conversions.py (jit, pure-Python, and public-API paths).
  • test_get_sample_reduced_fundamental asserted unique(phi1) == [0, π/2] for C4/C6. That held only because the wrong sign collapsed the affected two-fold's phi1 onto π/2; on develop those sampled rotations do not actually round-trip (matrix error 2.0). With the fix phi1 gains its correct degenerate value 3π/2 and the whole sample round-trips, so I updated that expectation.
  • Full pytest orix/tests suite passes locally.

Progress of the PR

  • Unit tests with pytest for all lines
  • Clean code style by running black via pre-commit
  • New features, API changes, and deprecations are mentioned in the unreleased section in CHANGELOG.rst

The Phi = pi branch of qu2eu used a = -2 * qu[1] * qu[2], which negated the
azimuth of the reconstructed axis. Rotation.to_euler() therefore returned
wrong Bunge Euler angles for every 180 degree rotation about an axis in the
xy-plane not aligned with x or y, and from_euler(to_euler(r)) recovered a
reflected rotation rather than r.

The correct term is a = 2 * qu[1] * qu[2], consistent with eu2qu at
beta = pi and Rowenhorst et al. (2015) Eq. A.14. Add a round-trip regression
test for equatorial two-fold rotations, and update the reduced-fundamental
sampling test whose phi1 expectation had absorbed the wrong sign.
@argerlt

argerlt commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Hello, thanks for contributing to ORIX!.

Good catch, and thank you for referencing the equations as well, it made this much easier to verify.

For any other maintainers looking at this issue, another simple proof:

>>> x = Rotation.from_euler(Rotation.from_axes_angles([1, 1, 0], np.pi-1e-12).to_euler())
>>> y = Rotation.from_euler(Rotation.from_axes_angles([1, 1, 0], np.pi-1e-3).to_euler())
>>> print(x.angle_with(y))

array([3.14159265])

These should be less than 1/10th of a degree apart, not almost 180.

I'll add some minor edits this afternoon, then this will be ready for merging.

Additionally, you should add your name to the init file and your name + affiliation to the zenodo.json file. I'll add my best guess as a suggestion later if you don't get to it first. You can also omit it if you choose, but if you contribute to ORIX, you should be credited on the DOI.

@gaoflow

gaoflow commented Jul 26, 2026

Copy link
Copy Markdown
Author

Thanks — and nice extra proof, that one's cleaner than mine.

Added myself to __init__.py and .zenodo.json (2f20347e). I left the affiliation and ORCID off the Zenodo entry since I don't have one to give; a couple of the existing entries are name-only so it should validate fine.

No rush on the edits, ping me if you'd rather I fold them in.

@argerlt argerlt left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved. I was going to make some minor language changes originally (technically Quaternion.to_euler was fixed, not Rotation.to_euler), but I like your wording more, it's more relevant to the average user.

Waiting for #660 to be finished, then we will merge this to develop

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants