diff --git a/.zenodo.json b/.zenodo.json
index c4c9ff3c..f59330d1 100644
--- a/.zenodo.json
+++ b/.zenodo.json
@@ -72,6 +72,9 @@
"name": "Alexander Clausen",
"orcid": "0000-0002-9555-7455",
"affiliation": "Jülich Research Centre, Ernst Ruska Centre"
+ },
+ {
+ "name": "Vincent Gao"
}
]
}
\ No newline at end of file
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 647e4f60..a9785ff1 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -7,6 +7,17 @@ on `Keep a Changelog `__, and this project
its best to adhere to `Semantic Versioning `__.
+Unreleased
+==========
+
+Fixed
+-----
+- ``Rotation.to_euler()`` now returns correct Bunge Euler angles for 180 degree
+ rotations about an axis in the xy-plane not aligned with x or y. A sign error in
+ the singular branch of the quaternion-to-Euler conversion previously reflected the
+ reconstructed axis, so the angles did not round-trip back to the input rotation.
+
+
2026-06-10 - version 0.15.0
===========================
diff --git a/orix/__init__.py b/orix/__init__.py
index 159b5d4e..e1654060 100644
--- a/orix/__init__.py
+++ b/orix/__init__.py
@@ -39,4 +39,5 @@
"Dorian Depriester",
"Eric Prestat",
"Alexander Clausen",
+ "Vincent Gao",
]
diff --git a/orix/quaternion/_conversions.py b/orix/quaternion/_conversions.py
index e6a889d4..d9ce4ad8 100644
--- a/orix/quaternion/_conversions.py
+++ b/orix/quaternion/_conversions.py
@@ -1016,7 +1016,7 @@ def qu2eu_single(qu: np.ndarray) -> np.ndarray:
a = -2 * qu[0] * qu[3]
b = qu[0] * qu[0] - qu[3] * qu[3]
else:
- a = -2 * qu[1] * qu[2]
+ a = 2 * qu[1] * qu[2]
b = qu[1] * qu[1] - qu[2] * qu[2]
eu[1] = np.pi
eu[0] = np.arctan2(a, b)
diff --git a/orix/tests/test_quaternion/test_conversions.py b/orix/tests/test_quaternion/test_conversions.py
index 8a4a3ba2..eeaad4f5 100644
--- a/orix/tests/test_quaternion/test_conversions.py
+++ b/orix/tests/test_quaternion/test_conversions.py
@@ -20,7 +20,7 @@
import numpy as np
import pytest
-from orix.quaternion import Orientation
+from orix.quaternion import Orientation, Rotation
from orix.quaternion._conversions import (
ax2qu,
ax2qu_2d,
@@ -105,6 +105,30 @@ def test_eu2qu2eu(self, euler_angles, quaternions_conversions):
assert ori.symmetry == Oh
assert np.allclose(ori.data[0], qu_64[-1], atol=1e-4)
+ def test_qu2eu_equatorial_twofold(self):
+ # 180 deg rotations about equatorial (xy-plane) axes hit the Phi = pi
+ # branch of qu2eu (Rowenhorst et al. (2015) Eq. A.14). Off-axis axes,
+ # with x and y both non-zero, must round-trip back to the rotation.
+ psi = np.linspace(0, np.pi, 19)
+ axes = np.column_stack([np.cos(psi), np.sin(psi), np.zeros_like(psi)])
+ qu = ax2qu(axes, np.full(psi.shape, np.pi))
+
+ # jit path: qu -> eu -> qu, compared as matrices to sidestep the
+ # quaternion double cover
+ assert np.allclose(qu2om(qu), qu2om(eu2qu(qu2eu(qu))), atol=1e-6)
+
+ # Pure-Python path
+ for q in qu:
+ q_rt = eu2qu_single.py_func(qu2eu_single.py_func(q))
+ assert np.allclose(
+ qu2om_single.py_func(q), qu2om_single.py_func(q_rt), atol=1e-6
+ )
+
+ # Public API round-trip
+ rot = Rotation.from_axes_angles(axes, np.pi)
+ rot_rt = Rotation.from_euler(rot.to_euler())
+ assert np.allclose(rot.to_matrix(), rot_rt.to_matrix(), atol=1e-6)
+
def test_get_pyramid(self, cubochoric_coordinates):
"""Cubochoric coordinates situated in expected pyramid."""
cu_64 = cubochoric_coordinates
diff --git a/orix/tests/test_sampling/test_sampling.py b/orix/tests/test_sampling/test_sampling.py
index cbcf4653..65c0f3c5 100644
--- a/orix/tests/test_sampling/test_sampling.py
+++ b/orix/tests/test_sampling/test_sampling.py
@@ -206,14 +206,15 @@ def test_get_sample_reduced_fundamental(self):
v_Oh = R_Oh * vz
assert np.all(v_Oh <= Oh.fundamental_sector)
- # Some rotations have a phi1 Euler angle of multiples of pi,
- # presumably due to rounding errors
+ # A few sampled rotations are 180 degree rotations about an axis in
+ # the xy-plane (Phi = pi), where phi1 is degenerate and lands on a
+ # multiple of pi / 2
phi1_C1 = R_C1.to_euler()[:, 0].round(7)
assert np.allclose(np.unique(phi1_C1), 0, atol=1e-7)
phi1_C4 = R_C4.to_euler()[:, 0].round(7)
- assert np.allclose(np.unique(phi1_C4), [0, np.pi / 2], atol=1e-7)
+ assert np.allclose(np.unique(phi1_C4), [0, np.pi / 2, 3 * np.pi / 2], atol=1e-7)
phi1_C6 = R_C6.to_euler()[:, 0].round(7)
- assert np.allclose(np.unique(phi1_C6), [0, np.pi / 2], atol=1e-7)
+ assert np.allclose(np.unique(phi1_C6), [0, np.pi / 2, 3 * np.pi / 2], atol=1e-7)
phi1_Oh = R_Oh.to_euler()[:, 0].round(7)
assert np.allclose(np.unique(phi1_Oh), [0, np.pi / 2], atol=1e-7)