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)