From df9ca1de8b27bb880f2158f999c12734b05f0515 Mon Sep 17 00:00:00 2001 From: Austin Gerlt Date: Mon, 19 May 2025 01:11:45 -0600 Subject: [PATCH 01/23] adding ancillary capabilities from PR442 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Håkon Wiik Ånes --- CHANGELOG.rst | 8 ++- orix/quaternion/misorientation.py | 49 ++++++++++++++++++ orix/quaternion/quaternion.py | 62 +++++++++++++++++++++++ orix/tests/quaternion/test_orientation.py | 30 ++++++----- 4 files changed, 133 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8b2b47049..305b8b33e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -31,10 +31,14 @@ Changed Removed ------- - +- ``verbose`` parameter in ``reduce()`` + (replacing ``map_into_symmetry_reduced_zone()``). + Deprecated ---------- - +- ``map_into_symmetry_reduced_zone()`` is deprecated since 0.14 and will be removed in + 0.15. Use ``reduce()`` instead. + Fixed ----- - minor speedups to 'pytest' diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index d5005865f..c7cc140c4 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -31,6 +31,7 @@ from scipy.spatial.transform import Rotation as SciPyRotation from tqdm import tqdm +from orix._util import deprecated from orix.quaternion.orientation_region import OrientationRegion from orix.quaternion.rotation import Rotation from orix.quaternion.symmetry import C1, Symmetry, _get_unique_symmetry_elements @@ -340,6 +341,7 @@ def equivalent(self, grain_exchange: bool = False) -> Misorientation: return self.__class__(equivalent).flatten() + @deprecated(since="0.14", removal="0.15", alternative="reduce") def map_into_symmetry_reduced_zone(self, verbose: bool = False) -> Misorientation: """Return equivalent transformations which have the smallest angle of rotation as a new misorientation. @@ -382,6 +384,53 @@ def map_into_symmetry_reduced_zone(self, verbose: bool = False) -> Misorientatio o_inside._symmetry = (Gl, Gr) return o_inside + def reduce(self) -> Misorientation: + """Return equivalent transformations which have the smallest + angle of rotation as a new misorientation. + + Parameters + ---------- + verbose + Whether to print a progressbar. Default is ``False``. + + Returns + ------- + M + A new misorientation object with the assigned symmetry. + + Examples + -------- + >>> from orix.quaternion.symmetry import C4, C2 + >>> data = np.array([[0.5, 0.5, 0.5, 0.5], [0, 1, 0, 0]]) + >>> M = Misorientation(data) + >>> M.symmetry = (C4, C2) + >>> M.reduce() + Misorientation (2,) 4, 2 + [[-0.7071 0.7071 0. 0. ] + [ 0. 1. 0. 0. ]] + """ + # create an itertool object for iterating over every + # combination of left and right symmetry operations + Gl, Gr = self._symmetry + symmetry_pairs = iproduct(Gl, Gr) + + # Define a Fundamental Zone (fz). for every misorientation, there + # is one and only one combination in `symmetry_pairs` that rotates + # it into the fz. + fz = OrientationRegion.from_symmetry(Gl, Gr) + reduced = self.__class__.identity(self.shape) + outside = np.ones(self.shape, dtype=bool) + # apply transformations to unreduced misorientations and save the + # reduced ones to `reduced` until all are inside the fz + for gl, gr in symmetry_pairs: + o_transformed = gl * self[outside] * gr + reduced[outside] = o_transformed + outside = ~(reduced < fz) + if not np.any(outside): + break + reduced._symmetry = (Gl, Gr) + return reduced + def scatter( self, projection: str = "axangle", diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index 099abf872..cc2243481 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -246,6 +246,16 @@ def __eq__(self, other: Any | Quaternion) -> bool: # ------------------------ Class methods ------------------------- # + @classmethod + def random(cls, shape: Union[int, tuple] = 1) -> Quaternion: + quat = super().random(shape) + quat.data[:, 0] = np.abs(quat.data[:, 0]) + return quat + + O = super().__invert__() + O.symmetry = self.symmetry + return O + @classmethod def from_axes_angles( cls, @@ -685,6 +695,58 @@ def from_align_vectors( return out[0] if len(out) == 1 else tuple(out) + @classmethod + def from_path_ends( + cls, waypoints: Quaternion, close_loop: bool = False, steps: int = 100 + ) -> Quaternion: + """Return Quaternions tracing the shortest path between two or more + consecutive waypoints. + + Parameters + ---------- + waypoints : Quaternion + Two or more quaternions that define waypoints along a path through + rotation space (SO3). + close_loop : bool, optional + Option to add a final trip from the last waypoint back to the + first, thus closing the loop. Default is False. + steps : int, optional + Number of points to return along the path between each pair of + waypoints. The default is 100. + + Returns + ------- + path :Quaternion + quaternions that map out a path between the given waypoints. + + Notes + ----- + This method can use Orientations and Misorientations as inputs, and + will return an object of the same class. However, symmetry is ignored + when determining the shortest routes. + """ + waypoints = waypoints.flatten() + n = waypoints.size + if not close_loop: + n = n - 1 + + path_list = [] + for i in range(n): + # get start and end for this leg of the trip + q1 = waypoints[i] + q2 = waypoints[(i + 1) % (waypoints.size)] + # find the ax/ang describing the trip between points + ax, ang = _conversions.qu2ax((~q1 * q2).data) + # get 100 steps along the trip and add them to the journey + trip = Quaternion.from_axes_angles(ax, np.linspace(0, ang, steps)) + path_list.append((q1 * (trip.flatten())).data) + path_data = np.concatenate(path_list, axis=0) + path = waypoints.__class__(path_data) + # copy the symmetry if it exists + if hasattr(waypoints, "_symmetry"): + path._symmetry = waypoints._symmetry + return path + @classmethod def triple_cross(cls, q1: Quaternion, q2: Quaternion, q3: Quaternion) -> Quaternion: """Pointwise cross product of three quaternions. diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 4ea13b1d9..003c22ceb 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -101,7 +101,7 @@ def test_quaternion_subclasses_copy_constructor_casting(): ) def test_set_symmetry(orientation, symmetry, expected): o = Orientation(orientation.data, symmetry=symmetry) - o = o.map_into_symmetry_reduced_zone() + o = o.reduce() assert np.allclose(o.data, expected, atol=1e-3) @@ -114,7 +114,7 @@ def test_orientation_persistence(symmetry, vector): v = symmetry.outer(vector).flatten() o = Orientation.random() oc = Orientation(o.data, symmetry=symmetry) - oc = oc.map_into_symmetry_reduced_zone() + oc = oc.reduce() v1 = o * v v1 = Vector3d(v1.data.round(4)) v2 = oc * v @@ -207,7 +207,7 @@ def test_equivalent(Gl): """ m = Misorientation([1, 1, 1, 1]) # any will do m_new = Misorientation(m.data, symmetry=(Gl, C4)) - m_new = m_new.map_into_symmetry_reduced_zone() + m_new = m_new.reduce() _ = m_new.equivalent(grain_exchange=True) @@ -220,13 +220,13 @@ def test_repr_ori(): shape = (2, 3) o = Orientation.identity(shape) o.symmetry = O - o = o.map_into_symmetry_reduced_zone() + o = o.reduce() assert repr(o).split("\n")[0] == f"Orientation {shape} {O.name}" def test_sub(): o = Orientation([1, 1, 1, 1], symmetry=C4) # any will do - o = o.map_into_symmetry_reduced_zone() + o = o.reduce() m = o - o assert np.allclose(m.data, [1, 0, 0, 0]) @@ -247,8 +247,10 @@ def test_map_into_reduced_symmetry_zone_verbose(): o = Orientation.random() o.symmetry = Oh o1 = o.map_into_symmetry_reduced_zone() - o2 = o.map_into_symmetry_reduced_zone(verbose=True) + o2 = o.reduce() + o3 = o.map_into_symmetry_reduced_zone(verbose=True) assert np.allclose(o1.data, o2.data) + assert np.allclose(o1.data, o3.data) @pytest.mark.parametrize( @@ -264,7 +266,7 @@ def test_transpose_3d(shape, expected_shape, axes): def test_transpose_symmetry(): o1 = Orientation.random_vonmises((11, 3)) o1.symmetry = Oh - o1 = o1.map_into_symmetry_reduced_zone() + o1 = o1.reduce() o2 = o1.transpose() assert o1.symmetry == o2.symmetry @@ -488,11 +490,11 @@ def test_from_euler_symmetry(self): assert np.allclose(o1.data, [0, -0.3827, 0, -0.9239], atol=1e-4) assert o1.symmetry.name == "1" o2 = Orientation.from_euler(euler, symmetry=Oh) - o2 = o2.map_into_symmetry_reduced_zone() + o2 = o2.reduce() assert np.allclose(o2.data, [0.9239, 0, 0.3827, 0], atol=1e-4) assert o2.symmetry.name == "m-3m" o3 = Orientation(o1.data, symmetry=Oh) - o3 = o3.map_into_symmetry_reduced_zone() + o3 = o3.reduce() assert np.allclose(o3.data, o2.data) o4 = Orientation.from_euler(np.rad2deg(euler), degrees=True) @@ -508,13 +510,13 @@ def test_from_matrix_symmetry(self): ) assert o1.symmetry.name == "1" o2 = Orientation.from_matrix(om, symmetry=Oh) - o2 = o2.map_into_symmetry_reduced_zone() + o2 = o2.reduce() assert np.allclose( o2.data, np.array([1, 0, 0, 0] * 2 + [-1, 0, 0, 0] * 2).reshape(4, 4) ) assert o2.symmetry.name == "m-3m" o3 = Orientation(o1.data, symmetry=Oh) - o3 = o3.map_into_symmetry_reduced_zone() + o3 = o3.reduce() assert np.allclose(o3.data, o2.data) def test_from_align_vectors(self): @@ -601,7 +603,7 @@ class TestOrientation: def test_get_distance_matrix(self, symmetry): q = [(0.5, 0.5, 0.5, 0.5), (0.5**0.5, 0, 0, 0.5**0.5)] o = Orientation(q, symmetry=symmetry) - o = o.map_into_symmetry_reduced_zone() + o = o.reduce() angles_numpy = o.get_distance_matrix() assert isinstance(angles_numpy, np.ndarray) assert angles_numpy.shape == (2, 2) @@ -694,7 +696,7 @@ def test_angle_with(self, symmetry): def test_negate_orientation(self): o = Orientation.identity() o.symmetry = Oh - o = o.map_into_symmetry_reduced_zone() + o = o.reduce() on = -o assert on.symmetry.name == o.symmetry.name @@ -703,7 +705,7 @@ def test_scatter(self, orientation, pure_misorientation): if pure_misorientation: orientation = Misorientation(orientation) orientation.symmetry = (C2, D6) - orientation = orientation.map_into_symmetry_reduced_zone() + orientation = orientation.reduce() fig_size = (5, 5) fig_axangle = orientation.scatter( return_figure=True, figure_kwargs=dict(figsize=fig_size) From e67ff70a8972ebbe37068dd79392edc57b28d9f8 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Tue, 27 May 2025 15:35:29 -0600 Subject: [PATCH 02/23] add tests for 'from_path_ends` --- orix/quaternion/quaternion.py | 50 ++++++--- orix/tests/quaternion/test_orientation.py | 45 ++++++-- orix/tests/quaternion/test_quaternion.py | 122 ++++++++++++++++++---- 3 files changed, 170 insertions(+), 47 deletions(-) diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index cc2243481..c18f1d5af 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -505,7 +505,9 @@ def from_euler( eu = np.atleast_2d(euler) if np.any(np.abs(eu) > 4 * np.pi): - warnings.warn("Angles are quite high, did you forget to set degrees=True?") + warnings.warn( + "Angles are quite high, did you forget to set degrees=True?" + ) Q = _conversions.eu2qu(eu) Q = cls(Q) @@ -544,7 +546,9 @@ def from_matrix(cls, matrix: np.ndarray | tuple | list) -> Quaternion: # Verify the input can be interpreted as an array of (3, 3) arrays om = np.atleast_2d(matrix) if om.shape[-2:] != (3, 3): - raise ValueError("the last two dimensions of 'matrix' must be (3, 3)") + raise ValueError( + "the last two dimensions of 'matrix' must be (3, 3)" + ) Q = _conversions.om2qu(om) Q = cls(Q) @@ -737,7 +741,7 @@ def from_path_ends( q2 = waypoints[(i + 1) % (waypoints.size)] # find the ax/ang describing the trip between points ax, ang = _conversions.qu2ax((~q1 * q2).data) - # get 100 steps along the trip and add them to the journey + # get 'steps=n' steps along the trip and add them to the journey trip = Quaternion.from_axes_angles(ax, np.linspace(0, ang, steps)) path_list.append((q1 * (trip.flatten())).data) path_data = np.concatenate(path_list, axis=0) @@ -748,7 +752,9 @@ def from_path_ends( return path @classmethod - def triple_cross(cls, q1: Quaternion, q2: Quaternion, q3: Quaternion) -> Quaternion: + def triple_cross( + cls, q1: Quaternion, q2: Quaternion, q3: Quaternion + ) -> Quaternion: """Pointwise cross product of three quaternions. Parameters @@ -1195,7 +1201,9 @@ def outer( qu12 = np.outer(qu1, qu2).reshape(*qu1.shape, *qu2.shape) qu = quaternion.as_float_array(qu12) else: # pragma: no cover - Q12 = Quaternion(self).reshape(-1, 1) * other.reshape(1, -1) + Q12 = Quaternion(self).reshape(-1, 1) * other.reshape( + 1, -1 + ) qu = Q12.data.reshape(*self.shape, *other.shape, 4) return other.__class__(qu) elif isinstance(other, Vector3d): @@ -1368,7 +1376,9 @@ def _outer_dask( @nb.guvectorize("(n)->(n)", cache=True) -def qu_conj_gufunc(qu: np.ndarray, qu2: np.ndarray) -> None: # pragma: no cover +def qu_conj_gufunc( + qu: np.ndarray, qu2: np.ndarray +) -> None: # pragma: no cover qu2[0] = qu[0] qu2[1] = -qu[1] qu2[2] = -qu[2] @@ -1379,13 +1389,23 @@ def qu_conj_gufunc(qu: np.ndarray, qu2: np.ndarray) -> None: # pragma: no cover def qu_multiply_gufunc( qu1: np.ndarray, qu2: np.ndarray, qu12: np.ndarray ) -> None: # pragma: no cover - qu12[0] = qu1[0] * qu2[0] - qu1[1] * qu2[1] - qu1[2] * qu2[2] - qu1[3] * qu2[3] - qu12[1] = qu1[1] * qu2[0] + qu1[0] * qu2[1] - qu1[3] * qu2[2] + qu1[2] * qu2[3] - qu12[2] = qu1[2] * qu2[0] + qu1[3] * qu2[1] + qu1[0] * qu2[2] - qu1[1] * qu2[3] - qu12[3] = qu1[3] * qu2[0] - qu1[2] * qu2[1] + qu1[1] * qu2[2] + qu1[0] * qu2[3] - - -def qu_multiply(qu1: np.ndarray, qu2: np.ndarray) -> np.ndarray: # pragma: no cover + qu12[0] = ( + qu1[0] * qu2[0] - qu1[1] * qu2[1] - qu1[2] * qu2[2] - qu1[3] * qu2[3] + ) + qu12[1] = ( + qu1[1] * qu2[0] + qu1[0] * qu2[1] - qu1[3] * qu2[2] + qu1[2] * qu2[3] + ) + qu12[2] = ( + qu1[2] * qu2[0] + qu1[3] * qu2[1] + qu1[0] * qu2[2] - qu1[1] * qu2[3] + ) + qu12[3] = ( + qu1[3] * qu2[0] - qu1[2] * qu2[1] + qu1[1] * qu2[2] + qu1[0] * qu2[3] + ) + + +def qu_multiply( + qu1: np.ndarray, qu2: np.ndarray +) -> np.ndarray: # pragma: no cover shape = np.broadcast_shapes(qu1.shape, qu2.shape) if not np.issubdtype(qu1.dtype, np.float64): qu1 = qu1.astype(np.float64) @@ -1410,7 +1430,9 @@ def qu_rotate_vec_gufunc( v2[2] = z - c * tx + b * ty + a * tz -def qu_rotate_vec(qu: np.ndarray, v: np.ndarray) -> np.ndarray: # pragma: no cover +def qu_rotate_vec( + qu: np.ndarray, v: np.ndarray +) -> np.ndarray: # pragma: no cover qu = np.atleast_2d(qu) v = np.atleast_2d(v) shape = np.broadcast_shapes(qu.shape[:-1], v.shape[:-1]) + (3,) diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 003c22ceb..57a31139a 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -301,7 +301,8 @@ def test_symmetry_property_wrong_type_orientation(): @pytest.mark.parametrize( - "error_type, value", [(ValueError, (1, 2)), (ValueError, (C1, 2)), (TypeError, 1)] + "error_type, value", + [(ValueError, (1, 2)), (ValueError, (C1, 2)), (TypeError, 1)], ) def test_symmetry_property_wrong_type_misorientation(error_type, value): mori = Misorientation.random((3, 2)) @@ -313,7 +314,9 @@ def test_symmetry_property_wrong_type_misorientation(error_type, value): "error_type, value", [(ValueError, (C1,)), (ValueError, (C1, C2, C1))], ) -def test_symmetry_property_wrong_number_of_values_misorientation(error_type, value): +def test_symmetry_property_wrong_number_of_values_misorientation( + error_type, value +): o = Misorientation.random((3, 2)) with pytest.raises(error_type, match="Value must be a 2-tuple"): # less than 2 Symmetry @@ -506,13 +509,15 @@ def test_from_matrix_symmetry(self): ) o1 = Orientation.from_matrix(om) assert np.allclose( - o1.data, np.array([1, 0, 0, 0] * 2 + [0, 1, 0, 0] * 2).reshape(4, 4) + o1.data, + np.array([1, 0, 0, 0] * 2 + [0, 1, 0, 0] * 2).reshape(4, 4), ) assert o1.symmetry.name == "1" o2 = Orientation.from_matrix(om, symmetry=Oh) o2 = o2.reduce() assert np.allclose( - o2.data, np.array([1, 0, 0, 0] * 2 + [-1, 0, 0, 0] * 2).reshape(4, 4) + o2.data, + np.array([1, 0, 0, 0] * 2 + [-1, 0, 0, 0] * 2).reshape(4, 4), ) assert o2.symmetry.name == "m-3m" o3 = Orientation(o1.data, symmetry=Oh) @@ -597,9 +602,19 @@ def test_from_scipy_rotation(self): with pytest.raises(TypeError, match="Value must be an instance of"): _ = Orientation.from_scipy_rotation(r_scipy, (Oh, Oh)) + def test_from_path_ends(self): + # generate paths with misorientations and orientations to check + # symmetry copying + wp_o = Orientation(data=np.eye(4)[:2], symmetry=Oh) + wp_m = Misorientation(data=np.eye(4)[2:], symmetry=[Oh, C3]) + assert Orientation.from_path_ends(wp_o)._symmetry == (C1, Oh) + assert Misorientation.from_path_ends(wp_m)._symmetry == (Oh, C3) + class TestOrientation: - @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) + @pytest.mark.parametrize( + "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] + ) def test_get_distance_matrix(self, symmetry): q = [(0.5, 0.5, 0.5, 0.5), (0.5**0.5, 0, 0, 0.5**0.5)] o = Orientation(q, symmetry=symmetry) @@ -626,11 +641,15 @@ def test_get_distance_matrix_lazy_parameters(self): o = Orientation(abcd) angle1 = o.get_distance_matrix(lazy=True, chunk_size=5) - angle2 = o.get_distance_matrix(lazy=True, chunk_size=10, progressbar=False) + angle2 = o.get_distance_matrix( + lazy=True, chunk_size=10, progressbar=False + ) assert np.allclose(angle1.data, angle2.data) - @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) + @pytest.mark.parametrize( + "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] + ) def test_angle_with_outer(self, symmetry): shape = (5,) o = Orientation.random(shape) @@ -677,7 +696,9 @@ def test_angle_with_outer_shape(self): assert awo_o12s.shape == awo_r12.shape assert not np.allclose(awo_o12s, awo_r12) - @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) + @pytest.mark.parametrize( + "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] + ) def test_angle_with(self, symmetry): q = [(0.5, 0.5, 0.5, 0.5), (0.5**0.5, 0, 0, 0.5**0.5)] r = Rotation(q) @@ -712,7 +733,9 @@ def test_scatter(self, orientation, pure_misorientation): ) assert (fig_axangle.get_size_inches() == fig_size).all() assert isinstance(fig_axangle.axes[0], AxAnglePlot) - fig_rodrigues = orientation.scatter(projection="rodrigues", return_figure=True) + fig_rodrigues = orientation.scatter( + projection="rodrigues", return_figure=True + ) assert isinstance(fig_rodrigues.axes[0], RodriguesPlot) # Add multiple axes to figure, one at a time @@ -805,7 +828,9 @@ def test_in_fundamental_region(self): for pg in _proper_groups: ori.symmetry = pg region = np.radians(pg.euler_fundamental_region) - assert np.all(np.max(ori.in_euler_fundamental_region(), axis=0) <= region) + assert np.all( + np.max(ori.in_euler_fundamental_region(), axis=0) <= region + ) def test_inverse(self): O1 = Orientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], D6) diff --git a/orix/tests/quaternion/test_quaternion.py b/orix/tests/quaternion/test_quaternion.py index 7f6be1d11..c82483bca 100644 --- a/orix/tests/quaternion/test_quaternion.py +++ b/orix/tests/quaternion/test_quaternion.py @@ -78,7 +78,9 @@ def test_neg(self, quaternion): assert np.allclose((-quaternion).data, (-quaternion.data)) def test_norm(self, quaternion): - assert np.allclose(quaternion.norm, (quaternion.data**2).sum(axis=-1) ** 0.5) + assert np.allclose( + quaternion.norm, (quaternion.data**2).sum(axis=-1) ** 0.5 + ) def test_unit(self, quaternion): assert np.allclose(quaternion.unit.norm, 1) @@ -102,7 +104,9 @@ def test_mul(self, quaternion, something): assert np.allclose(q1.d, qa * sd + qb * sc - qc * sb + qd * sa) def test_mul_identity(self, quaternion): - assert np.allclose((quaternion * Quaternion.identity()).data, quaternion.data) + assert np.allclose( + (quaternion * Quaternion.identity()).data, quaternion.data + ) def test_no_multiplicative_inverse(self, quaternion, something): q1 = quaternion * something @@ -193,7 +197,9 @@ def test_antipodal(self, quaternion): assert qa.size == 2 * q.size def test_edgecase_outer(self, quaternion): - with pytest.raises(NotImplementedError, match="This operation is currently "): + with pytest.raises( + NotImplementedError, match="This operation is currently " + ): _ = quaternion.outer([3, 2]) def test_failing_mul(self, quaternion): @@ -298,7 +304,9 @@ def test_outer_dask_wrong_type_raises(self): q = Quaternion(abcd) # not Quaternion or Vector3d other = np.random.rand(7, 3) - with pytest.raises(TypeError, match="Other must be Quaternion or Vector3d"): + with pytest.raises( + TypeError, match="Other must be Quaternion or Vector3d" + ): q._outer_dask(other) def test_from_align_vectors(self): @@ -308,7 +316,8 @@ def test_from_align_vectors(self): q1 = Quaternion.from_align_vectors(v1, v2) assert isinstance(q1, Quaternion) assert np.allclose( - q1.data, np.array([[0.65328148, 0.70532785, -0.05012611, -0.27059805]]) + q1.data, + np.array([[0.65328148, 0.70532785, -0.05012611, -0.27059805]]), ) assert np.allclose(v1.unit.data, (q1 * v2.unit).data) @@ -317,8 +326,12 @@ def test_from_align_vectors(self): error = out[1] assert error == 0 - _, sens_mat = Quaternion.from_align_vectors(v1, v2, return_sensitivity=True) - assert np.allclose(sens_mat, np.array([[1, 0, 0], [0, 1, 0], [0, 0, 0.5]])) + _, sens_mat = Quaternion.from_align_vectors( + v1, v2, return_sensitivity=True + ) + assert np.allclose( + sens_mat, np.array([[1, 0, 0], [0, 1, 0], [0, 0, 0.5]]) + ) out = Quaternion.from_align_vectors( v1, v2, return_rmsd=True, return_sensitivity=True @@ -330,6 +343,37 @@ def test_from_align_vectors(self): ) assert np.allclose(q2.data, q1.data) + def test_from_path_ends(self): + # choose a path with repeats and 180 tilts to test edge cases + waypoints = Quaternion( + data=np.array( + [ + [1, 0, 0, 0], + [1, 0, 0, 0], + [1, 1, 0, 0], + [1, 0, 1, 0], + [1, 0, 0, 1], + [1, 0, 0, 1], + [1, 0, -1, 0], + [1, 0, 1, 0], + [-1, 0, 1, 0], + [1, 0, 0, -1], + [-1, 0, 0, -1], + [-1, 0, 0, 1], + ] + ) + ) + path = Quaternion.from_path_ends(waypoints) + loop = Quaternion.from_path_ends(waypoints, close_loop=True, steps=11) + # check the sizes are as expected + assert path.shape == (1100,) + assert loop.shape == (132,) + # check the spacing between points is homogenous + path_spacing = [(x[1:]).dot(x[:-1]) for x in path.reshape(11, 100)] + loop_spacing = [(x[1:]).dot(x[:-1]) for x in loop.reshape(12, 11)] + assert np.all(np.std(path_spacing, axis=1) < 1e-12) + assert np.all(np.std(loop_spacing, axis=1) < 1e-12) + def test_equality(self): Q1 = Quaternion.from_axes_angles([1, 1, 1], -np.pi / 3) Q2 = Quaternion.from_axes_angles([1, 1, 1], np.pi / 3) @@ -361,7 +405,9 @@ def test_direction_values(self, eu): assert np.allclose((q_l2c * q_c2l).data, [1, 0, 0, 0]) def test_direction_kwarg_dumb(self, eu): - with pytest.raises(ValueError, match="The chosen direction is not one of "): + with pytest.raises( + ValueError, match="The chosen direction is not one of " + ): _ = Quaternion.from_euler(eu, direction="dumb_direction") def test_edge_cases_to_euler(self): @@ -372,19 +418,25 @@ def test_edge_cases_to_euler(self): _ = q.to_euler() def test_passing_degrees_warns(self): - with pytest.warns(UserWarning, match="Angles are quite high, did you forget "): + with pytest.warns( + UserWarning, match="Angles are quite high, did you forget " + ): q = Quaternion.from_euler([90, 0, 0]) assert np.allclose(q.data, [0.5253, 0, 0, -0.8509], atol=1e-4) class TestFromToMatrix: def test_to_matrix(self): - q = Quaternion([[1, 0, 0, 0], [3, 0, 0, 0], [0, 1, 0, 0], [0, 2, 0, 0]]) + q = Quaternion( + [[1, 0, 0, 0], [3, 0, 0, 0], [0, 1, 0, 0], [0, 2, 0, 0]] + ) om = np.array( [np.eye(3), np.eye(3), np.diag([1, -1, -1]), np.diag([1, -1, -1])] ) # Shapes are handled correctly - assert np.allclose(q.reshape(2, 2).unit.to_matrix(), om.reshape(2, 2, 3, 3)) + assert np.allclose( + q.reshape(2, 2).unit.to_matrix(), om.reshape(2, 2, 3, 3) + ) q2 = Quaternion( [ @@ -398,19 +450,25 @@ def test_to_matrix(self): # Inverse equal to transpose assert all(np.allclose(np.linalg.inv(i), i.T) for i in om_from_q2) # Cross product of any two rows gives the third - assert all(np.allclose(np.cross(i[:, 0], i[:, 1]), i[:, 2]) for i in om_from_q2) + assert all( + np.allclose(np.cross(i[:, 0], i[:, 1]), i[:, 2]) + for i in om_from_q2 + ) # Sum of squares of any column or row equals unity assert np.allclose(np.sum(np.square(om_from_q2), axis=1), 1) # Rows assert np.allclose(np.sum(np.square(om_from_q2), axis=2), 1) # Columns def test_from_matrix(self): - q = Quaternion([[1, 0, 0, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]]) + q = Quaternion( + [[1, 0, 0, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]] + ) ident = np.identity(3) rot_180x = np.diag([1, -1, -1]) om = np.array([ident, 2 * ident, rot_180x, 2 * rot_180x]) assert np.allclose(Quaternion.from_matrix(om).data, q.data) assert np.allclose( - Quaternion.from_matrix(om.reshape(2, 2, 3, 3)).data, q.reshape(2, 2).data + Quaternion.from_matrix(om.reshape(2, 2, 3, 3)).data, + q.reshape(2, 2).data, ) def test_from_to_matrix(self): @@ -447,7 +505,9 @@ def test_get_rotation_matrix_from_diffpy(self): q = Quaternion.from_matrix([i.R for i in sg225.symop_list]) assert not np.isnan(q.data).any() - def test_from_matrix_raises(self, quaternions_conversions, orientation_matrices): + def test_from_matrix_raises( + self, quaternions_conversions, orientation_matrices + ): qu = Quaternion.from_matrix(orientation_matrices).data assert np.allclose(qu, quaternions_conversions, atol=1e-4) with pytest.raises(ValueError, match="(3, 3)"): @@ -467,12 +527,16 @@ def test_from_axes_angles(self, rotations, extra_dim): rotations = rotations.__class__(rotations.data[..., np.newaxis, :]) ax = AxAngle.from_rotation(rotations) Q1 = Quaternion.from_axes_angles(ax.axis.data, ax.angle) - Q2 = Quaternion.from_axes_angles(ax.axis, np.rad2deg(ax.angle), degrees=True) + Q2 = Quaternion.from_axes_angles( + ax.axis, np.rad2deg(ax.angle), degrees=True + ) assert np.allclose(Q1.data, Q2.data) def test_to_axes_angles(self, quaternions_conversions, axis_angle_pairs): ax = Quaternion(quaternions_conversions).to_axes_angles() - assert np.allclose(np.deg2rad(ax.angle), axis_angle_pairs[:, 3], atol=4) + assert np.allclose( + np.deg2rad(ax.angle), axis_angle_pairs[:, 3], atol=4 + ) def test_from_axes_angles_empty(self): q = Quaternion.from_axes_angles([], []) @@ -484,12 +548,16 @@ class TestFromToRodrigues: Rodrigues and Rodrigues-Frank vectors. """ - def test_from_to_rodrigues(self, quaternions_conversions, rodrigues_vectors): + def test_from_to_rodrigues( + self, quaternions_conversions, rodrigues_vectors + ): axes = rodrigues_vectors[..., :3] angles = rodrigues_vectors[..., 3] q1 = Quaternion(quaternions_conversions) - with pytest.warns(UserWarning, match="Highest angle is greater than 179.999 "): + with pytest.warns( + UserWarning, match="Highest angle is greater than 179.999 " + ): q2 = Quaternion.from_rodrigues(axes, angles) assert np.allclose(q1.data, q2.data, atol=1e-4) ro = q1.to_rodrigues(frank=True) @@ -499,7 +567,9 @@ def test_from_to_rodrigues(self, quaternions_conversions, rodrigues_vectors): _ = Quaternion.from_rodrigues([1e15, 1e15, 1e10]) with pytest.warns(UserWarning, match="Max."): _ = Quaternion.from_rodrigues([0, 0, 1e-16]) - with pytest.raises(ValueError, match="Final dimension of vector array must be"): + with pytest.raises( + ValueError, match="Final dimension of vector array must be" + ): Quaternion.from_rodrigues([1, 2, 3, 4]) def test_from_rodrigues_empty(self): @@ -512,7 +582,9 @@ class TestFromToHomochoric: homochoric vectors. """ - def test_from_to_homochoric(self, homochoric_vectors, quaternions_conversions): + def test_from_to_homochoric( + self, homochoric_vectors, quaternions_conversions + ): ho1 = homochoric_vectors ho2 = Vector3d(ho1) ho3 = Homochoric(ho1) @@ -526,7 +598,9 @@ def test_from_to_homochoric(self, homochoric_vectors, quaternions_conversions): assert np.allclose(q1.data, quaternions_conversions, atol=1e-4) assert np.allclose(q2.data, quaternions_conversions, atol=1e-4) assert np.allclose(q3.data, quaternions_conversions, atol=1e-4) - assert np.allclose(q4.data, quaternions_conversions.reshape(2, 5, 4), atol=1e-4) + assert np.allclose( + q4.data, quaternions_conversions.reshape(2, 5, 4), atol=1e-4 + ) assert np.allclose(ho1, q1.to_homochoric().data, atol=1e-4) assert np.allclose(ho1, q2.to_homochoric().data, atol=1e-4) @@ -534,7 +608,9 @@ def test_from_to_homochoric(self, homochoric_vectors, quaternions_conversions): assert np.allclose(ho4, q4.to_homochoric().data, atol=1e-4) def test_from_homochoric_raises(self): - with pytest.raises(ValueError, match="Final dimension of vector array must be"): + with pytest.raises( + ValueError, match="Final dimension of vector array must be" + ): Quaternion.from_homochoric([1, 2]) def test_from_homochoric_empty(self): From 5ffecb3b5353065e71de2746d18c4348a5231e02 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Tue, 27 May 2025 15:37:31 -0600 Subject: [PATCH 03/23] black formatting --- orix/quaternion/quaternion.py | 48 ++++--------- orix/tests/quaternion/test_orientation.py | 28 ++------ orix/tests/quaternion/test_quaternion.py | 85 ++++++----------------- 3 files changed, 41 insertions(+), 120 deletions(-) diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index c18f1d5af..f72d6b704 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -505,9 +505,7 @@ def from_euler( eu = np.atleast_2d(euler) if np.any(np.abs(eu) > 4 * np.pi): - warnings.warn( - "Angles are quite high, did you forget to set degrees=True?" - ) + warnings.warn("Angles are quite high, did you forget to set degrees=True?") Q = _conversions.eu2qu(eu) Q = cls(Q) @@ -546,9 +544,7 @@ def from_matrix(cls, matrix: np.ndarray | tuple | list) -> Quaternion: # Verify the input can be interpreted as an array of (3, 3) arrays om = np.atleast_2d(matrix) if om.shape[-2:] != (3, 3): - raise ValueError( - "the last two dimensions of 'matrix' must be (3, 3)" - ) + raise ValueError("the last two dimensions of 'matrix' must be (3, 3)") Q = _conversions.om2qu(om) Q = cls(Q) @@ -752,9 +748,7 @@ def from_path_ends( return path @classmethod - def triple_cross( - cls, q1: Quaternion, q2: Quaternion, q3: Quaternion - ) -> Quaternion: + def triple_cross(cls, q1: Quaternion, q2: Quaternion, q3: Quaternion) -> Quaternion: """Pointwise cross product of three quaternions. Parameters @@ -1201,9 +1195,7 @@ def outer( qu12 = np.outer(qu1, qu2).reshape(*qu1.shape, *qu2.shape) qu = quaternion.as_float_array(qu12) else: # pragma: no cover - Q12 = Quaternion(self).reshape(-1, 1) * other.reshape( - 1, -1 - ) + Q12 = Quaternion(self).reshape(-1, 1) * other.reshape(1, -1) qu = Q12.data.reshape(*self.shape, *other.shape, 4) return other.__class__(qu) elif isinstance(other, Vector3d): @@ -1376,9 +1368,7 @@ def _outer_dask( @nb.guvectorize("(n)->(n)", cache=True) -def qu_conj_gufunc( - qu: np.ndarray, qu2: np.ndarray -) -> None: # pragma: no cover +def qu_conj_gufunc(qu: np.ndarray, qu2: np.ndarray) -> None: # pragma: no cover qu2[0] = qu[0] qu2[1] = -qu[1] qu2[2] = -qu[2] @@ -1389,23 +1379,13 @@ def qu_conj_gufunc( def qu_multiply_gufunc( qu1: np.ndarray, qu2: np.ndarray, qu12: np.ndarray ) -> None: # pragma: no cover - qu12[0] = ( - qu1[0] * qu2[0] - qu1[1] * qu2[1] - qu1[2] * qu2[2] - qu1[3] * qu2[3] - ) - qu12[1] = ( - qu1[1] * qu2[0] + qu1[0] * qu2[1] - qu1[3] * qu2[2] + qu1[2] * qu2[3] - ) - qu12[2] = ( - qu1[2] * qu2[0] + qu1[3] * qu2[1] + qu1[0] * qu2[2] - qu1[1] * qu2[3] - ) - qu12[3] = ( - qu1[3] * qu2[0] - qu1[2] * qu2[1] + qu1[1] * qu2[2] + qu1[0] * qu2[3] - ) - - -def qu_multiply( - qu1: np.ndarray, qu2: np.ndarray -) -> np.ndarray: # pragma: no cover + qu12[0] = qu1[0] * qu2[0] - qu1[1] * qu2[1] - qu1[2] * qu2[2] - qu1[3] * qu2[3] + qu12[1] = qu1[1] * qu2[0] + qu1[0] * qu2[1] - qu1[3] * qu2[2] + qu1[2] * qu2[3] + qu12[2] = qu1[2] * qu2[0] + qu1[3] * qu2[1] + qu1[0] * qu2[2] - qu1[1] * qu2[3] + qu12[3] = qu1[3] * qu2[0] - qu1[2] * qu2[1] + qu1[1] * qu2[2] + qu1[0] * qu2[3] + + +def qu_multiply(qu1: np.ndarray, qu2: np.ndarray) -> np.ndarray: # pragma: no cover shape = np.broadcast_shapes(qu1.shape, qu2.shape) if not np.issubdtype(qu1.dtype, np.float64): qu1 = qu1.astype(np.float64) @@ -1430,9 +1410,7 @@ def qu_rotate_vec_gufunc( v2[2] = z - c * tx + b * ty + a * tz -def qu_rotate_vec( - qu: np.ndarray, v: np.ndarray -) -> np.ndarray: # pragma: no cover +def qu_rotate_vec(qu: np.ndarray, v: np.ndarray) -> np.ndarray: # pragma: no cover qu = np.atleast_2d(qu) v = np.atleast_2d(v) shape = np.broadcast_shapes(qu.shape[:-1], v.shape[:-1]) + (3,) diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 57a31139a..0645a135e 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -314,9 +314,7 @@ def test_symmetry_property_wrong_type_misorientation(error_type, value): "error_type, value", [(ValueError, (C1,)), (ValueError, (C1, C2, C1))], ) -def test_symmetry_property_wrong_number_of_values_misorientation( - error_type, value -): +def test_symmetry_property_wrong_number_of_values_misorientation(error_type, value): o = Misorientation.random((3, 2)) with pytest.raises(error_type, match="Value must be a 2-tuple"): # less than 2 Symmetry @@ -612,9 +610,7 @@ def test_from_path_ends(self): class TestOrientation: - @pytest.mark.parametrize( - "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] - ) + @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) def test_get_distance_matrix(self, symmetry): q = [(0.5, 0.5, 0.5, 0.5), (0.5**0.5, 0, 0, 0.5**0.5)] o = Orientation(q, symmetry=symmetry) @@ -641,15 +637,11 @@ def test_get_distance_matrix_lazy_parameters(self): o = Orientation(abcd) angle1 = o.get_distance_matrix(lazy=True, chunk_size=5) - angle2 = o.get_distance_matrix( - lazy=True, chunk_size=10, progressbar=False - ) + angle2 = o.get_distance_matrix(lazy=True, chunk_size=10, progressbar=False) assert np.allclose(angle1.data, angle2.data) - @pytest.mark.parametrize( - "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] - ) + @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) def test_angle_with_outer(self, symmetry): shape = (5,) o = Orientation.random(shape) @@ -696,9 +688,7 @@ def test_angle_with_outer_shape(self): assert awo_o12s.shape == awo_r12.shape assert not np.allclose(awo_o12s, awo_r12) - @pytest.mark.parametrize( - "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] - ) + @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) def test_angle_with(self, symmetry): q = [(0.5, 0.5, 0.5, 0.5), (0.5**0.5, 0, 0, 0.5**0.5)] r = Rotation(q) @@ -733,9 +723,7 @@ def test_scatter(self, orientation, pure_misorientation): ) assert (fig_axangle.get_size_inches() == fig_size).all() assert isinstance(fig_axangle.axes[0], AxAnglePlot) - fig_rodrigues = orientation.scatter( - projection="rodrigues", return_figure=True - ) + fig_rodrigues = orientation.scatter(projection="rodrigues", return_figure=True) assert isinstance(fig_rodrigues.axes[0], RodriguesPlot) # Add multiple axes to figure, one at a time @@ -828,9 +816,7 @@ def test_in_fundamental_region(self): for pg in _proper_groups: ori.symmetry = pg region = np.radians(pg.euler_fundamental_region) - assert np.all( - np.max(ori.in_euler_fundamental_region(), axis=0) <= region - ) + assert np.all(np.max(ori.in_euler_fundamental_region(), axis=0) <= region) def test_inverse(self): O1 = Orientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], D6) diff --git a/orix/tests/quaternion/test_quaternion.py b/orix/tests/quaternion/test_quaternion.py index c82483bca..332193a0a 100644 --- a/orix/tests/quaternion/test_quaternion.py +++ b/orix/tests/quaternion/test_quaternion.py @@ -78,9 +78,7 @@ def test_neg(self, quaternion): assert np.allclose((-quaternion).data, (-quaternion.data)) def test_norm(self, quaternion): - assert np.allclose( - quaternion.norm, (quaternion.data**2).sum(axis=-1) ** 0.5 - ) + assert np.allclose(quaternion.norm, (quaternion.data**2).sum(axis=-1) ** 0.5) def test_unit(self, quaternion): assert np.allclose(quaternion.unit.norm, 1) @@ -104,9 +102,7 @@ def test_mul(self, quaternion, something): assert np.allclose(q1.d, qa * sd + qb * sc - qc * sb + qd * sa) def test_mul_identity(self, quaternion): - assert np.allclose( - (quaternion * Quaternion.identity()).data, quaternion.data - ) + assert np.allclose((quaternion * Quaternion.identity()).data, quaternion.data) def test_no_multiplicative_inverse(self, quaternion, something): q1 = quaternion * something @@ -197,9 +193,7 @@ def test_antipodal(self, quaternion): assert qa.size == 2 * q.size def test_edgecase_outer(self, quaternion): - with pytest.raises( - NotImplementedError, match="This operation is currently " - ): + with pytest.raises(NotImplementedError, match="This operation is currently "): _ = quaternion.outer([3, 2]) def test_failing_mul(self, quaternion): @@ -304,9 +298,7 @@ def test_outer_dask_wrong_type_raises(self): q = Quaternion(abcd) # not Quaternion or Vector3d other = np.random.rand(7, 3) - with pytest.raises( - TypeError, match="Other must be Quaternion or Vector3d" - ): + with pytest.raises(TypeError, match="Other must be Quaternion or Vector3d"): q._outer_dask(other) def test_from_align_vectors(self): @@ -326,12 +318,8 @@ def test_from_align_vectors(self): error = out[1] assert error == 0 - _, sens_mat = Quaternion.from_align_vectors( - v1, v2, return_sensitivity=True - ) - assert np.allclose( - sens_mat, np.array([[1, 0, 0], [0, 1, 0], [0, 0, 0.5]]) - ) + _, sens_mat = Quaternion.from_align_vectors(v1, v2, return_sensitivity=True) + assert np.allclose(sens_mat, np.array([[1, 0, 0], [0, 1, 0], [0, 0, 0.5]])) out = Quaternion.from_align_vectors( v1, v2, return_rmsd=True, return_sensitivity=True @@ -405,9 +393,7 @@ def test_direction_values(self, eu): assert np.allclose((q_l2c * q_c2l).data, [1, 0, 0, 0]) def test_direction_kwarg_dumb(self, eu): - with pytest.raises( - ValueError, match="The chosen direction is not one of " - ): + with pytest.raises(ValueError, match="The chosen direction is not one of "): _ = Quaternion.from_euler(eu, direction="dumb_direction") def test_edge_cases_to_euler(self): @@ -418,25 +404,19 @@ def test_edge_cases_to_euler(self): _ = q.to_euler() def test_passing_degrees_warns(self): - with pytest.warns( - UserWarning, match="Angles are quite high, did you forget " - ): + with pytest.warns(UserWarning, match="Angles are quite high, did you forget "): q = Quaternion.from_euler([90, 0, 0]) assert np.allclose(q.data, [0.5253, 0, 0, -0.8509], atol=1e-4) class TestFromToMatrix: def test_to_matrix(self): - q = Quaternion( - [[1, 0, 0, 0], [3, 0, 0, 0], [0, 1, 0, 0], [0, 2, 0, 0]] - ) + q = Quaternion([[1, 0, 0, 0], [3, 0, 0, 0], [0, 1, 0, 0], [0, 2, 0, 0]]) om = np.array( [np.eye(3), np.eye(3), np.diag([1, -1, -1]), np.diag([1, -1, -1])] ) # Shapes are handled correctly - assert np.allclose( - q.reshape(2, 2).unit.to_matrix(), om.reshape(2, 2, 3, 3) - ) + assert np.allclose(q.reshape(2, 2).unit.to_matrix(), om.reshape(2, 2, 3, 3)) q2 = Quaternion( [ @@ -450,18 +430,13 @@ def test_to_matrix(self): # Inverse equal to transpose assert all(np.allclose(np.linalg.inv(i), i.T) for i in om_from_q2) # Cross product of any two rows gives the third - assert all( - np.allclose(np.cross(i[:, 0], i[:, 1]), i[:, 2]) - for i in om_from_q2 - ) + assert all(np.allclose(np.cross(i[:, 0], i[:, 1]), i[:, 2]) for i in om_from_q2) # Sum of squares of any column or row equals unity assert np.allclose(np.sum(np.square(om_from_q2), axis=1), 1) # Rows assert np.allclose(np.sum(np.square(om_from_q2), axis=2), 1) # Columns def test_from_matrix(self): - q = Quaternion( - [[1, 0, 0, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]] - ) + q = Quaternion([[1, 0, 0, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]]) ident = np.identity(3) rot_180x = np.diag([1, -1, -1]) om = np.array([ident, 2 * ident, rot_180x, 2 * rot_180x]) @@ -505,9 +480,7 @@ def test_get_rotation_matrix_from_diffpy(self): q = Quaternion.from_matrix([i.R for i in sg225.symop_list]) assert not np.isnan(q.data).any() - def test_from_matrix_raises( - self, quaternions_conversions, orientation_matrices - ): + def test_from_matrix_raises(self, quaternions_conversions, orientation_matrices): qu = Quaternion.from_matrix(orientation_matrices).data assert np.allclose(qu, quaternions_conversions, atol=1e-4) with pytest.raises(ValueError, match="(3, 3)"): @@ -527,16 +500,12 @@ def test_from_axes_angles(self, rotations, extra_dim): rotations = rotations.__class__(rotations.data[..., np.newaxis, :]) ax = AxAngle.from_rotation(rotations) Q1 = Quaternion.from_axes_angles(ax.axis.data, ax.angle) - Q2 = Quaternion.from_axes_angles( - ax.axis, np.rad2deg(ax.angle), degrees=True - ) + Q2 = Quaternion.from_axes_angles(ax.axis, np.rad2deg(ax.angle), degrees=True) assert np.allclose(Q1.data, Q2.data) def test_to_axes_angles(self, quaternions_conversions, axis_angle_pairs): ax = Quaternion(quaternions_conversions).to_axes_angles() - assert np.allclose( - np.deg2rad(ax.angle), axis_angle_pairs[:, 3], atol=4 - ) + assert np.allclose(np.deg2rad(ax.angle), axis_angle_pairs[:, 3], atol=4) def test_from_axes_angles_empty(self): q = Quaternion.from_axes_angles([], []) @@ -548,16 +517,12 @@ class TestFromToRodrigues: Rodrigues and Rodrigues-Frank vectors. """ - def test_from_to_rodrigues( - self, quaternions_conversions, rodrigues_vectors - ): + def test_from_to_rodrigues(self, quaternions_conversions, rodrigues_vectors): axes = rodrigues_vectors[..., :3] angles = rodrigues_vectors[..., 3] q1 = Quaternion(quaternions_conversions) - with pytest.warns( - UserWarning, match="Highest angle is greater than 179.999 " - ): + with pytest.warns(UserWarning, match="Highest angle is greater than 179.999 "): q2 = Quaternion.from_rodrigues(axes, angles) assert np.allclose(q1.data, q2.data, atol=1e-4) ro = q1.to_rodrigues(frank=True) @@ -567,9 +532,7 @@ def test_from_to_rodrigues( _ = Quaternion.from_rodrigues([1e15, 1e15, 1e10]) with pytest.warns(UserWarning, match="Max."): _ = Quaternion.from_rodrigues([0, 0, 1e-16]) - with pytest.raises( - ValueError, match="Final dimension of vector array must be" - ): + with pytest.raises(ValueError, match="Final dimension of vector array must be"): Quaternion.from_rodrigues([1, 2, 3, 4]) def test_from_rodrigues_empty(self): @@ -582,9 +545,7 @@ class TestFromToHomochoric: homochoric vectors. """ - def test_from_to_homochoric( - self, homochoric_vectors, quaternions_conversions - ): + def test_from_to_homochoric(self, homochoric_vectors, quaternions_conversions): ho1 = homochoric_vectors ho2 = Vector3d(ho1) ho3 = Homochoric(ho1) @@ -598,9 +559,7 @@ def test_from_to_homochoric( assert np.allclose(q1.data, quaternions_conversions, atol=1e-4) assert np.allclose(q2.data, quaternions_conversions, atol=1e-4) assert np.allclose(q3.data, quaternions_conversions, atol=1e-4) - assert np.allclose( - q4.data, quaternions_conversions.reshape(2, 5, 4), atol=1e-4 - ) + assert np.allclose(q4.data, quaternions_conversions.reshape(2, 5, 4), atol=1e-4) assert np.allclose(ho1, q1.to_homochoric().data, atol=1e-4) assert np.allclose(ho1, q2.to_homochoric().data, atol=1e-4) @@ -608,9 +567,7 @@ def test_from_to_homochoric( assert np.allclose(ho4, q4.to_homochoric().data, atol=1e-4) def test_from_homochoric_raises(self): - with pytest.raises( - ValueError, match="Final dimension of vector array must be" - ): + with pytest.raises(ValueError, match="Final dimension of vector array must be"): Quaternion.from_homochoric([1, 2]) def test_from_homochoric_empty(self): From b15fff83011cbd333d8dd42fa88c68634afb33e0 Mon Sep 17 00:00:00 2001 From: Austin Gerlt Date: Sun, 13 Jul 2025 04:29:15 -0600 Subject: [PATCH 04/23] adding requested changes fomr #558 --- orix/quaternion/misorientation.py | 5 ---- orix/quaternion/quaternion.py | 46 ++++++++++++++----------------- 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index c7cc140c4..c4710cf5c 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -388,11 +388,6 @@ def reduce(self) -> Misorientation: """Return equivalent transformations which have the smallest angle of rotation as a new misorientation. - Parameters - ---------- - verbose - Whether to print a progressbar. Default is ``False``. - Returns ------- M diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index f72d6b704..e321be0fd 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -247,15 +247,11 @@ def __eq__(self, other: Any | Quaternion) -> bool: # ------------------------ Class methods ------------------------- # @classmethod - def random(cls, shape: Union[int, tuple] = 1) -> Quaternion: + def random(cls, shape: int | tuple = 1) -> Quaternion: quat = super().random(shape) quat.data[:, 0] = np.abs(quat.data[:, 0]) return quat - O = super().__invert__() - O.symmetry = self.symmetry - return O - @classmethod def from_axes_angles( cls, @@ -697,22 +693,22 @@ def from_align_vectors( @classmethod def from_path_ends( - cls, waypoints: Quaternion, close_loop: bool = False, steps: int = 100 + cls, points: Quaternion, closed: bool = False, steps: int = 100 ) -> Quaternion: - """Return Quaternions tracing the shortest path between two or more - consecutive waypoints. + """Return quaternions tracing the shortest path between two or more + consecutive points. Parameters ---------- - waypoints : Quaternion + points Two or more quaternions that define waypoints along a path through rotation space (SO3). - close_loop : bool, optional + closed Option to add a final trip from the last waypoint back to the - first, thus closing the loop. Default is False. - steps : int, optional - Number of points to return along the path between each pair of - waypoints. The default is 100. + first, thus closing the loop. The default is False. + steps + Number of quaternions to return along the path between each + pair of waypoints. The default is 100. Returns ------- @@ -721,30 +717,30 @@ def from_path_ends( Notes ----- - This method can use Orientations and Misorientations as inputs, and - will return an object of the same class. However, symmetry is ignored - when determining the shortest routes. + This method can use :class:Orientation and :class:Misorientation as + inputs and will return an object of the same class. However, + symmetry is ignored when determining the shortest routes. """ - waypoints = waypoints.flatten() - n = waypoints.size - if not close_loop: + points = points.flatten() + n = points.size + if not closed: n = n - 1 path_list = [] for i in range(n): # get start and end for this leg of the trip - q1 = waypoints[i] - q2 = waypoints[(i + 1) % (waypoints.size)] + q1 = points[i] + q2 = points[(i + 1) % (points.size)] # find the ax/ang describing the trip between points ax, ang = _conversions.qu2ax((~q1 * q2).data) # get 'steps=n' steps along the trip and add them to the journey trip = Quaternion.from_axes_angles(ax, np.linspace(0, ang, steps)) path_list.append((q1 * (trip.flatten())).data) path_data = np.concatenate(path_list, axis=0) - path = waypoints.__class__(path_data) + path = points.__class__(path_data) # copy the symmetry if it exists - if hasattr(waypoints, "_symmetry"): - path._symmetry = waypoints._symmetry + if hasattr(points, "_symmetry"): + path._symmetry = points._symmetry return path @classmethod From 4b3d35b9a9d79bb5703e7f52ab1282692e32759d Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 17 Jul 2025 15:46:34 -0600 Subject: [PATCH 05/23] made Quaternion.from_path_ends blind to symmetry, and added symmetry-aware counterpart to Misorientation --- orix/quaternion/misorientation.py | 32 +++++++++++++++++++++++ orix/quaternion/quaternion.py | 24 ++++++----------- orix/tests/quaternion/test_orientation.py | 28 ++++++++++++++++++++ orix/tests/quaternion/test_quaternion.py | 2 +- 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index c4710cf5c..9175bd5e5 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -266,6 +266,38 @@ def from_scipy_rotation( M.symmetry = symmetry return M + @classmethod + def from_path_ends( + cls, points: Misorientation, closed: bool = False, steps: int = 100 + ) -> Misorientation: + """Return (mis)orientations tracing the shortest path between + two or more consecutive points. + + Parameters + ---------- + points + Two or more (mis)orientations that define waypoints along + a path through rotation space (SO3). + closed + Option to add a final trip from the last waypoint back to + the first, thus closing the loop. The default is False. + steps + Number of (mis)orientations to return along the path + between each pair of waypoints. The default is 100. + + Returns + ------- + path :Quaternion + quaternions that map a path between the given waypoints. + + """ + out = super().from_path_ends(points=points, closed=closed, steps=steps) + path = cls(out.data) + # copy the symmetry if it exists. + if hasattr(points, "_symmetry"): + path._symmetry = points._symmetry + return path + @classmethod def random( cls, diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index e321be0fd..5e9db9f3b 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -695,17 +695,17 @@ def from_align_vectors( def from_path_ends( cls, points: Quaternion, closed: bool = False, steps: int = 100 ) -> Quaternion: - """Return quaternions tracing the shortest path between two or more - consecutive points. + """Return quaternions tracing the shortest path between two or + more consecutive points. Parameters ---------- points - Two or more quaternions that define waypoints along a path through - rotation space (SO3). + Two or more quaternions that define waypoints along a path + through rotation space (SO3). closed - Option to add a final trip from the last waypoint back to the - first, thus closing the loop. The default is False. + Option to add a final trip from the last waypoint back to + the first, thus closing the loop. The default is False. steps Number of quaternions to return along the path between each pair of waypoints. The default is 100. @@ -713,13 +713,8 @@ def from_path_ends( Returns ------- path :Quaternion - quaternions that map out a path between the given waypoints. + quaternions that map a path between the given waypoints. - Notes - ----- - This method can use :class:Orientation and :class:Misorientation as - inputs and will return an object of the same class. However, - symmetry is ignored when determining the shortest routes. """ points = points.flatten() n = points.size @@ -737,10 +732,7 @@ def from_path_ends( trip = Quaternion.from_axes_angles(ax, np.linspace(0, ang, steps)) path_list.append((q1 * (trip.flatten())).data) path_data = np.concatenate(path_list, axis=0) - path = points.__class__(path_data) - # copy the symmetry if it exists - if hasattr(points, "_symmetry"): - path._symmetry = points._symmetry + path = cls(path_data) return path @classmethod diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 0645a135e..96677a507 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -441,6 +441,34 @@ def test_from_scipy_rotation(self): with pytest.raises(TypeError, match="Value must be a 2-tuple of"): _ = Misorientation.from_scipy_rotation(r_scipy, Oh) + def test_from_path_ends(self): + """check from_path_ends returns what you would expect and + preserves symmetry information.""" + q = Quaternion.random(10) + r = Rotation.random(10) + o = Orientation.random(10, Oh) + m = Misorientation.random(10, [D3, Oh]) + + # make sure the result is the correct class. + a = Quaternion.from_path_ends(q) + b = Rotation.from_path_ends(q) + c = Orientation.from_path_ends(r) + d = Quaternion.from_path_ends(o) + e = Orientation.from_path_ends(q) + f = Misorientation.from_path_ends(m) + g = Orientation.from_path_ends(o) + assert isinstance(a, Quaternion) + assert isinstance(b, Rotation) + assert isinstance(c, Orientation) + assert isinstance(d, Quaternion) + assert isinstance(e, Orientation) + assert isinstance(f, Misorientation) + assert isinstance(g, Orientation) + + # make sure symmetry information is preserved. + assert f.symmetry == m.symmetry + assert g.symmetry == o.symmetry + def test_inverse(self): M1 = Misorientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], (Oh, D6)) M2 = ~M1 diff --git a/orix/tests/quaternion/test_quaternion.py b/orix/tests/quaternion/test_quaternion.py index 332193a0a..41a9e2605 100644 --- a/orix/tests/quaternion/test_quaternion.py +++ b/orix/tests/quaternion/test_quaternion.py @@ -352,7 +352,7 @@ def test_from_path_ends(self): ) ) path = Quaternion.from_path_ends(waypoints) - loop = Quaternion.from_path_ends(waypoints, close_loop=True, steps=11) + loop = Quaternion.from_path_ends(waypoints, closed=True, steps=11) # check the sizes are as expected assert path.shape == (1100,) assert loop.shape == (132,) From 6fe959f9ccb4729db9031ca9c09a43f97f87933f Mon Sep 17 00:00:00 2001 From: Austin Gerlt Date: Fri, 18 Jul 2025 03:07:45 -0600 Subject: [PATCH 06/23] fixing docstring styling --- orix/quaternion/quaternion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index 5e9db9f3b..249ead06e 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -701,7 +701,7 @@ def from_path_ends( Parameters ---------- points - Two or more quaternions that define waypoints along a path + Two or more quaternions that define points along a path through rotation space (SO3). closed Option to add a final trip from the last waypoint back to @@ -712,7 +712,7 @@ def from_path_ends( Returns ------- - path :Quaternion + path quaternions that map a path between the given waypoints. """ From 18088ef307a4e4e5e89924c9d3fdd668ac74f481 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:48:07 -0600 Subject: [PATCH 07/23] formatting and clarifying tests --- orix/quaternion/misorientation.py | 3 +- orix/quaternion/quaternion.py | 1 - orix/tests/quaternion/test_orientation.py | 75 +++++++++++++---------- 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index 9175bd5e5..eafd7b6db 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -287,9 +287,8 @@ def from_path_ends( Returns ------- - path :Quaternion + path quaternions that map a path between the given waypoints. - """ out = super().from_path_ends(points=points, closed=closed, steps=steps) path = cls(out.data) diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index 249ead06e..d11fa2b60 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -714,7 +714,6 @@ def from_path_ends( ------- path quaternions that map a path between the given waypoints. - """ points = points.flatten() n = points.size diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 96677a507..c4e93c661 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -321,6 +321,39 @@ def test_symmetry_property_wrong_number_of_values_misorientation(error_type, val o.symmetry = value +def test_from_path_ends(): + """check from_path_ends returns what you would expect and + preserves symmetry information. + + In particular, ensure the class of the returned object matches the class + used for creating it, NOT the class of the object passed in. + """ + q = Quaternion.random(10) + r = Rotation.random(10) + o = Orientation.random(10, Oh) + m = Misorientation.random(10, [D3, Oh]) + + # make sure the result is the correct class. + a = Quaternion.from_path_ends(q) + b = Rotation.from_path_ends(q) + c = Orientation.from_path_ends(r) + d = Quaternion.from_path_ends(o) + e = Orientation.from_path_ends(q) + f = Misorientation.from_path_ends(m) + g = Orientation.from_path_ends(o) + assert isinstance(a, Quaternion) + assert isinstance(b, Rotation) + assert isinstance(c, Orientation) + assert isinstance(d, Quaternion) + assert isinstance(e, Orientation) + assert isinstance(f, Misorientation) + assert isinstance(g, Orientation) + + # make sure symmetry information is preserved. + assert f.symmetry == m.symmetry + assert g.symmetry == o.symmetry + + class TestMisorientation: def test_get_distance_matrix(self): """Compute distance between every misorientation in an instance @@ -442,32 +475,9 @@ def test_from_scipy_rotation(self): _ = Misorientation.from_scipy_rotation(r_scipy, Oh) def test_from_path_ends(self): - """check from_path_ends returns what you would expect and - preserves symmetry information.""" - q = Quaternion.random(10) - r = Rotation.random(10) - o = Orientation.random(10, Oh) - m = Misorientation.random(10, [D3, Oh]) - - # make sure the result is the correct class. - a = Quaternion.from_path_ends(q) - b = Rotation.from_path_ends(q) - c = Orientation.from_path_ends(r) - d = Quaternion.from_path_ends(o) - e = Orientation.from_path_ends(q) - f = Misorientation.from_path_ends(m) - g = Orientation.from_path_ends(o) - assert isinstance(a, Quaternion) - assert isinstance(b, Rotation) - assert isinstance(c, Orientation) - assert isinstance(d, Quaternion) - assert isinstance(e, Orientation) - assert isinstance(f, Misorientation) - assert isinstance(g, Orientation) - - # make sure symmetry information is preserved. - assert f.symmetry == m.symmetry - assert g.symmetry == o.symmetry + # generate paths with misorientations to check symmetry copying + wp_m = Misorientation(data=np.eye(4)[2:], symmetry=[Oh, C3]) + assert Misorientation.from_path_ends(wp_m)._symmetry == (Oh, C3) def test_inverse(self): M1 = Misorientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], (Oh, D6)) @@ -628,14 +638,6 @@ def test_from_scipy_rotation(self): with pytest.raises(TypeError, match="Value must be an instance of"): _ = Orientation.from_scipy_rotation(r_scipy, (Oh, Oh)) - def test_from_path_ends(self): - # generate paths with misorientations and orientations to check - # symmetry copying - wp_o = Orientation(data=np.eye(4)[:2], symmetry=Oh) - wp_m = Misorientation(data=np.eye(4)[2:], symmetry=[Oh, C3]) - assert Orientation.from_path_ends(wp_o)._symmetry == (C1, Oh) - assert Misorientation.from_path_ends(wp_m)._symmetry == (Oh, C3) - class TestOrientation: @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) @@ -846,6 +848,11 @@ def test_in_fundamental_region(self): region = np.radians(pg.euler_fundamental_region) assert np.all(np.max(ori.in_euler_fundamental_region(), axis=0) <= region) + def test_from_path_ends(self): + # generate paths with orientations to check symmetry copying + wp_o = Orientation(data=np.eye(4)[:2], symmetry=Oh) + assert Orientation.from_path_ends(wp_o)._symmetry == (C1, Oh) + def test_inverse(self): O1 = Orientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], D6) O2 = ~O1 From 892054acee019b65bc85796197535cd50e6dd387 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:38:21 -0600 Subject: [PATCH 08/23] clarifying tests and adding from_path_ends example --- examples/plotting/plot_non_euclidean_paths.py | 81 +++++++++++++++++++ orix/tests/quaternion/test_quaternion.py | 12 +++ 2 files changed, 93 insertions(+) create mode 100644 examples/plotting/plot_non_euclidean_paths.py diff --git a/examples/plotting/plot_non_euclidean_paths.py b/examples/plotting/plot_non_euclidean_paths.py new file mode 100644 index 000000000..3a3b6ca16 --- /dev/null +++ b/examples/plotting/plot_non_euclidean_paths.py @@ -0,0 +1,81 @@ +r""" +======================== +Plot symmetry operations +======================== + +This example shows how to use the `from_path_ends` functions from +:class:`~orix.vector.Vector3d`, :class:`~orix.quaternions.Rotation`, and +:class:`~orix.quaternions.Orientation` to draw paths through thier +respective non-Euclidean spaces. +""" + +import numpy as np +import matplotlib.pyplot as plt +from orix.quaternion import Orientation, Rotation +from orix.quaternion.symmetry import Oh, D3 +from orix.vector import Vector3d + + +fig = plt.figure() + +# plot a path in homochoric space with no symmetry +rot_path = Rotation( + data=np.array( + [ + [1, 0, 0, 0], + [1, 1, 0, 0], + [1, 0, 1, 0], + [1, 0, 0, 1], + [1, 0, -1, 0], + [1, 0, 0, -1], + [1, 0, 0, -1], + ] + ) +) +rotation_path = Rotation.from_path_ends(rot_path, closed=True) +# cast the rotation to a symmetry-less orientation for plotting purposes +Orientation(rotation_path).scatter( + figure=fig, position=[2, 2, 1], marker=">", c=np.arange(700) +) + +# plot a path in rodrigues space with m-3m (cubic) symmetry. +m3m_path = Orientation( + data=np.array( + [ + [1, 0, 0, 0], + [2, 1, 0, 0], + [3, 0, 1, 0], + [4, 0, 0, 1], + [5, 0, -1, 0], + [6, 0, 0, -1], + [7, 0, 0, -1], + [8, 1, 0, 0], + [9, 0, 1, 0], + [10, 0, 0, 1], + [11, 0, -1, 0], + [12, 0, 0, -1], + [13, 0, 0, -1], + ] + ), + symmetry=Oh, +) +orientation_path = Orientation.from_path_ends(m3m_path.reduce(), closed=True).reduce() +orientation_path.scatter(figure=fig, position=[2, 2, 2], marker=">", c=np.arange(1300)) + +# plot a second path in rodrigues space with symmetry, but while also crossing a +# symmetry boundary +fiber_start = Rotation.identity(1) +fiber_middle = Rotation.from_axes_angles([1, 2, 3], np.pi) +fiber_end = Rotation.from_axes_angles([1, 2, 3], 2 * np.pi) +fiber_points = Orientation.stack([fiber_start, fiber_middle, fiber_end]) +fiber_points.symmetry = Oh +fiber_path = Orientation.from_path_ends(fiber_points, closed=False).reduce() +fiber_path.scatter(figure=fig, position=[2, 2, 3], marker=">", c=np.arange(200)) + + +# plot vectors +ax4 = plt.subplot(2, 2, 4, projection="stereographic") +vector_points = Vector3d(np.array([[-1,0,0],[0,1,0.1],[1,0,0.2],[0,-1,0.3],[-1,0,0.4]])) + +vector_path = Vector3d.from_path_ends(vector_points,steps = 200) +ax4.scatter(vector_path, figure=fig, marker=">", c=np.arange(vector_path.size)) diff --git a/orix/tests/quaternion/test_quaternion.py b/orix/tests/quaternion/test_quaternion.py index 41a9e2605..fea55e915 100644 --- a/orix/tests/quaternion/test_quaternion.py +++ b/orix/tests/quaternion/test_quaternion.py @@ -362,6 +362,18 @@ def test_from_path_ends(self): assert np.all(np.std(path_spacing, axis=1) < 1e-12) assert np.all(np.std(loop_spacing, axis=1) < 1e-12) + def test_from_path_ends_fiber(self): + # check that a linear path in quaternion space follows an explicitly defined + # fiber along the same path. + # this ensures the path is the shortest distance in SO3 space, (as opposed + # to rodrigues or quaternion space) and also evenly spaced along the path. + Q1 = Quaternion.identity() + Q2 = Quaternion.from_axes_angles([1, 1, 1], 60, degrees=True) + Q12 = Quaternion.stack((Q1, Q2)) + Q_path1 = Quaternion.from_axes_angles([1, 1, 1], np.arange(59), degrees=True) + Q_path2 = Quaternion.from_path_ends(Q12, steps=Q_path1.size) + assert np.allclose(Q_path1.dot(Q_path2), 1, atol=1e-3) + def test_equality(self): Q1 = Quaternion.from_axes_angles([1, 1, 1], -np.pi / 3) Q2 = Quaternion.from_axes_angles([1, 1, 1], np.pi / 3) From 269996cff3a7df5033171b3ace20b8be47adb1ce Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:48:54 -0600 Subject: [PATCH 09/23] formatting --- examples/plotting/plot_non_euclidean_paths.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/plotting/plot_non_euclidean_paths.py b/examples/plotting/plot_non_euclidean_paths.py index 3a3b6ca16..50dbe2060 100644 --- a/examples/plotting/plot_non_euclidean_paths.py +++ b/examples/plotting/plot_non_euclidean_paths.py @@ -9,13 +9,13 @@ respective non-Euclidean spaces. """ -import numpy as np import matplotlib.pyplot as plt +import numpy as np + from orix.quaternion import Orientation, Rotation -from orix.quaternion.symmetry import Oh, D3 +from orix.quaternion.symmetry import D3, Oh from orix.vector import Vector3d - fig = plt.figure() # plot a path in homochoric space with no symmetry @@ -75,7 +75,9 @@ # plot vectors ax4 = plt.subplot(2, 2, 4, projection="stereographic") -vector_points = Vector3d(np.array([[-1,0,0],[0,1,0.1],[1,0,0.2],[0,-1,0.3],[-1,0,0.4]])) +vector_points = Vector3d( + np.array([[-1, 0, 0], [0, 1, 0.1], [1, 0, 0.2], [0, -1, 0.3], [-1, 0, 0.4]]) +) -vector_path = Vector3d.from_path_ends(vector_points,steps = 200) +vector_path = Vector3d.from_path_ends(vector_points, steps=200) ax4.scatter(vector_path, figure=fig, marker=">", c=np.arange(vector_path.size)) From e5c307f7c57bc07a860bcfa1d2204a942af37573 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Wed, 3 Sep 2025 19:56:12 -0600 Subject: [PATCH 10/23] including suggestions and updating example --- examples/plotting/plot_non_euclidean_paths.py | 138 +++++++++++------- orix/quaternion/misorientation.py | 18 ++- orix/quaternion/orientation.py | 35 +++++ orix/tests/quaternion/test_orientation.py | 53 +++++-- 4 files changed, 169 insertions(+), 75 deletions(-) diff --git a/examples/plotting/plot_non_euclidean_paths.py b/examples/plotting/plot_non_euclidean_paths.py index 50dbe2060..2768c0a2a 100644 --- a/examples/plotting/plot_non_euclidean_paths.py +++ b/examples/plotting/plot_non_euclidean_paths.py @@ -1,83 +1,115 @@ r""" -======================== -Plot symmetry operations -======================== +======================================== +Plot Paths Through Non-Euclidean Spaces +======================================== -This example shows how to use the `from_path_ends` functions from -:class:`~orix.vector.Vector3d`, :class:`~orix.quaternions.Rotation`, and -:class:`~orix.quaternions.Orientation` to draw paths through thier -respective non-Euclidean spaces. +This example shows three variations on how 'from_path_ends' can be +used to plot paths between points in rotational and vector spaces. + +This functionality is available in :class:`~orix.vector.Vector3d`, +:class:`~orix.quaternions.Rotation`, +:class:`~orix.quaternions.Orientation`, +and :class:`~orix.quaternions.Misorientation`. """ import matplotlib.pyplot as plt +from matplotlib import cm import numpy as np -from orix.quaternion import Orientation, Rotation +from orix.quaternion import Misorientation, Orientation, Rotation from orix.quaternion.symmetry import D3, Oh from orix.vector import Vector3d -fig = plt.figure() +fig = plt.figure(figsize=(4, 8)) -# plot a path in homochoric space with no symmetry -rot_path = Rotation( +# ========= # +# Example 1: Plotting a path of rotations with no symmetry in homochoric space +# ========= # +rots_along_path = Rotation( data=np.array( [ [1, 0, 0, 0], - [1, 1, 0, 0], - [1, 0, 1, 0], [1, 0, 0, 1], - [1, 0, -1, 0], - [1, 0, 0, -1], - [1, 0, 0, -1], + [1, 1, 1, 1], ] ) ) -rotation_path = Rotation.from_path_ends(rot_path, closed=True) -# cast the rotation to a symmetry-less orientation for plotting purposes -Orientation(rotation_path).scatter( - figure=fig, position=[2, 2, 1], marker=">", c=np.arange(700) -) +n_steps = 20 +rotation_path = Rotation.from_path_ends(rots_along_path, steps=n_steps) +# create an Orientation loop using this path with no symmetry elements +ori_path = Orientation(rotation_path) +# plot the path in homochoric space +segment_colors = cm.inferno(np.linspace(0, 1, n_steps)) -# plot a path in rodrigues space with m-3m (cubic) symmetry. -m3m_path = Orientation( +path_colors = np.vstack([segment_colors for x in range(rots_along_path.size - 1)]) +ori_path.scatter(figure=fig, position=[3, 1, 1], marker=">", c=path_colors) +fig.axes[0].set_title(r"$90^\circ$ rotation around X, then Y") + +# ========= # +# Example 2: Plotting the rotation of several orientations in m3m Rodrigues +# space around the z axis. +# ========= # +oris = Orientation( data=np.array( [ - [1, 0, 0, 0], - [2, 1, 0, 0], - [3, 0, 1, 0], - [4, 0, 0, 1], - [5, 0, -1, 0], - [6, 0, 0, -1], - [7, 0, 0, -1], - [8, 1, 0, 0], - [9, 0, 1, 0], - [10, 0, 0, 1], - [11, 0, -1, 0], - [12, 0, 0, -1], - [13, 0, 0, -1], + [0.69, 0.24, 0.68, 0.01], + [0.26, 0.59, 0.32, 0.7], + [0.07, 0.17, 0.93, 0.31], + [0.6, 0.03, 0.61, 0.52], + [0.51, 0.38, 0.34, 0.69], + [0.31, 0.86, 0.22, 0.35], + [0.68, 0.67, 0.06, 0.31], + [0.01, 0.12, 0.05, 0.99], + [0.39, 0.45, 0.34, 0.72], + [0.65, 0.59, 0.46, 0.15], ] ), symmetry=Oh, +).reduce() +# define a 20 degree rotation around the z axis +shift = Orientation.from_axes_angles([0, 0, 1], np.pi / 9) +segment_colors = cm.inferno(np.linspace(0, 1, 10)) + +ori_paths = [] +for ori in oris: + shifted = (shift * ori).reduce() + to_from = Orientation.stack([ori, shifted]).flatten() + ori_paths.append(Orientation.from_path_ends(to_from, steps=10)) +# plot a path in roddrigues space with m-3m (cubic) symmetry. +ori_path = Orientation.stack(ori_paths).flatten() +ori_path.symmetry = Oh +ori_path.scatter( + figure=fig, + position=[3, 1, 2], + marker=">", + c=np.tile(segment_colors, [10, 1]), + projection="rodrigues", ) -orientation_path = Orientation.from_path_ends(m3m_path.reduce(), closed=True).reduce() -orientation_path.scatter(figure=fig, position=[2, 2, 2], marker=">", c=np.arange(1300)) +fig.axes[1].set_title(r"$20^{\circ}$ rotations around X-axis in m3m") -# plot a second path in rodrigues space with symmetry, but while also crossing a -# symmetry boundary -fiber_start = Rotation.identity(1) -fiber_middle = Rotation.from_axes_angles([1, 2, 3], np.pi) -fiber_end = Rotation.from_axes_angles([1, 2, 3], 2 * np.pi) -fiber_points = Orientation.stack([fiber_start, fiber_middle, fiber_end]) -fiber_points.symmetry = Oh -fiber_path = Orientation.from_path_ends(fiber_points, closed=False).reduce() -fiber_path.scatter(figure=fig, position=[2, 2, 3], marker=">", c=np.arange(200)) +# ========= # +# Example 3: creating a customized Wulf Plotting the rotation of several orientations in m3m Rodrigues +# space around the z axis. +# ========= # # plot vectors -ax4 = plt.subplot(2, 2, 4, projection="stereographic") -vector_points = Vector3d( - np.array([[-1, 0, 0], [0, 1, 0.1], [1, 0, 0.2], [0, -1, 0.3], [-1, 0, 0.4]]) -) +ax_upper = plt.subplot(3, 1, 3, projection="stereographic", hemisphere="upper") +r90x = Rotation.from_axes_angles([1, -1, -1], [0, 60], degrees=True) +x_axis_points = r90x * Vector3d.xvector() +y_axis_points = r90x * Vector3d.yvector() +z_axis_points = r90x * Vector3d.zvector() + +x_axis_path = Vector3d.from_path_ends(x_axis_points.unique()) +y_axis_path = Vector3d.from_path_ends(y_axis_points.unique()) +z_axis_path = Vector3d.from_path_ends(z_axis_points.unique()) +cx = cm.Reds(np.linspace(0.1, 1, x_axis_path.size)) +cy = cm.Greens(np.linspace(0.1, 1, y_axis_path.size)) +cz = cm.Blues(np.linspace(0.1, 1, z_axis_path.size)) + +spx = ax_upper.scatter(x_axis_path, figure=fig, marker=">", c=cx, label="X") +spy = ax_upper.scatter(y_axis_path, figure=fig, marker=">", c=cy, label="Y") +spz = ax_upper.scatter(z_axis_path, figure=fig, marker=">", c=cz, label="Z") +ax_upper.legend(loc="lower center", ncols=3) -vector_path = Vector3d.from_path_ends(vector_points, steps=200) -ax4.scatter(vector_path, figure=fig, marker=">", c=np.arange(vector_path.size)) +plt.tight_layout() diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index eafd7b6db..018d0d06c 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -270,31 +270,35 @@ def from_scipy_rotation( def from_path_ends( cls, points: Misorientation, closed: bool = False, steps: int = 100 ) -> Misorientation: - """Return (mis)orientations tracing the shortest path between + """Return misorientations tracing the shortest path between two or more consecutive points. Parameters ---------- points - Two or more (mis)orientations that define waypoints along + Two or more misorientations that define waypoints along a path through rotation space (SO3). closed Option to add a final trip from the last waypoint back to the first, thus closing the loop. The default is False. steps - Number of (mis)orientations to return along the path + Number of misorientations to return along the path between each pair of waypoints. The default is 100. Returns ------- path - quaternions that map a path between the given waypoints. + misorientations that map a path between the given waypoints. """ + # Confirm `points` are (mis)orientations. + if not isinstance(points, Misorientation): + raise TypeError( + f"Points must be a Misorientation, not of type {type(points)}" + ) + # Create a path through Quaternion space, then reapply the symmetry. out = super().from_path_ends(points=points, closed=closed, steps=steps) path = cls(out.data) - # copy the symmetry if it exists. - if hasattr(points, "_symmetry"): - path._symmetry = points._symmetry + path._symmetry = points._symmetry return path @classmethod diff --git a/orix/quaternion/orientation.py b/orix/quaternion/orientation.py index 598208c75..d93b524d5 100644 --- a/orix/quaternion/orientation.py +++ b/orix/quaternion/orientation.py @@ -346,6 +346,41 @@ def from_scipy_rotation( O.symmetry = symmetry return O + @classmethod + def from_path_ends( + cls, points: Orientation, closed: bool = False, steps: int = 100 + ) -> Misorientation: + """Return orientations tracing the shortest path between + two or more consecutive points. + + Parameters + ---------- + points + Two or more orientations that define waypoints along + a path through rotation space (SO3). + closed + Option to add a final trip from the last waypoint back to + the first, thus closing the loop. The default is False. + steps + Number of orientations to return along the path + between each pair of waypoints. The default is 100. + + Returns + ------- + path + orientations that map a path between the given waypoints. + """ + # Confirm `points` are orientations. + if not isinstance(points, Orientation): + raise TypeError( + f"Points must be an Orientation instance, not of type {type(points)}" + ) + # Create a path through Quaternion space, then reapply the symmetry. + out = super().from_path_ends(points=points, closed=closed, steps=steps) + path = cls(out.data) + path._symmetry = points._symmetry + return path + @classmethod def random( cls, shape: Union[int, tuple] = 1, symmetry: Optional[Symmetry] = None diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index c4e93c661..a259fc998 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -333,25 +333,48 @@ def test_from_path_ends(): o = Orientation.random(10, Oh) m = Misorientation.random(10, [D3, Oh]) - # make sure the result is the correct class. + # Quaternion sanity checks a = Quaternion.from_path_ends(q) - b = Rotation.from_path_ends(q) - c = Orientation.from_path_ends(r) - d = Quaternion.from_path_ends(o) - e = Orientation.from_path_ends(q) - f = Misorientation.from_path_ends(m) - g = Orientation.from_path_ends(o) assert isinstance(a, Quaternion) - assert isinstance(b, Rotation) - assert isinstance(c, Orientation) + b = Quaternion.from_path_ends(r) + assert isinstance(b, Quaternion) + c = Quaternion.from_path_ends(o) + assert isinstance(c, Quaternion) + d = Quaternion.from_path_ends(m) assert isinstance(d, Quaternion) - assert isinstance(e, Orientation) - assert isinstance(f, Misorientation) - assert isinstance(g, Orientation) - # make sure symmetry information is preserved. - assert f.symmetry == m.symmetry - assert g.symmetry == o.symmetry + # Rotation sanity checks + a = Rotation.from_path_ends(q) + assert isinstance(a, Rotation) + b = Rotation.from_path_ends(r) + assert isinstance(b, Rotation) + c = Rotation.from_path_ends(o) + assert isinstance(c, Rotation) + d = Rotation.from_path_ends(m) + assert isinstance(d, Rotation) + + # Misorientation sanity checks + with pytest.raises(TypeError, match="Points must be a Misorientation"): + a = Misorientation.from_path_ends(q) + with pytest.raises(TypeError, match="Points must be a Misorientation"): + b = Misorientation.from_path_ends(r) + c = Misorientation.from_path_ends(o) + assert isinstance(c, Misorientation) + d = Misorientation.from_path_ends(m) + assert isinstance(d, Misorientation) + assert c.symmetry[1] == o.symmetry + assert d.symmetry == m.symmetry + + # Orientation sanity checks + with pytest.raises(TypeError, match="Points must be an Orientation"): + a = Orientation.from_path_ends(q) + with pytest.raises(TypeError, match="Points must be an Orientation"): + b = Orientation.from_path_ends(r) + c = Orientation.from_path_ends(o) + assert c.symmetry == o.symmetry + assert isinstance(c, Orientation) + with pytest.raises(TypeError, match="Points must be an Orientation"): + d = Orientation.from_path_ends(m) class TestMisorientation: From 70a877cbc69a2da090c095e1cea72f29a93eee96 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 4 Sep 2025 16:47:33 -0600 Subject: [PATCH 11/23] hotfix for projections --- orix/plot/__init__.py | 12 +++++++++++- orix/plot/__init__.pyi | 6 ------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/orix/plot/__init__.py b/orix/plot/__init__.py index b5010328a..de90d82d4 100644 --- a/orix/plot/__init__.py +++ b/orix/plot/__init__.py @@ -22,10 +22,20 @@ :class:`~orix.quaternion.Orientation`, :class:`~orix.quaternion.Misorientation`, and :class:`~orix.crystal_map.CrystalMap`. -""" +NOTE: While lazy loading is preferred, the following six classes +are explicitly imported in order to populate matplotlib.projections +""" import lazy_loader +from orix.plot.crystal_map_plot import CrystalMapPlot +from orix.plot.rotation_plot import AxAnglePlot, RodriguesPlot, RotationPlot +from orix.plot.stereographic_plot import StereographicPlot + +# Must be imported below StereographicPlot since it imports it +from orix.plot.inverse_pole_figure_plot import InversePoleFigurePlot # isort: skip + + # Imports from stub file (see contributor guide for details) __getattr__, __dir__, __all__ = lazy_loader.attach_stub(__name__, __file__) diff --git a/orix/plot/__init__.pyi b/orix/plot/__init__.pyi index c34d2e7dd..b4c339971 100644 --- a/orix/plot/__init__.pyi +++ b/orix/plot/__init__.pyi @@ -30,15 +30,9 @@ from .inverse_pole_figure_plot import InversePoleFigurePlot # isort: skip # Lazily imported in module init __all__ = [ # Classes - "AxAnglePlot", - "CrystalMapPlot", "DirectionColorKeyTSL", "EulerColorKey", - "InversePoleFigurePlot", "IPFColorKeyTSL", - "RodriguesPlot", - "RotationPlot", - "StereographicPlot", # Functions "format_labels", ] From 81c6b1d55b2b26ea2803550bcb134cb48deaea1f Mon Sep 17 00:00:00 2001 From: Austin Gerlt Date: Mon, 19 May 2025 01:11:45 -0600 Subject: [PATCH 12/23] adding ancillary capabilities from PR442 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Håkon Wiik Ånes --- CHANGELOG.rst | 8 ++- orix/quaternion/misorientation.py | 49 ++++++++++++++++++ orix/quaternion/quaternion.py | 62 +++++++++++++++++++++++ orix/tests/quaternion/test_orientation.py | 30 ++++++----- 4 files changed, 133 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8b2b47049..305b8b33e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -31,10 +31,14 @@ Changed Removed ------- - +- ``verbose`` parameter in ``reduce()`` + (replacing ``map_into_symmetry_reduced_zone()``). + Deprecated ---------- - +- ``map_into_symmetry_reduced_zone()`` is deprecated since 0.14 and will be removed in + 0.15. Use ``reduce()`` instead. + Fixed ----- - minor speedups to 'pytest' diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index d5005865f..c7cc140c4 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -31,6 +31,7 @@ from scipy.spatial.transform import Rotation as SciPyRotation from tqdm import tqdm +from orix._util import deprecated from orix.quaternion.orientation_region import OrientationRegion from orix.quaternion.rotation import Rotation from orix.quaternion.symmetry import C1, Symmetry, _get_unique_symmetry_elements @@ -340,6 +341,7 @@ def equivalent(self, grain_exchange: bool = False) -> Misorientation: return self.__class__(equivalent).flatten() + @deprecated(since="0.14", removal="0.15", alternative="reduce") def map_into_symmetry_reduced_zone(self, verbose: bool = False) -> Misorientation: """Return equivalent transformations which have the smallest angle of rotation as a new misorientation. @@ -382,6 +384,53 @@ def map_into_symmetry_reduced_zone(self, verbose: bool = False) -> Misorientatio o_inside._symmetry = (Gl, Gr) return o_inside + def reduce(self) -> Misorientation: + """Return equivalent transformations which have the smallest + angle of rotation as a new misorientation. + + Parameters + ---------- + verbose + Whether to print a progressbar. Default is ``False``. + + Returns + ------- + M + A new misorientation object with the assigned symmetry. + + Examples + -------- + >>> from orix.quaternion.symmetry import C4, C2 + >>> data = np.array([[0.5, 0.5, 0.5, 0.5], [0, 1, 0, 0]]) + >>> M = Misorientation(data) + >>> M.symmetry = (C4, C2) + >>> M.reduce() + Misorientation (2,) 4, 2 + [[-0.7071 0.7071 0. 0. ] + [ 0. 1. 0. 0. ]] + """ + # create an itertool object for iterating over every + # combination of left and right symmetry operations + Gl, Gr = self._symmetry + symmetry_pairs = iproduct(Gl, Gr) + + # Define a Fundamental Zone (fz). for every misorientation, there + # is one and only one combination in `symmetry_pairs` that rotates + # it into the fz. + fz = OrientationRegion.from_symmetry(Gl, Gr) + reduced = self.__class__.identity(self.shape) + outside = np.ones(self.shape, dtype=bool) + # apply transformations to unreduced misorientations and save the + # reduced ones to `reduced` until all are inside the fz + for gl, gr in symmetry_pairs: + o_transformed = gl * self[outside] * gr + reduced[outside] = o_transformed + outside = ~(reduced < fz) + if not np.any(outside): + break + reduced._symmetry = (Gl, Gr) + return reduced + def scatter( self, projection: str = "axangle", diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index 099abf872..cc2243481 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -246,6 +246,16 @@ def __eq__(self, other: Any | Quaternion) -> bool: # ------------------------ Class methods ------------------------- # + @classmethod + def random(cls, shape: Union[int, tuple] = 1) -> Quaternion: + quat = super().random(shape) + quat.data[:, 0] = np.abs(quat.data[:, 0]) + return quat + + O = super().__invert__() + O.symmetry = self.symmetry + return O + @classmethod def from_axes_angles( cls, @@ -685,6 +695,58 @@ def from_align_vectors( return out[0] if len(out) == 1 else tuple(out) + @classmethod + def from_path_ends( + cls, waypoints: Quaternion, close_loop: bool = False, steps: int = 100 + ) -> Quaternion: + """Return Quaternions tracing the shortest path between two or more + consecutive waypoints. + + Parameters + ---------- + waypoints : Quaternion + Two or more quaternions that define waypoints along a path through + rotation space (SO3). + close_loop : bool, optional + Option to add a final trip from the last waypoint back to the + first, thus closing the loop. Default is False. + steps : int, optional + Number of points to return along the path between each pair of + waypoints. The default is 100. + + Returns + ------- + path :Quaternion + quaternions that map out a path between the given waypoints. + + Notes + ----- + This method can use Orientations and Misorientations as inputs, and + will return an object of the same class. However, symmetry is ignored + when determining the shortest routes. + """ + waypoints = waypoints.flatten() + n = waypoints.size + if not close_loop: + n = n - 1 + + path_list = [] + for i in range(n): + # get start and end for this leg of the trip + q1 = waypoints[i] + q2 = waypoints[(i + 1) % (waypoints.size)] + # find the ax/ang describing the trip between points + ax, ang = _conversions.qu2ax((~q1 * q2).data) + # get 100 steps along the trip and add them to the journey + trip = Quaternion.from_axes_angles(ax, np.linspace(0, ang, steps)) + path_list.append((q1 * (trip.flatten())).data) + path_data = np.concatenate(path_list, axis=0) + path = waypoints.__class__(path_data) + # copy the symmetry if it exists + if hasattr(waypoints, "_symmetry"): + path._symmetry = waypoints._symmetry + return path + @classmethod def triple_cross(cls, q1: Quaternion, q2: Quaternion, q3: Quaternion) -> Quaternion: """Pointwise cross product of three quaternions. diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 4ea13b1d9..003c22ceb 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -101,7 +101,7 @@ def test_quaternion_subclasses_copy_constructor_casting(): ) def test_set_symmetry(orientation, symmetry, expected): o = Orientation(orientation.data, symmetry=symmetry) - o = o.map_into_symmetry_reduced_zone() + o = o.reduce() assert np.allclose(o.data, expected, atol=1e-3) @@ -114,7 +114,7 @@ def test_orientation_persistence(symmetry, vector): v = symmetry.outer(vector).flatten() o = Orientation.random() oc = Orientation(o.data, symmetry=symmetry) - oc = oc.map_into_symmetry_reduced_zone() + oc = oc.reduce() v1 = o * v v1 = Vector3d(v1.data.round(4)) v2 = oc * v @@ -207,7 +207,7 @@ def test_equivalent(Gl): """ m = Misorientation([1, 1, 1, 1]) # any will do m_new = Misorientation(m.data, symmetry=(Gl, C4)) - m_new = m_new.map_into_symmetry_reduced_zone() + m_new = m_new.reduce() _ = m_new.equivalent(grain_exchange=True) @@ -220,13 +220,13 @@ def test_repr_ori(): shape = (2, 3) o = Orientation.identity(shape) o.symmetry = O - o = o.map_into_symmetry_reduced_zone() + o = o.reduce() assert repr(o).split("\n")[0] == f"Orientation {shape} {O.name}" def test_sub(): o = Orientation([1, 1, 1, 1], symmetry=C4) # any will do - o = o.map_into_symmetry_reduced_zone() + o = o.reduce() m = o - o assert np.allclose(m.data, [1, 0, 0, 0]) @@ -247,8 +247,10 @@ def test_map_into_reduced_symmetry_zone_verbose(): o = Orientation.random() o.symmetry = Oh o1 = o.map_into_symmetry_reduced_zone() - o2 = o.map_into_symmetry_reduced_zone(verbose=True) + o2 = o.reduce() + o3 = o.map_into_symmetry_reduced_zone(verbose=True) assert np.allclose(o1.data, o2.data) + assert np.allclose(o1.data, o3.data) @pytest.mark.parametrize( @@ -264,7 +266,7 @@ def test_transpose_3d(shape, expected_shape, axes): def test_transpose_symmetry(): o1 = Orientation.random_vonmises((11, 3)) o1.symmetry = Oh - o1 = o1.map_into_symmetry_reduced_zone() + o1 = o1.reduce() o2 = o1.transpose() assert o1.symmetry == o2.symmetry @@ -488,11 +490,11 @@ def test_from_euler_symmetry(self): assert np.allclose(o1.data, [0, -0.3827, 0, -0.9239], atol=1e-4) assert o1.symmetry.name == "1" o2 = Orientation.from_euler(euler, symmetry=Oh) - o2 = o2.map_into_symmetry_reduced_zone() + o2 = o2.reduce() assert np.allclose(o2.data, [0.9239, 0, 0.3827, 0], atol=1e-4) assert o2.symmetry.name == "m-3m" o3 = Orientation(o1.data, symmetry=Oh) - o3 = o3.map_into_symmetry_reduced_zone() + o3 = o3.reduce() assert np.allclose(o3.data, o2.data) o4 = Orientation.from_euler(np.rad2deg(euler), degrees=True) @@ -508,13 +510,13 @@ def test_from_matrix_symmetry(self): ) assert o1.symmetry.name == "1" o2 = Orientation.from_matrix(om, symmetry=Oh) - o2 = o2.map_into_symmetry_reduced_zone() + o2 = o2.reduce() assert np.allclose( o2.data, np.array([1, 0, 0, 0] * 2 + [-1, 0, 0, 0] * 2).reshape(4, 4) ) assert o2.symmetry.name == "m-3m" o3 = Orientation(o1.data, symmetry=Oh) - o3 = o3.map_into_symmetry_reduced_zone() + o3 = o3.reduce() assert np.allclose(o3.data, o2.data) def test_from_align_vectors(self): @@ -601,7 +603,7 @@ class TestOrientation: def test_get_distance_matrix(self, symmetry): q = [(0.5, 0.5, 0.5, 0.5), (0.5**0.5, 0, 0, 0.5**0.5)] o = Orientation(q, symmetry=symmetry) - o = o.map_into_symmetry_reduced_zone() + o = o.reduce() angles_numpy = o.get_distance_matrix() assert isinstance(angles_numpy, np.ndarray) assert angles_numpy.shape == (2, 2) @@ -694,7 +696,7 @@ def test_angle_with(self, symmetry): def test_negate_orientation(self): o = Orientation.identity() o.symmetry = Oh - o = o.map_into_symmetry_reduced_zone() + o = o.reduce() on = -o assert on.symmetry.name == o.symmetry.name @@ -703,7 +705,7 @@ def test_scatter(self, orientation, pure_misorientation): if pure_misorientation: orientation = Misorientation(orientation) orientation.symmetry = (C2, D6) - orientation = orientation.map_into_symmetry_reduced_zone() + orientation = orientation.reduce() fig_size = (5, 5) fig_axangle = orientation.scatter( return_figure=True, figure_kwargs=dict(figsize=fig_size) From d01b78be3473eb66b63bf8b72e5fc58b1c139cb4 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Tue, 27 May 2025 15:35:29 -0600 Subject: [PATCH 13/23] add tests for 'from_path_ends` --- orix/quaternion/quaternion.py | 50 ++++++--- orix/tests/quaternion/test_orientation.py | 45 ++++++-- orix/tests/quaternion/test_quaternion.py | 122 ++++++++++++++++++---- 3 files changed, 170 insertions(+), 47 deletions(-) diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index cc2243481..c18f1d5af 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -505,7 +505,9 @@ def from_euler( eu = np.atleast_2d(euler) if np.any(np.abs(eu) > 4 * np.pi): - warnings.warn("Angles are quite high, did you forget to set degrees=True?") + warnings.warn( + "Angles are quite high, did you forget to set degrees=True?" + ) Q = _conversions.eu2qu(eu) Q = cls(Q) @@ -544,7 +546,9 @@ def from_matrix(cls, matrix: np.ndarray | tuple | list) -> Quaternion: # Verify the input can be interpreted as an array of (3, 3) arrays om = np.atleast_2d(matrix) if om.shape[-2:] != (3, 3): - raise ValueError("the last two dimensions of 'matrix' must be (3, 3)") + raise ValueError( + "the last two dimensions of 'matrix' must be (3, 3)" + ) Q = _conversions.om2qu(om) Q = cls(Q) @@ -737,7 +741,7 @@ def from_path_ends( q2 = waypoints[(i + 1) % (waypoints.size)] # find the ax/ang describing the trip between points ax, ang = _conversions.qu2ax((~q1 * q2).data) - # get 100 steps along the trip and add them to the journey + # get 'steps=n' steps along the trip and add them to the journey trip = Quaternion.from_axes_angles(ax, np.linspace(0, ang, steps)) path_list.append((q1 * (trip.flatten())).data) path_data = np.concatenate(path_list, axis=0) @@ -748,7 +752,9 @@ def from_path_ends( return path @classmethod - def triple_cross(cls, q1: Quaternion, q2: Quaternion, q3: Quaternion) -> Quaternion: + def triple_cross( + cls, q1: Quaternion, q2: Quaternion, q3: Quaternion + ) -> Quaternion: """Pointwise cross product of three quaternions. Parameters @@ -1195,7 +1201,9 @@ def outer( qu12 = np.outer(qu1, qu2).reshape(*qu1.shape, *qu2.shape) qu = quaternion.as_float_array(qu12) else: # pragma: no cover - Q12 = Quaternion(self).reshape(-1, 1) * other.reshape(1, -1) + Q12 = Quaternion(self).reshape(-1, 1) * other.reshape( + 1, -1 + ) qu = Q12.data.reshape(*self.shape, *other.shape, 4) return other.__class__(qu) elif isinstance(other, Vector3d): @@ -1368,7 +1376,9 @@ def _outer_dask( @nb.guvectorize("(n)->(n)", cache=True) -def qu_conj_gufunc(qu: np.ndarray, qu2: np.ndarray) -> None: # pragma: no cover +def qu_conj_gufunc( + qu: np.ndarray, qu2: np.ndarray +) -> None: # pragma: no cover qu2[0] = qu[0] qu2[1] = -qu[1] qu2[2] = -qu[2] @@ -1379,13 +1389,23 @@ def qu_conj_gufunc(qu: np.ndarray, qu2: np.ndarray) -> None: # pragma: no cover def qu_multiply_gufunc( qu1: np.ndarray, qu2: np.ndarray, qu12: np.ndarray ) -> None: # pragma: no cover - qu12[0] = qu1[0] * qu2[0] - qu1[1] * qu2[1] - qu1[2] * qu2[2] - qu1[3] * qu2[3] - qu12[1] = qu1[1] * qu2[0] + qu1[0] * qu2[1] - qu1[3] * qu2[2] + qu1[2] * qu2[3] - qu12[2] = qu1[2] * qu2[0] + qu1[3] * qu2[1] + qu1[0] * qu2[2] - qu1[1] * qu2[3] - qu12[3] = qu1[3] * qu2[0] - qu1[2] * qu2[1] + qu1[1] * qu2[2] + qu1[0] * qu2[3] - - -def qu_multiply(qu1: np.ndarray, qu2: np.ndarray) -> np.ndarray: # pragma: no cover + qu12[0] = ( + qu1[0] * qu2[0] - qu1[1] * qu2[1] - qu1[2] * qu2[2] - qu1[3] * qu2[3] + ) + qu12[1] = ( + qu1[1] * qu2[0] + qu1[0] * qu2[1] - qu1[3] * qu2[2] + qu1[2] * qu2[3] + ) + qu12[2] = ( + qu1[2] * qu2[0] + qu1[3] * qu2[1] + qu1[0] * qu2[2] - qu1[1] * qu2[3] + ) + qu12[3] = ( + qu1[3] * qu2[0] - qu1[2] * qu2[1] + qu1[1] * qu2[2] + qu1[0] * qu2[3] + ) + + +def qu_multiply( + qu1: np.ndarray, qu2: np.ndarray +) -> np.ndarray: # pragma: no cover shape = np.broadcast_shapes(qu1.shape, qu2.shape) if not np.issubdtype(qu1.dtype, np.float64): qu1 = qu1.astype(np.float64) @@ -1410,7 +1430,9 @@ def qu_rotate_vec_gufunc( v2[2] = z - c * tx + b * ty + a * tz -def qu_rotate_vec(qu: np.ndarray, v: np.ndarray) -> np.ndarray: # pragma: no cover +def qu_rotate_vec( + qu: np.ndarray, v: np.ndarray +) -> np.ndarray: # pragma: no cover qu = np.atleast_2d(qu) v = np.atleast_2d(v) shape = np.broadcast_shapes(qu.shape[:-1], v.shape[:-1]) + (3,) diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 003c22ceb..57a31139a 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -301,7 +301,8 @@ def test_symmetry_property_wrong_type_orientation(): @pytest.mark.parametrize( - "error_type, value", [(ValueError, (1, 2)), (ValueError, (C1, 2)), (TypeError, 1)] + "error_type, value", + [(ValueError, (1, 2)), (ValueError, (C1, 2)), (TypeError, 1)], ) def test_symmetry_property_wrong_type_misorientation(error_type, value): mori = Misorientation.random((3, 2)) @@ -313,7 +314,9 @@ def test_symmetry_property_wrong_type_misorientation(error_type, value): "error_type, value", [(ValueError, (C1,)), (ValueError, (C1, C2, C1))], ) -def test_symmetry_property_wrong_number_of_values_misorientation(error_type, value): +def test_symmetry_property_wrong_number_of_values_misorientation( + error_type, value +): o = Misorientation.random((3, 2)) with pytest.raises(error_type, match="Value must be a 2-tuple"): # less than 2 Symmetry @@ -506,13 +509,15 @@ def test_from_matrix_symmetry(self): ) o1 = Orientation.from_matrix(om) assert np.allclose( - o1.data, np.array([1, 0, 0, 0] * 2 + [0, 1, 0, 0] * 2).reshape(4, 4) + o1.data, + np.array([1, 0, 0, 0] * 2 + [0, 1, 0, 0] * 2).reshape(4, 4), ) assert o1.symmetry.name == "1" o2 = Orientation.from_matrix(om, symmetry=Oh) o2 = o2.reduce() assert np.allclose( - o2.data, np.array([1, 0, 0, 0] * 2 + [-1, 0, 0, 0] * 2).reshape(4, 4) + o2.data, + np.array([1, 0, 0, 0] * 2 + [-1, 0, 0, 0] * 2).reshape(4, 4), ) assert o2.symmetry.name == "m-3m" o3 = Orientation(o1.data, symmetry=Oh) @@ -597,9 +602,19 @@ def test_from_scipy_rotation(self): with pytest.raises(TypeError, match="Value must be an instance of"): _ = Orientation.from_scipy_rotation(r_scipy, (Oh, Oh)) + def test_from_path_ends(self): + # generate paths with misorientations and orientations to check + # symmetry copying + wp_o = Orientation(data=np.eye(4)[:2], symmetry=Oh) + wp_m = Misorientation(data=np.eye(4)[2:], symmetry=[Oh, C3]) + assert Orientation.from_path_ends(wp_o)._symmetry == (C1, Oh) + assert Misorientation.from_path_ends(wp_m)._symmetry == (Oh, C3) + class TestOrientation: - @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) + @pytest.mark.parametrize( + "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] + ) def test_get_distance_matrix(self, symmetry): q = [(0.5, 0.5, 0.5, 0.5), (0.5**0.5, 0, 0, 0.5**0.5)] o = Orientation(q, symmetry=symmetry) @@ -626,11 +641,15 @@ def test_get_distance_matrix_lazy_parameters(self): o = Orientation(abcd) angle1 = o.get_distance_matrix(lazy=True, chunk_size=5) - angle2 = o.get_distance_matrix(lazy=True, chunk_size=10, progressbar=False) + angle2 = o.get_distance_matrix( + lazy=True, chunk_size=10, progressbar=False + ) assert np.allclose(angle1.data, angle2.data) - @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) + @pytest.mark.parametrize( + "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] + ) def test_angle_with_outer(self, symmetry): shape = (5,) o = Orientation.random(shape) @@ -677,7 +696,9 @@ def test_angle_with_outer_shape(self): assert awo_o12s.shape == awo_r12.shape assert not np.allclose(awo_o12s, awo_r12) - @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) + @pytest.mark.parametrize( + "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] + ) def test_angle_with(self, symmetry): q = [(0.5, 0.5, 0.5, 0.5), (0.5**0.5, 0, 0, 0.5**0.5)] r = Rotation(q) @@ -712,7 +733,9 @@ def test_scatter(self, orientation, pure_misorientation): ) assert (fig_axangle.get_size_inches() == fig_size).all() assert isinstance(fig_axangle.axes[0], AxAnglePlot) - fig_rodrigues = orientation.scatter(projection="rodrigues", return_figure=True) + fig_rodrigues = orientation.scatter( + projection="rodrigues", return_figure=True + ) assert isinstance(fig_rodrigues.axes[0], RodriguesPlot) # Add multiple axes to figure, one at a time @@ -805,7 +828,9 @@ def test_in_fundamental_region(self): for pg in _proper_groups: ori.symmetry = pg region = np.radians(pg.euler_fundamental_region) - assert np.all(np.max(ori.in_euler_fundamental_region(), axis=0) <= region) + assert np.all( + np.max(ori.in_euler_fundamental_region(), axis=0) <= region + ) def test_inverse(self): O1 = Orientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], D6) diff --git a/orix/tests/quaternion/test_quaternion.py b/orix/tests/quaternion/test_quaternion.py index 7f6be1d11..c82483bca 100644 --- a/orix/tests/quaternion/test_quaternion.py +++ b/orix/tests/quaternion/test_quaternion.py @@ -78,7 +78,9 @@ def test_neg(self, quaternion): assert np.allclose((-quaternion).data, (-quaternion.data)) def test_norm(self, quaternion): - assert np.allclose(quaternion.norm, (quaternion.data**2).sum(axis=-1) ** 0.5) + assert np.allclose( + quaternion.norm, (quaternion.data**2).sum(axis=-1) ** 0.5 + ) def test_unit(self, quaternion): assert np.allclose(quaternion.unit.norm, 1) @@ -102,7 +104,9 @@ def test_mul(self, quaternion, something): assert np.allclose(q1.d, qa * sd + qb * sc - qc * sb + qd * sa) def test_mul_identity(self, quaternion): - assert np.allclose((quaternion * Quaternion.identity()).data, quaternion.data) + assert np.allclose( + (quaternion * Quaternion.identity()).data, quaternion.data + ) def test_no_multiplicative_inverse(self, quaternion, something): q1 = quaternion * something @@ -193,7 +197,9 @@ def test_antipodal(self, quaternion): assert qa.size == 2 * q.size def test_edgecase_outer(self, quaternion): - with pytest.raises(NotImplementedError, match="This operation is currently "): + with pytest.raises( + NotImplementedError, match="This operation is currently " + ): _ = quaternion.outer([3, 2]) def test_failing_mul(self, quaternion): @@ -298,7 +304,9 @@ def test_outer_dask_wrong_type_raises(self): q = Quaternion(abcd) # not Quaternion or Vector3d other = np.random.rand(7, 3) - with pytest.raises(TypeError, match="Other must be Quaternion or Vector3d"): + with pytest.raises( + TypeError, match="Other must be Quaternion or Vector3d" + ): q._outer_dask(other) def test_from_align_vectors(self): @@ -308,7 +316,8 @@ def test_from_align_vectors(self): q1 = Quaternion.from_align_vectors(v1, v2) assert isinstance(q1, Quaternion) assert np.allclose( - q1.data, np.array([[0.65328148, 0.70532785, -0.05012611, -0.27059805]]) + q1.data, + np.array([[0.65328148, 0.70532785, -0.05012611, -0.27059805]]), ) assert np.allclose(v1.unit.data, (q1 * v2.unit).data) @@ -317,8 +326,12 @@ def test_from_align_vectors(self): error = out[1] assert error == 0 - _, sens_mat = Quaternion.from_align_vectors(v1, v2, return_sensitivity=True) - assert np.allclose(sens_mat, np.array([[1, 0, 0], [0, 1, 0], [0, 0, 0.5]])) + _, sens_mat = Quaternion.from_align_vectors( + v1, v2, return_sensitivity=True + ) + assert np.allclose( + sens_mat, np.array([[1, 0, 0], [0, 1, 0], [0, 0, 0.5]]) + ) out = Quaternion.from_align_vectors( v1, v2, return_rmsd=True, return_sensitivity=True @@ -330,6 +343,37 @@ def test_from_align_vectors(self): ) assert np.allclose(q2.data, q1.data) + def test_from_path_ends(self): + # choose a path with repeats and 180 tilts to test edge cases + waypoints = Quaternion( + data=np.array( + [ + [1, 0, 0, 0], + [1, 0, 0, 0], + [1, 1, 0, 0], + [1, 0, 1, 0], + [1, 0, 0, 1], + [1, 0, 0, 1], + [1, 0, -1, 0], + [1, 0, 1, 0], + [-1, 0, 1, 0], + [1, 0, 0, -1], + [-1, 0, 0, -1], + [-1, 0, 0, 1], + ] + ) + ) + path = Quaternion.from_path_ends(waypoints) + loop = Quaternion.from_path_ends(waypoints, close_loop=True, steps=11) + # check the sizes are as expected + assert path.shape == (1100,) + assert loop.shape == (132,) + # check the spacing between points is homogenous + path_spacing = [(x[1:]).dot(x[:-1]) for x in path.reshape(11, 100)] + loop_spacing = [(x[1:]).dot(x[:-1]) for x in loop.reshape(12, 11)] + assert np.all(np.std(path_spacing, axis=1) < 1e-12) + assert np.all(np.std(loop_spacing, axis=1) < 1e-12) + def test_equality(self): Q1 = Quaternion.from_axes_angles([1, 1, 1], -np.pi / 3) Q2 = Quaternion.from_axes_angles([1, 1, 1], np.pi / 3) @@ -361,7 +405,9 @@ def test_direction_values(self, eu): assert np.allclose((q_l2c * q_c2l).data, [1, 0, 0, 0]) def test_direction_kwarg_dumb(self, eu): - with pytest.raises(ValueError, match="The chosen direction is not one of "): + with pytest.raises( + ValueError, match="The chosen direction is not one of " + ): _ = Quaternion.from_euler(eu, direction="dumb_direction") def test_edge_cases_to_euler(self): @@ -372,19 +418,25 @@ def test_edge_cases_to_euler(self): _ = q.to_euler() def test_passing_degrees_warns(self): - with pytest.warns(UserWarning, match="Angles are quite high, did you forget "): + with pytest.warns( + UserWarning, match="Angles are quite high, did you forget " + ): q = Quaternion.from_euler([90, 0, 0]) assert np.allclose(q.data, [0.5253, 0, 0, -0.8509], atol=1e-4) class TestFromToMatrix: def test_to_matrix(self): - q = Quaternion([[1, 0, 0, 0], [3, 0, 0, 0], [0, 1, 0, 0], [0, 2, 0, 0]]) + q = Quaternion( + [[1, 0, 0, 0], [3, 0, 0, 0], [0, 1, 0, 0], [0, 2, 0, 0]] + ) om = np.array( [np.eye(3), np.eye(3), np.diag([1, -1, -1]), np.diag([1, -1, -1])] ) # Shapes are handled correctly - assert np.allclose(q.reshape(2, 2).unit.to_matrix(), om.reshape(2, 2, 3, 3)) + assert np.allclose( + q.reshape(2, 2).unit.to_matrix(), om.reshape(2, 2, 3, 3) + ) q2 = Quaternion( [ @@ -398,19 +450,25 @@ def test_to_matrix(self): # Inverse equal to transpose assert all(np.allclose(np.linalg.inv(i), i.T) for i in om_from_q2) # Cross product of any two rows gives the third - assert all(np.allclose(np.cross(i[:, 0], i[:, 1]), i[:, 2]) for i in om_from_q2) + assert all( + np.allclose(np.cross(i[:, 0], i[:, 1]), i[:, 2]) + for i in om_from_q2 + ) # Sum of squares of any column or row equals unity assert np.allclose(np.sum(np.square(om_from_q2), axis=1), 1) # Rows assert np.allclose(np.sum(np.square(om_from_q2), axis=2), 1) # Columns def test_from_matrix(self): - q = Quaternion([[1, 0, 0, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]]) + q = Quaternion( + [[1, 0, 0, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]] + ) ident = np.identity(3) rot_180x = np.diag([1, -1, -1]) om = np.array([ident, 2 * ident, rot_180x, 2 * rot_180x]) assert np.allclose(Quaternion.from_matrix(om).data, q.data) assert np.allclose( - Quaternion.from_matrix(om.reshape(2, 2, 3, 3)).data, q.reshape(2, 2).data + Quaternion.from_matrix(om.reshape(2, 2, 3, 3)).data, + q.reshape(2, 2).data, ) def test_from_to_matrix(self): @@ -447,7 +505,9 @@ def test_get_rotation_matrix_from_diffpy(self): q = Quaternion.from_matrix([i.R for i in sg225.symop_list]) assert not np.isnan(q.data).any() - def test_from_matrix_raises(self, quaternions_conversions, orientation_matrices): + def test_from_matrix_raises( + self, quaternions_conversions, orientation_matrices + ): qu = Quaternion.from_matrix(orientation_matrices).data assert np.allclose(qu, quaternions_conversions, atol=1e-4) with pytest.raises(ValueError, match="(3, 3)"): @@ -467,12 +527,16 @@ def test_from_axes_angles(self, rotations, extra_dim): rotations = rotations.__class__(rotations.data[..., np.newaxis, :]) ax = AxAngle.from_rotation(rotations) Q1 = Quaternion.from_axes_angles(ax.axis.data, ax.angle) - Q2 = Quaternion.from_axes_angles(ax.axis, np.rad2deg(ax.angle), degrees=True) + Q2 = Quaternion.from_axes_angles( + ax.axis, np.rad2deg(ax.angle), degrees=True + ) assert np.allclose(Q1.data, Q2.data) def test_to_axes_angles(self, quaternions_conversions, axis_angle_pairs): ax = Quaternion(quaternions_conversions).to_axes_angles() - assert np.allclose(np.deg2rad(ax.angle), axis_angle_pairs[:, 3], atol=4) + assert np.allclose( + np.deg2rad(ax.angle), axis_angle_pairs[:, 3], atol=4 + ) def test_from_axes_angles_empty(self): q = Quaternion.from_axes_angles([], []) @@ -484,12 +548,16 @@ class TestFromToRodrigues: Rodrigues and Rodrigues-Frank vectors. """ - def test_from_to_rodrigues(self, quaternions_conversions, rodrigues_vectors): + def test_from_to_rodrigues( + self, quaternions_conversions, rodrigues_vectors + ): axes = rodrigues_vectors[..., :3] angles = rodrigues_vectors[..., 3] q1 = Quaternion(quaternions_conversions) - with pytest.warns(UserWarning, match="Highest angle is greater than 179.999 "): + with pytest.warns( + UserWarning, match="Highest angle is greater than 179.999 " + ): q2 = Quaternion.from_rodrigues(axes, angles) assert np.allclose(q1.data, q2.data, atol=1e-4) ro = q1.to_rodrigues(frank=True) @@ -499,7 +567,9 @@ def test_from_to_rodrigues(self, quaternions_conversions, rodrigues_vectors): _ = Quaternion.from_rodrigues([1e15, 1e15, 1e10]) with pytest.warns(UserWarning, match="Max."): _ = Quaternion.from_rodrigues([0, 0, 1e-16]) - with pytest.raises(ValueError, match="Final dimension of vector array must be"): + with pytest.raises( + ValueError, match="Final dimension of vector array must be" + ): Quaternion.from_rodrigues([1, 2, 3, 4]) def test_from_rodrigues_empty(self): @@ -512,7 +582,9 @@ class TestFromToHomochoric: homochoric vectors. """ - def test_from_to_homochoric(self, homochoric_vectors, quaternions_conversions): + def test_from_to_homochoric( + self, homochoric_vectors, quaternions_conversions + ): ho1 = homochoric_vectors ho2 = Vector3d(ho1) ho3 = Homochoric(ho1) @@ -526,7 +598,9 @@ def test_from_to_homochoric(self, homochoric_vectors, quaternions_conversions): assert np.allclose(q1.data, quaternions_conversions, atol=1e-4) assert np.allclose(q2.data, quaternions_conversions, atol=1e-4) assert np.allclose(q3.data, quaternions_conversions, atol=1e-4) - assert np.allclose(q4.data, quaternions_conversions.reshape(2, 5, 4), atol=1e-4) + assert np.allclose( + q4.data, quaternions_conversions.reshape(2, 5, 4), atol=1e-4 + ) assert np.allclose(ho1, q1.to_homochoric().data, atol=1e-4) assert np.allclose(ho1, q2.to_homochoric().data, atol=1e-4) @@ -534,7 +608,9 @@ def test_from_to_homochoric(self, homochoric_vectors, quaternions_conversions): assert np.allclose(ho4, q4.to_homochoric().data, atol=1e-4) def test_from_homochoric_raises(self): - with pytest.raises(ValueError, match="Final dimension of vector array must be"): + with pytest.raises( + ValueError, match="Final dimension of vector array must be" + ): Quaternion.from_homochoric([1, 2]) def test_from_homochoric_empty(self): From e8afae0ef6713293d7a7873f48815944685a031a Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Tue, 27 May 2025 15:37:31 -0600 Subject: [PATCH 14/23] black formatting --- orix/quaternion/quaternion.py | 48 ++++--------- orix/tests/quaternion/test_orientation.py | 28 ++------ orix/tests/quaternion/test_quaternion.py | 85 ++++++----------------- 3 files changed, 41 insertions(+), 120 deletions(-) diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index c18f1d5af..f72d6b704 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -505,9 +505,7 @@ def from_euler( eu = np.atleast_2d(euler) if np.any(np.abs(eu) > 4 * np.pi): - warnings.warn( - "Angles are quite high, did you forget to set degrees=True?" - ) + warnings.warn("Angles are quite high, did you forget to set degrees=True?") Q = _conversions.eu2qu(eu) Q = cls(Q) @@ -546,9 +544,7 @@ def from_matrix(cls, matrix: np.ndarray | tuple | list) -> Quaternion: # Verify the input can be interpreted as an array of (3, 3) arrays om = np.atleast_2d(matrix) if om.shape[-2:] != (3, 3): - raise ValueError( - "the last two dimensions of 'matrix' must be (3, 3)" - ) + raise ValueError("the last two dimensions of 'matrix' must be (3, 3)") Q = _conversions.om2qu(om) Q = cls(Q) @@ -752,9 +748,7 @@ def from_path_ends( return path @classmethod - def triple_cross( - cls, q1: Quaternion, q2: Quaternion, q3: Quaternion - ) -> Quaternion: + def triple_cross(cls, q1: Quaternion, q2: Quaternion, q3: Quaternion) -> Quaternion: """Pointwise cross product of three quaternions. Parameters @@ -1201,9 +1195,7 @@ def outer( qu12 = np.outer(qu1, qu2).reshape(*qu1.shape, *qu2.shape) qu = quaternion.as_float_array(qu12) else: # pragma: no cover - Q12 = Quaternion(self).reshape(-1, 1) * other.reshape( - 1, -1 - ) + Q12 = Quaternion(self).reshape(-1, 1) * other.reshape(1, -1) qu = Q12.data.reshape(*self.shape, *other.shape, 4) return other.__class__(qu) elif isinstance(other, Vector3d): @@ -1376,9 +1368,7 @@ def _outer_dask( @nb.guvectorize("(n)->(n)", cache=True) -def qu_conj_gufunc( - qu: np.ndarray, qu2: np.ndarray -) -> None: # pragma: no cover +def qu_conj_gufunc(qu: np.ndarray, qu2: np.ndarray) -> None: # pragma: no cover qu2[0] = qu[0] qu2[1] = -qu[1] qu2[2] = -qu[2] @@ -1389,23 +1379,13 @@ def qu_conj_gufunc( def qu_multiply_gufunc( qu1: np.ndarray, qu2: np.ndarray, qu12: np.ndarray ) -> None: # pragma: no cover - qu12[0] = ( - qu1[0] * qu2[0] - qu1[1] * qu2[1] - qu1[2] * qu2[2] - qu1[3] * qu2[3] - ) - qu12[1] = ( - qu1[1] * qu2[0] + qu1[0] * qu2[1] - qu1[3] * qu2[2] + qu1[2] * qu2[3] - ) - qu12[2] = ( - qu1[2] * qu2[0] + qu1[3] * qu2[1] + qu1[0] * qu2[2] - qu1[1] * qu2[3] - ) - qu12[3] = ( - qu1[3] * qu2[0] - qu1[2] * qu2[1] + qu1[1] * qu2[2] + qu1[0] * qu2[3] - ) - - -def qu_multiply( - qu1: np.ndarray, qu2: np.ndarray -) -> np.ndarray: # pragma: no cover + qu12[0] = qu1[0] * qu2[0] - qu1[1] * qu2[1] - qu1[2] * qu2[2] - qu1[3] * qu2[3] + qu12[1] = qu1[1] * qu2[0] + qu1[0] * qu2[1] - qu1[3] * qu2[2] + qu1[2] * qu2[3] + qu12[2] = qu1[2] * qu2[0] + qu1[3] * qu2[1] + qu1[0] * qu2[2] - qu1[1] * qu2[3] + qu12[3] = qu1[3] * qu2[0] - qu1[2] * qu2[1] + qu1[1] * qu2[2] + qu1[0] * qu2[3] + + +def qu_multiply(qu1: np.ndarray, qu2: np.ndarray) -> np.ndarray: # pragma: no cover shape = np.broadcast_shapes(qu1.shape, qu2.shape) if not np.issubdtype(qu1.dtype, np.float64): qu1 = qu1.astype(np.float64) @@ -1430,9 +1410,7 @@ def qu_rotate_vec_gufunc( v2[2] = z - c * tx + b * ty + a * tz -def qu_rotate_vec( - qu: np.ndarray, v: np.ndarray -) -> np.ndarray: # pragma: no cover +def qu_rotate_vec(qu: np.ndarray, v: np.ndarray) -> np.ndarray: # pragma: no cover qu = np.atleast_2d(qu) v = np.atleast_2d(v) shape = np.broadcast_shapes(qu.shape[:-1], v.shape[:-1]) + (3,) diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 57a31139a..0645a135e 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -314,9 +314,7 @@ def test_symmetry_property_wrong_type_misorientation(error_type, value): "error_type, value", [(ValueError, (C1,)), (ValueError, (C1, C2, C1))], ) -def test_symmetry_property_wrong_number_of_values_misorientation( - error_type, value -): +def test_symmetry_property_wrong_number_of_values_misorientation(error_type, value): o = Misorientation.random((3, 2)) with pytest.raises(error_type, match="Value must be a 2-tuple"): # less than 2 Symmetry @@ -612,9 +610,7 @@ def test_from_path_ends(self): class TestOrientation: - @pytest.mark.parametrize( - "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] - ) + @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) def test_get_distance_matrix(self, symmetry): q = [(0.5, 0.5, 0.5, 0.5), (0.5**0.5, 0, 0, 0.5**0.5)] o = Orientation(q, symmetry=symmetry) @@ -641,15 +637,11 @@ def test_get_distance_matrix_lazy_parameters(self): o = Orientation(abcd) angle1 = o.get_distance_matrix(lazy=True, chunk_size=5) - angle2 = o.get_distance_matrix( - lazy=True, chunk_size=10, progressbar=False - ) + angle2 = o.get_distance_matrix(lazy=True, chunk_size=10, progressbar=False) assert np.allclose(angle1.data, angle2.data) - @pytest.mark.parametrize( - "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] - ) + @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) def test_angle_with_outer(self, symmetry): shape = (5,) o = Orientation.random(shape) @@ -696,9 +688,7 @@ def test_angle_with_outer_shape(self): assert awo_o12s.shape == awo_r12.shape assert not np.allclose(awo_o12s, awo_r12) - @pytest.mark.parametrize( - "symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh] - ) + @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) def test_angle_with(self, symmetry): q = [(0.5, 0.5, 0.5, 0.5), (0.5**0.5, 0, 0, 0.5**0.5)] r = Rotation(q) @@ -733,9 +723,7 @@ def test_scatter(self, orientation, pure_misorientation): ) assert (fig_axangle.get_size_inches() == fig_size).all() assert isinstance(fig_axangle.axes[0], AxAnglePlot) - fig_rodrigues = orientation.scatter( - projection="rodrigues", return_figure=True - ) + fig_rodrigues = orientation.scatter(projection="rodrigues", return_figure=True) assert isinstance(fig_rodrigues.axes[0], RodriguesPlot) # Add multiple axes to figure, one at a time @@ -828,9 +816,7 @@ def test_in_fundamental_region(self): for pg in _proper_groups: ori.symmetry = pg region = np.radians(pg.euler_fundamental_region) - assert np.all( - np.max(ori.in_euler_fundamental_region(), axis=0) <= region - ) + assert np.all(np.max(ori.in_euler_fundamental_region(), axis=0) <= region) def test_inverse(self): O1 = Orientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], D6) diff --git a/orix/tests/quaternion/test_quaternion.py b/orix/tests/quaternion/test_quaternion.py index c82483bca..332193a0a 100644 --- a/orix/tests/quaternion/test_quaternion.py +++ b/orix/tests/quaternion/test_quaternion.py @@ -78,9 +78,7 @@ def test_neg(self, quaternion): assert np.allclose((-quaternion).data, (-quaternion.data)) def test_norm(self, quaternion): - assert np.allclose( - quaternion.norm, (quaternion.data**2).sum(axis=-1) ** 0.5 - ) + assert np.allclose(quaternion.norm, (quaternion.data**2).sum(axis=-1) ** 0.5) def test_unit(self, quaternion): assert np.allclose(quaternion.unit.norm, 1) @@ -104,9 +102,7 @@ def test_mul(self, quaternion, something): assert np.allclose(q1.d, qa * sd + qb * sc - qc * sb + qd * sa) def test_mul_identity(self, quaternion): - assert np.allclose( - (quaternion * Quaternion.identity()).data, quaternion.data - ) + assert np.allclose((quaternion * Quaternion.identity()).data, quaternion.data) def test_no_multiplicative_inverse(self, quaternion, something): q1 = quaternion * something @@ -197,9 +193,7 @@ def test_antipodal(self, quaternion): assert qa.size == 2 * q.size def test_edgecase_outer(self, quaternion): - with pytest.raises( - NotImplementedError, match="This operation is currently " - ): + with pytest.raises(NotImplementedError, match="This operation is currently "): _ = quaternion.outer([3, 2]) def test_failing_mul(self, quaternion): @@ -304,9 +298,7 @@ def test_outer_dask_wrong_type_raises(self): q = Quaternion(abcd) # not Quaternion or Vector3d other = np.random.rand(7, 3) - with pytest.raises( - TypeError, match="Other must be Quaternion or Vector3d" - ): + with pytest.raises(TypeError, match="Other must be Quaternion or Vector3d"): q._outer_dask(other) def test_from_align_vectors(self): @@ -326,12 +318,8 @@ def test_from_align_vectors(self): error = out[1] assert error == 0 - _, sens_mat = Quaternion.from_align_vectors( - v1, v2, return_sensitivity=True - ) - assert np.allclose( - sens_mat, np.array([[1, 0, 0], [0, 1, 0], [0, 0, 0.5]]) - ) + _, sens_mat = Quaternion.from_align_vectors(v1, v2, return_sensitivity=True) + assert np.allclose(sens_mat, np.array([[1, 0, 0], [0, 1, 0], [0, 0, 0.5]])) out = Quaternion.from_align_vectors( v1, v2, return_rmsd=True, return_sensitivity=True @@ -405,9 +393,7 @@ def test_direction_values(self, eu): assert np.allclose((q_l2c * q_c2l).data, [1, 0, 0, 0]) def test_direction_kwarg_dumb(self, eu): - with pytest.raises( - ValueError, match="The chosen direction is not one of " - ): + with pytest.raises(ValueError, match="The chosen direction is not one of "): _ = Quaternion.from_euler(eu, direction="dumb_direction") def test_edge_cases_to_euler(self): @@ -418,25 +404,19 @@ def test_edge_cases_to_euler(self): _ = q.to_euler() def test_passing_degrees_warns(self): - with pytest.warns( - UserWarning, match="Angles are quite high, did you forget " - ): + with pytest.warns(UserWarning, match="Angles are quite high, did you forget "): q = Quaternion.from_euler([90, 0, 0]) assert np.allclose(q.data, [0.5253, 0, 0, -0.8509], atol=1e-4) class TestFromToMatrix: def test_to_matrix(self): - q = Quaternion( - [[1, 0, 0, 0], [3, 0, 0, 0], [0, 1, 0, 0], [0, 2, 0, 0]] - ) + q = Quaternion([[1, 0, 0, 0], [3, 0, 0, 0], [0, 1, 0, 0], [0, 2, 0, 0]]) om = np.array( [np.eye(3), np.eye(3), np.diag([1, -1, -1]), np.diag([1, -1, -1])] ) # Shapes are handled correctly - assert np.allclose( - q.reshape(2, 2).unit.to_matrix(), om.reshape(2, 2, 3, 3) - ) + assert np.allclose(q.reshape(2, 2).unit.to_matrix(), om.reshape(2, 2, 3, 3)) q2 = Quaternion( [ @@ -450,18 +430,13 @@ def test_to_matrix(self): # Inverse equal to transpose assert all(np.allclose(np.linalg.inv(i), i.T) for i in om_from_q2) # Cross product of any two rows gives the third - assert all( - np.allclose(np.cross(i[:, 0], i[:, 1]), i[:, 2]) - for i in om_from_q2 - ) + assert all(np.allclose(np.cross(i[:, 0], i[:, 1]), i[:, 2]) for i in om_from_q2) # Sum of squares of any column or row equals unity assert np.allclose(np.sum(np.square(om_from_q2), axis=1), 1) # Rows assert np.allclose(np.sum(np.square(om_from_q2), axis=2), 1) # Columns def test_from_matrix(self): - q = Quaternion( - [[1, 0, 0, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]] - ) + q = Quaternion([[1, 0, 0, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 1, 0, 0]]) ident = np.identity(3) rot_180x = np.diag([1, -1, -1]) om = np.array([ident, 2 * ident, rot_180x, 2 * rot_180x]) @@ -505,9 +480,7 @@ def test_get_rotation_matrix_from_diffpy(self): q = Quaternion.from_matrix([i.R for i in sg225.symop_list]) assert not np.isnan(q.data).any() - def test_from_matrix_raises( - self, quaternions_conversions, orientation_matrices - ): + def test_from_matrix_raises(self, quaternions_conversions, orientation_matrices): qu = Quaternion.from_matrix(orientation_matrices).data assert np.allclose(qu, quaternions_conversions, atol=1e-4) with pytest.raises(ValueError, match="(3, 3)"): @@ -527,16 +500,12 @@ def test_from_axes_angles(self, rotations, extra_dim): rotations = rotations.__class__(rotations.data[..., np.newaxis, :]) ax = AxAngle.from_rotation(rotations) Q1 = Quaternion.from_axes_angles(ax.axis.data, ax.angle) - Q2 = Quaternion.from_axes_angles( - ax.axis, np.rad2deg(ax.angle), degrees=True - ) + Q2 = Quaternion.from_axes_angles(ax.axis, np.rad2deg(ax.angle), degrees=True) assert np.allclose(Q1.data, Q2.data) def test_to_axes_angles(self, quaternions_conversions, axis_angle_pairs): ax = Quaternion(quaternions_conversions).to_axes_angles() - assert np.allclose( - np.deg2rad(ax.angle), axis_angle_pairs[:, 3], atol=4 - ) + assert np.allclose(np.deg2rad(ax.angle), axis_angle_pairs[:, 3], atol=4) def test_from_axes_angles_empty(self): q = Quaternion.from_axes_angles([], []) @@ -548,16 +517,12 @@ class TestFromToRodrigues: Rodrigues and Rodrigues-Frank vectors. """ - def test_from_to_rodrigues( - self, quaternions_conversions, rodrigues_vectors - ): + def test_from_to_rodrigues(self, quaternions_conversions, rodrigues_vectors): axes = rodrigues_vectors[..., :3] angles = rodrigues_vectors[..., 3] q1 = Quaternion(quaternions_conversions) - with pytest.warns( - UserWarning, match="Highest angle is greater than 179.999 " - ): + with pytest.warns(UserWarning, match="Highest angle is greater than 179.999 "): q2 = Quaternion.from_rodrigues(axes, angles) assert np.allclose(q1.data, q2.data, atol=1e-4) ro = q1.to_rodrigues(frank=True) @@ -567,9 +532,7 @@ def test_from_to_rodrigues( _ = Quaternion.from_rodrigues([1e15, 1e15, 1e10]) with pytest.warns(UserWarning, match="Max."): _ = Quaternion.from_rodrigues([0, 0, 1e-16]) - with pytest.raises( - ValueError, match="Final dimension of vector array must be" - ): + with pytest.raises(ValueError, match="Final dimension of vector array must be"): Quaternion.from_rodrigues([1, 2, 3, 4]) def test_from_rodrigues_empty(self): @@ -582,9 +545,7 @@ class TestFromToHomochoric: homochoric vectors. """ - def test_from_to_homochoric( - self, homochoric_vectors, quaternions_conversions - ): + def test_from_to_homochoric(self, homochoric_vectors, quaternions_conversions): ho1 = homochoric_vectors ho2 = Vector3d(ho1) ho3 = Homochoric(ho1) @@ -598,9 +559,7 @@ def test_from_to_homochoric( assert np.allclose(q1.data, quaternions_conversions, atol=1e-4) assert np.allclose(q2.data, quaternions_conversions, atol=1e-4) assert np.allclose(q3.data, quaternions_conversions, atol=1e-4) - assert np.allclose( - q4.data, quaternions_conversions.reshape(2, 5, 4), atol=1e-4 - ) + assert np.allclose(q4.data, quaternions_conversions.reshape(2, 5, 4), atol=1e-4) assert np.allclose(ho1, q1.to_homochoric().data, atol=1e-4) assert np.allclose(ho1, q2.to_homochoric().data, atol=1e-4) @@ -608,9 +567,7 @@ def test_from_to_homochoric( assert np.allclose(ho4, q4.to_homochoric().data, atol=1e-4) def test_from_homochoric_raises(self): - with pytest.raises( - ValueError, match="Final dimension of vector array must be" - ): + with pytest.raises(ValueError, match="Final dimension of vector array must be"): Quaternion.from_homochoric([1, 2]) def test_from_homochoric_empty(self): From fcdc166ed7be3ee32b7d311f618f3b100c69f5db Mon Sep 17 00:00:00 2001 From: Austin Gerlt Date: Sun, 13 Jul 2025 04:29:15 -0600 Subject: [PATCH 15/23] adding requested changes fomr #558 --- orix/quaternion/misorientation.py | 5 ---- orix/quaternion/quaternion.py | 46 ++++++++++++++----------------- 2 files changed, 21 insertions(+), 30 deletions(-) diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index c7cc140c4..c4710cf5c 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -388,11 +388,6 @@ def reduce(self) -> Misorientation: """Return equivalent transformations which have the smallest angle of rotation as a new misorientation. - Parameters - ---------- - verbose - Whether to print a progressbar. Default is ``False``. - Returns ------- M diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index f72d6b704..e321be0fd 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -247,15 +247,11 @@ def __eq__(self, other: Any | Quaternion) -> bool: # ------------------------ Class methods ------------------------- # @classmethod - def random(cls, shape: Union[int, tuple] = 1) -> Quaternion: + def random(cls, shape: int | tuple = 1) -> Quaternion: quat = super().random(shape) quat.data[:, 0] = np.abs(quat.data[:, 0]) return quat - O = super().__invert__() - O.symmetry = self.symmetry - return O - @classmethod def from_axes_angles( cls, @@ -697,22 +693,22 @@ def from_align_vectors( @classmethod def from_path_ends( - cls, waypoints: Quaternion, close_loop: bool = False, steps: int = 100 + cls, points: Quaternion, closed: bool = False, steps: int = 100 ) -> Quaternion: - """Return Quaternions tracing the shortest path between two or more - consecutive waypoints. + """Return quaternions tracing the shortest path between two or more + consecutive points. Parameters ---------- - waypoints : Quaternion + points Two or more quaternions that define waypoints along a path through rotation space (SO3). - close_loop : bool, optional + closed Option to add a final trip from the last waypoint back to the - first, thus closing the loop. Default is False. - steps : int, optional - Number of points to return along the path between each pair of - waypoints. The default is 100. + first, thus closing the loop. The default is False. + steps + Number of quaternions to return along the path between each + pair of waypoints. The default is 100. Returns ------- @@ -721,30 +717,30 @@ def from_path_ends( Notes ----- - This method can use Orientations and Misorientations as inputs, and - will return an object of the same class. However, symmetry is ignored - when determining the shortest routes. + This method can use :class:Orientation and :class:Misorientation as + inputs and will return an object of the same class. However, + symmetry is ignored when determining the shortest routes. """ - waypoints = waypoints.flatten() - n = waypoints.size - if not close_loop: + points = points.flatten() + n = points.size + if not closed: n = n - 1 path_list = [] for i in range(n): # get start and end for this leg of the trip - q1 = waypoints[i] - q2 = waypoints[(i + 1) % (waypoints.size)] + q1 = points[i] + q2 = points[(i + 1) % (points.size)] # find the ax/ang describing the trip between points ax, ang = _conversions.qu2ax((~q1 * q2).data) # get 'steps=n' steps along the trip and add them to the journey trip = Quaternion.from_axes_angles(ax, np.linspace(0, ang, steps)) path_list.append((q1 * (trip.flatten())).data) path_data = np.concatenate(path_list, axis=0) - path = waypoints.__class__(path_data) + path = points.__class__(path_data) # copy the symmetry if it exists - if hasattr(waypoints, "_symmetry"): - path._symmetry = waypoints._symmetry + if hasattr(points, "_symmetry"): + path._symmetry = points._symmetry return path @classmethod From 7704d641c96aa1ffb065f91355afd4e238fa30f0 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 17 Jul 2025 15:46:34 -0600 Subject: [PATCH 16/23] made Quaternion.from_path_ends blind to symmetry, and added symmetry-aware counterpart to Misorientation --- orix/quaternion/misorientation.py | 32 +++++++++++++++++++++++ orix/quaternion/quaternion.py | 24 ++++++----------- orix/tests/quaternion/test_orientation.py | 28 ++++++++++++++++++++ orix/tests/quaternion/test_quaternion.py | 2 +- 4 files changed, 69 insertions(+), 17 deletions(-) diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index c4710cf5c..9175bd5e5 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -266,6 +266,38 @@ def from_scipy_rotation( M.symmetry = symmetry return M + @classmethod + def from_path_ends( + cls, points: Misorientation, closed: bool = False, steps: int = 100 + ) -> Misorientation: + """Return (mis)orientations tracing the shortest path between + two or more consecutive points. + + Parameters + ---------- + points + Two or more (mis)orientations that define waypoints along + a path through rotation space (SO3). + closed + Option to add a final trip from the last waypoint back to + the first, thus closing the loop. The default is False. + steps + Number of (mis)orientations to return along the path + between each pair of waypoints. The default is 100. + + Returns + ------- + path :Quaternion + quaternions that map a path between the given waypoints. + + """ + out = super().from_path_ends(points=points, closed=closed, steps=steps) + path = cls(out.data) + # copy the symmetry if it exists. + if hasattr(points, "_symmetry"): + path._symmetry = points._symmetry + return path + @classmethod def random( cls, diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index e321be0fd..5e9db9f3b 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -695,17 +695,17 @@ def from_align_vectors( def from_path_ends( cls, points: Quaternion, closed: bool = False, steps: int = 100 ) -> Quaternion: - """Return quaternions tracing the shortest path between two or more - consecutive points. + """Return quaternions tracing the shortest path between two or + more consecutive points. Parameters ---------- points - Two or more quaternions that define waypoints along a path through - rotation space (SO3). + Two or more quaternions that define waypoints along a path + through rotation space (SO3). closed - Option to add a final trip from the last waypoint back to the - first, thus closing the loop. The default is False. + Option to add a final trip from the last waypoint back to + the first, thus closing the loop. The default is False. steps Number of quaternions to return along the path between each pair of waypoints. The default is 100. @@ -713,13 +713,8 @@ def from_path_ends( Returns ------- path :Quaternion - quaternions that map out a path between the given waypoints. + quaternions that map a path between the given waypoints. - Notes - ----- - This method can use :class:Orientation and :class:Misorientation as - inputs and will return an object of the same class. However, - symmetry is ignored when determining the shortest routes. """ points = points.flatten() n = points.size @@ -737,10 +732,7 @@ def from_path_ends( trip = Quaternion.from_axes_angles(ax, np.linspace(0, ang, steps)) path_list.append((q1 * (trip.flatten())).data) path_data = np.concatenate(path_list, axis=0) - path = points.__class__(path_data) - # copy the symmetry if it exists - if hasattr(points, "_symmetry"): - path._symmetry = points._symmetry + path = cls(path_data) return path @classmethod diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 0645a135e..96677a507 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -441,6 +441,34 @@ def test_from_scipy_rotation(self): with pytest.raises(TypeError, match="Value must be a 2-tuple of"): _ = Misorientation.from_scipy_rotation(r_scipy, Oh) + def test_from_path_ends(self): + """check from_path_ends returns what you would expect and + preserves symmetry information.""" + q = Quaternion.random(10) + r = Rotation.random(10) + o = Orientation.random(10, Oh) + m = Misorientation.random(10, [D3, Oh]) + + # make sure the result is the correct class. + a = Quaternion.from_path_ends(q) + b = Rotation.from_path_ends(q) + c = Orientation.from_path_ends(r) + d = Quaternion.from_path_ends(o) + e = Orientation.from_path_ends(q) + f = Misorientation.from_path_ends(m) + g = Orientation.from_path_ends(o) + assert isinstance(a, Quaternion) + assert isinstance(b, Rotation) + assert isinstance(c, Orientation) + assert isinstance(d, Quaternion) + assert isinstance(e, Orientation) + assert isinstance(f, Misorientation) + assert isinstance(g, Orientation) + + # make sure symmetry information is preserved. + assert f.symmetry == m.symmetry + assert g.symmetry == o.symmetry + def test_inverse(self): M1 = Misorientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], (Oh, D6)) M2 = ~M1 diff --git a/orix/tests/quaternion/test_quaternion.py b/orix/tests/quaternion/test_quaternion.py index 332193a0a..41a9e2605 100644 --- a/orix/tests/quaternion/test_quaternion.py +++ b/orix/tests/quaternion/test_quaternion.py @@ -352,7 +352,7 @@ def test_from_path_ends(self): ) ) path = Quaternion.from_path_ends(waypoints) - loop = Quaternion.from_path_ends(waypoints, close_loop=True, steps=11) + loop = Quaternion.from_path_ends(waypoints, closed=True, steps=11) # check the sizes are as expected assert path.shape == (1100,) assert loop.shape == (132,) From 57b56885b14f916b652e051cd52f34954c6da670 Mon Sep 17 00:00:00 2001 From: Austin Gerlt Date: Fri, 18 Jul 2025 03:07:45 -0600 Subject: [PATCH 17/23] fixing docstring styling --- orix/quaternion/quaternion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index 5e9db9f3b..249ead06e 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -701,7 +701,7 @@ def from_path_ends( Parameters ---------- points - Two or more quaternions that define waypoints along a path + Two or more quaternions that define points along a path through rotation space (SO3). closed Option to add a final trip from the last waypoint back to @@ -712,7 +712,7 @@ def from_path_ends( Returns ------- - path :Quaternion + path quaternions that map a path between the given waypoints. """ From f842e0bb298b1edffc74884d7ff44e14f9a7f09b Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Fri, 18 Jul 2025 11:48:07 -0600 Subject: [PATCH 18/23] formatting and clarifying tests --- orix/quaternion/misorientation.py | 3 +- orix/quaternion/quaternion.py | 1 - orix/tests/quaternion/test_orientation.py | 75 +++++++++++++---------- 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index 9175bd5e5..eafd7b6db 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -287,9 +287,8 @@ def from_path_ends( Returns ------- - path :Quaternion + path quaternions that map a path between the given waypoints. - """ out = super().from_path_ends(points=points, closed=closed, steps=steps) path = cls(out.data) diff --git a/orix/quaternion/quaternion.py b/orix/quaternion/quaternion.py index 249ead06e..d11fa2b60 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -714,7 +714,6 @@ def from_path_ends( ------- path quaternions that map a path between the given waypoints. - """ points = points.flatten() n = points.size diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 96677a507..c4e93c661 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -321,6 +321,39 @@ def test_symmetry_property_wrong_number_of_values_misorientation(error_type, val o.symmetry = value +def test_from_path_ends(): + """check from_path_ends returns what you would expect and + preserves symmetry information. + + In particular, ensure the class of the returned object matches the class + used for creating it, NOT the class of the object passed in. + """ + q = Quaternion.random(10) + r = Rotation.random(10) + o = Orientation.random(10, Oh) + m = Misorientation.random(10, [D3, Oh]) + + # make sure the result is the correct class. + a = Quaternion.from_path_ends(q) + b = Rotation.from_path_ends(q) + c = Orientation.from_path_ends(r) + d = Quaternion.from_path_ends(o) + e = Orientation.from_path_ends(q) + f = Misorientation.from_path_ends(m) + g = Orientation.from_path_ends(o) + assert isinstance(a, Quaternion) + assert isinstance(b, Rotation) + assert isinstance(c, Orientation) + assert isinstance(d, Quaternion) + assert isinstance(e, Orientation) + assert isinstance(f, Misorientation) + assert isinstance(g, Orientation) + + # make sure symmetry information is preserved. + assert f.symmetry == m.symmetry + assert g.symmetry == o.symmetry + + class TestMisorientation: def test_get_distance_matrix(self): """Compute distance between every misorientation in an instance @@ -442,32 +475,9 @@ def test_from_scipy_rotation(self): _ = Misorientation.from_scipy_rotation(r_scipy, Oh) def test_from_path_ends(self): - """check from_path_ends returns what you would expect and - preserves symmetry information.""" - q = Quaternion.random(10) - r = Rotation.random(10) - o = Orientation.random(10, Oh) - m = Misorientation.random(10, [D3, Oh]) - - # make sure the result is the correct class. - a = Quaternion.from_path_ends(q) - b = Rotation.from_path_ends(q) - c = Orientation.from_path_ends(r) - d = Quaternion.from_path_ends(o) - e = Orientation.from_path_ends(q) - f = Misorientation.from_path_ends(m) - g = Orientation.from_path_ends(o) - assert isinstance(a, Quaternion) - assert isinstance(b, Rotation) - assert isinstance(c, Orientation) - assert isinstance(d, Quaternion) - assert isinstance(e, Orientation) - assert isinstance(f, Misorientation) - assert isinstance(g, Orientation) - - # make sure symmetry information is preserved. - assert f.symmetry == m.symmetry - assert g.symmetry == o.symmetry + # generate paths with misorientations to check symmetry copying + wp_m = Misorientation(data=np.eye(4)[2:], symmetry=[Oh, C3]) + assert Misorientation.from_path_ends(wp_m)._symmetry == (Oh, C3) def test_inverse(self): M1 = Misorientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], (Oh, D6)) @@ -628,14 +638,6 @@ def test_from_scipy_rotation(self): with pytest.raises(TypeError, match="Value must be an instance of"): _ = Orientation.from_scipy_rotation(r_scipy, (Oh, Oh)) - def test_from_path_ends(self): - # generate paths with misorientations and orientations to check - # symmetry copying - wp_o = Orientation(data=np.eye(4)[:2], symmetry=Oh) - wp_m = Misorientation(data=np.eye(4)[2:], symmetry=[Oh, C3]) - assert Orientation.from_path_ends(wp_o)._symmetry == (C1, Oh) - assert Misorientation.from_path_ends(wp_m)._symmetry == (Oh, C3) - class TestOrientation: @pytest.mark.parametrize("symmetry", [C1, C2, C3, C4, D2, D3, D6, T, O, Oh]) @@ -846,6 +848,11 @@ def test_in_fundamental_region(self): region = np.radians(pg.euler_fundamental_region) assert np.all(np.max(ori.in_euler_fundamental_region(), axis=0) <= region) + def test_from_path_ends(self): + # generate paths with orientations to check symmetry copying + wp_o = Orientation(data=np.eye(4)[:2], symmetry=Oh) + assert Orientation.from_path_ends(wp_o)._symmetry == (C1, Oh) + def test_inverse(self): O1 = Orientation([np.sqrt(2) / 2, np.sqrt(2) / 2, 0, 0], D6) O2 = ~O1 From d089947707526fe82c38e514638d300c25d5058b Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:38:21 -0600 Subject: [PATCH 19/23] clarifying tests and adding from_path_ends example --- examples/plotting/plot_non_euclidean_paths.py | 81 +++++++++++++++++++ orix/tests/quaternion/test_quaternion.py | 12 +++ 2 files changed, 93 insertions(+) create mode 100644 examples/plotting/plot_non_euclidean_paths.py diff --git a/examples/plotting/plot_non_euclidean_paths.py b/examples/plotting/plot_non_euclidean_paths.py new file mode 100644 index 000000000..3a3b6ca16 --- /dev/null +++ b/examples/plotting/plot_non_euclidean_paths.py @@ -0,0 +1,81 @@ +r""" +======================== +Plot symmetry operations +======================== + +This example shows how to use the `from_path_ends` functions from +:class:`~orix.vector.Vector3d`, :class:`~orix.quaternions.Rotation`, and +:class:`~orix.quaternions.Orientation` to draw paths through thier +respective non-Euclidean spaces. +""" + +import numpy as np +import matplotlib.pyplot as plt +from orix.quaternion import Orientation, Rotation +from orix.quaternion.symmetry import Oh, D3 +from orix.vector import Vector3d + + +fig = plt.figure() + +# plot a path in homochoric space with no symmetry +rot_path = Rotation( + data=np.array( + [ + [1, 0, 0, 0], + [1, 1, 0, 0], + [1, 0, 1, 0], + [1, 0, 0, 1], + [1, 0, -1, 0], + [1, 0, 0, -1], + [1, 0, 0, -1], + ] + ) +) +rotation_path = Rotation.from_path_ends(rot_path, closed=True) +# cast the rotation to a symmetry-less orientation for plotting purposes +Orientation(rotation_path).scatter( + figure=fig, position=[2, 2, 1], marker=">", c=np.arange(700) +) + +# plot a path in rodrigues space with m-3m (cubic) symmetry. +m3m_path = Orientation( + data=np.array( + [ + [1, 0, 0, 0], + [2, 1, 0, 0], + [3, 0, 1, 0], + [4, 0, 0, 1], + [5, 0, -1, 0], + [6, 0, 0, -1], + [7, 0, 0, -1], + [8, 1, 0, 0], + [9, 0, 1, 0], + [10, 0, 0, 1], + [11, 0, -1, 0], + [12, 0, 0, -1], + [13, 0, 0, -1], + ] + ), + symmetry=Oh, +) +orientation_path = Orientation.from_path_ends(m3m_path.reduce(), closed=True).reduce() +orientation_path.scatter(figure=fig, position=[2, 2, 2], marker=">", c=np.arange(1300)) + +# plot a second path in rodrigues space with symmetry, but while also crossing a +# symmetry boundary +fiber_start = Rotation.identity(1) +fiber_middle = Rotation.from_axes_angles([1, 2, 3], np.pi) +fiber_end = Rotation.from_axes_angles([1, 2, 3], 2 * np.pi) +fiber_points = Orientation.stack([fiber_start, fiber_middle, fiber_end]) +fiber_points.symmetry = Oh +fiber_path = Orientation.from_path_ends(fiber_points, closed=False).reduce() +fiber_path.scatter(figure=fig, position=[2, 2, 3], marker=">", c=np.arange(200)) + + +# plot vectors +ax4 = plt.subplot(2, 2, 4, projection="stereographic") +vector_points = Vector3d(np.array([[-1,0,0],[0,1,0.1],[1,0,0.2],[0,-1,0.3],[-1,0,0.4]])) + +vector_path = Vector3d.from_path_ends(vector_points,steps = 200) +ax4.scatter(vector_path, figure=fig, marker=">", c=np.arange(vector_path.size)) diff --git a/orix/tests/quaternion/test_quaternion.py b/orix/tests/quaternion/test_quaternion.py index 41a9e2605..fea55e915 100644 --- a/orix/tests/quaternion/test_quaternion.py +++ b/orix/tests/quaternion/test_quaternion.py @@ -362,6 +362,18 @@ def test_from_path_ends(self): assert np.all(np.std(path_spacing, axis=1) < 1e-12) assert np.all(np.std(loop_spacing, axis=1) < 1e-12) + def test_from_path_ends_fiber(self): + # check that a linear path in quaternion space follows an explicitly defined + # fiber along the same path. + # this ensures the path is the shortest distance in SO3 space, (as opposed + # to rodrigues or quaternion space) and also evenly spaced along the path. + Q1 = Quaternion.identity() + Q2 = Quaternion.from_axes_angles([1, 1, 1], 60, degrees=True) + Q12 = Quaternion.stack((Q1, Q2)) + Q_path1 = Quaternion.from_axes_angles([1, 1, 1], np.arange(59), degrees=True) + Q_path2 = Quaternion.from_path_ends(Q12, steps=Q_path1.size) + assert np.allclose(Q_path1.dot(Q_path2), 1, atol=1e-3) + def test_equality(self): Q1 = Quaternion.from_axes_angles([1, 1, 1], -np.pi / 3) Q2 = Quaternion.from_axes_angles([1, 1, 1], np.pi / 3) From ef9f77c8079b3cd353851bb381c651481b3283ea Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:48:54 -0600 Subject: [PATCH 20/23] formatting --- examples/plotting/plot_non_euclidean_paths.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/examples/plotting/plot_non_euclidean_paths.py b/examples/plotting/plot_non_euclidean_paths.py index 3a3b6ca16..50dbe2060 100644 --- a/examples/plotting/plot_non_euclidean_paths.py +++ b/examples/plotting/plot_non_euclidean_paths.py @@ -9,13 +9,13 @@ respective non-Euclidean spaces. """ -import numpy as np import matplotlib.pyplot as plt +import numpy as np + from orix.quaternion import Orientation, Rotation -from orix.quaternion.symmetry import Oh, D3 +from orix.quaternion.symmetry import D3, Oh from orix.vector import Vector3d - fig = plt.figure() # plot a path in homochoric space with no symmetry @@ -75,7 +75,9 @@ # plot vectors ax4 = plt.subplot(2, 2, 4, projection="stereographic") -vector_points = Vector3d(np.array([[-1,0,0],[0,1,0.1],[1,0,0.2],[0,-1,0.3],[-1,0,0.4]])) +vector_points = Vector3d( + np.array([[-1, 0, 0], [0, 1, 0.1], [1, 0, 0.2], [0, -1, 0.3], [-1, 0, 0.4]]) +) -vector_path = Vector3d.from_path_ends(vector_points,steps = 200) +vector_path = Vector3d.from_path_ends(vector_points, steps=200) ax4.scatter(vector_path, figure=fig, marker=">", c=np.arange(vector_path.size)) From 03827e0f7543e177e14a0ae51f5bfa6f1af0b1fc Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Wed, 3 Sep 2025 19:56:12 -0600 Subject: [PATCH 21/23] including suggestions and updating example --- examples/plotting/plot_non_euclidean_paths.py | 138 +++++++++++------- orix/quaternion/misorientation.py | 18 ++- orix/quaternion/orientation.py | 35 +++++ orix/tests/quaternion/test_orientation.py | 53 +++++-- 4 files changed, 169 insertions(+), 75 deletions(-) diff --git a/examples/plotting/plot_non_euclidean_paths.py b/examples/plotting/plot_non_euclidean_paths.py index 50dbe2060..2768c0a2a 100644 --- a/examples/plotting/plot_non_euclidean_paths.py +++ b/examples/plotting/plot_non_euclidean_paths.py @@ -1,83 +1,115 @@ r""" -======================== -Plot symmetry operations -======================== +======================================== +Plot Paths Through Non-Euclidean Spaces +======================================== -This example shows how to use the `from_path_ends` functions from -:class:`~orix.vector.Vector3d`, :class:`~orix.quaternions.Rotation`, and -:class:`~orix.quaternions.Orientation` to draw paths through thier -respective non-Euclidean spaces. +This example shows three variations on how 'from_path_ends' can be +used to plot paths between points in rotational and vector spaces. + +This functionality is available in :class:`~orix.vector.Vector3d`, +:class:`~orix.quaternions.Rotation`, +:class:`~orix.quaternions.Orientation`, +and :class:`~orix.quaternions.Misorientation`. """ import matplotlib.pyplot as plt +from matplotlib import cm import numpy as np -from orix.quaternion import Orientation, Rotation +from orix.quaternion import Misorientation, Orientation, Rotation from orix.quaternion.symmetry import D3, Oh from orix.vector import Vector3d -fig = plt.figure() +fig = plt.figure(figsize=(4, 8)) -# plot a path in homochoric space with no symmetry -rot_path = Rotation( +# ========= # +# Example 1: Plotting a path of rotations with no symmetry in homochoric space +# ========= # +rots_along_path = Rotation( data=np.array( [ [1, 0, 0, 0], - [1, 1, 0, 0], - [1, 0, 1, 0], [1, 0, 0, 1], - [1, 0, -1, 0], - [1, 0, 0, -1], - [1, 0, 0, -1], + [1, 1, 1, 1], ] ) ) -rotation_path = Rotation.from_path_ends(rot_path, closed=True) -# cast the rotation to a symmetry-less orientation for plotting purposes -Orientation(rotation_path).scatter( - figure=fig, position=[2, 2, 1], marker=">", c=np.arange(700) -) +n_steps = 20 +rotation_path = Rotation.from_path_ends(rots_along_path, steps=n_steps) +# create an Orientation loop using this path with no symmetry elements +ori_path = Orientation(rotation_path) +# plot the path in homochoric space +segment_colors = cm.inferno(np.linspace(0, 1, n_steps)) -# plot a path in rodrigues space with m-3m (cubic) symmetry. -m3m_path = Orientation( +path_colors = np.vstack([segment_colors for x in range(rots_along_path.size - 1)]) +ori_path.scatter(figure=fig, position=[3, 1, 1], marker=">", c=path_colors) +fig.axes[0].set_title(r"$90^\circ$ rotation around X, then Y") + +# ========= # +# Example 2: Plotting the rotation of several orientations in m3m Rodrigues +# space around the z axis. +# ========= # +oris = Orientation( data=np.array( [ - [1, 0, 0, 0], - [2, 1, 0, 0], - [3, 0, 1, 0], - [4, 0, 0, 1], - [5, 0, -1, 0], - [6, 0, 0, -1], - [7, 0, 0, -1], - [8, 1, 0, 0], - [9, 0, 1, 0], - [10, 0, 0, 1], - [11, 0, -1, 0], - [12, 0, 0, -1], - [13, 0, 0, -1], + [0.69, 0.24, 0.68, 0.01], + [0.26, 0.59, 0.32, 0.7], + [0.07, 0.17, 0.93, 0.31], + [0.6, 0.03, 0.61, 0.52], + [0.51, 0.38, 0.34, 0.69], + [0.31, 0.86, 0.22, 0.35], + [0.68, 0.67, 0.06, 0.31], + [0.01, 0.12, 0.05, 0.99], + [0.39, 0.45, 0.34, 0.72], + [0.65, 0.59, 0.46, 0.15], ] ), symmetry=Oh, +).reduce() +# define a 20 degree rotation around the z axis +shift = Orientation.from_axes_angles([0, 0, 1], np.pi / 9) +segment_colors = cm.inferno(np.linspace(0, 1, 10)) + +ori_paths = [] +for ori in oris: + shifted = (shift * ori).reduce() + to_from = Orientation.stack([ori, shifted]).flatten() + ori_paths.append(Orientation.from_path_ends(to_from, steps=10)) +# plot a path in roddrigues space with m-3m (cubic) symmetry. +ori_path = Orientation.stack(ori_paths).flatten() +ori_path.symmetry = Oh +ori_path.scatter( + figure=fig, + position=[3, 1, 2], + marker=">", + c=np.tile(segment_colors, [10, 1]), + projection="rodrigues", ) -orientation_path = Orientation.from_path_ends(m3m_path.reduce(), closed=True).reduce() -orientation_path.scatter(figure=fig, position=[2, 2, 2], marker=">", c=np.arange(1300)) +fig.axes[1].set_title(r"$20^{\circ}$ rotations around X-axis in m3m") -# plot a second path in rodrigues space with symmetry, but while also crossing a -# symmetry boundary -fiber_start = Rotation.identity(1) -fiber_middle = Rotation.from_axes_angles([1, 2, 3], np.pi) -fiber_end = Rotation.from_axes_angles([1, 2, 3], 2 * np.pi) -fiber_points = Orientation.stack([fiber_start, fiber_middle, fiber_end]) -fiber_points.symmetry = Oh -fiber_path = Orientation.from_path_ends(fiber_points, closed=False).reduce() -fiber_path.scatter(figure=fig, position=[2, 2, 3], marker=">", c=np.arange(200)) +# ========= # +# Example 3: creating a customized Wulf Plotting the rotation of several orientations in m3m Rodrigues +# space around the z axis. +# ========= # # plot vectors -ax4 = plt.subplot(2, 2, 4, projection="stereographic") -vector_points = Vector3d( - np.array([[-1, 0, 0], [0, 1, 0.1], [1, 0, 0.2], [0, -1, 0.3], [-1, 0, 0.4]]) -) +ax_upper = plt.subplot(3, 1, 3, projection="stereographic", hemisphere="upper") +r90x = Rotation.from_axes_angles([1, -1, -1], [0, 60], degrees=True) +x_axis_points = r90x * Vector3d.xvector() +y_axis_points = r90x * Vector3d.yvector() +z_axis_points = r90x * Vector3d.zvector() + +x_axis_path = Vector3d.from_path_ends(x_axis_points.unique()) +y_axis_path = Vector3d.from_path_ends(y_axis_points.unique()) +z_axis_path = Vector3d.from_path_ends(z_axis_points.unique()) +cx = cm.Reds(np.linspace(0.1, 1, x_axis_path.size)) +cy = cm.Greens(np.linspace(0.1, 1, y_axis_path.size)) +cz = cm.Blues(np.linspace(0.1, 1, z_axis_path.size)) + +spx = ax_upper.scatter(x_axis_path, figure=fig, marker=">", c=cx, label="X") +spy = ax_upper.scatter(y_axis_path, figure=fig, marker=">", c=cy, label="Y") +spz = ax_upper.scatter(z_axis_path, figure=fig, marker=">", c=cz, label="Z") +ax_upper.legend(loc="lower center", ncols=3) -vector_path = Vector3d.from_path_ends(vector_points, steps=200) -ax4.scatter(vector_path, figure=fig, marker=">", c=np.arange(vector_path.size)) +plt.tight_layout() diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index eafd7b6db..018d0d06c 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -270,31 +270,35 @@ def from_scipy_rotation( def from_path_ends( cls, points: Misorientation, closed: bool = False, steps: int = 100 ) -> Misorientation: - """Return (mis)orientations tracing the shortest path between + """Return misorientations tracing the shortest path between two or more consecutive points. Parameters ---------- points - Two or more (mis)orientations that define waypoints along + Two or more misorientations that define waypoints along a path through rotation space (SO3). closed Option to add a final trip from the last waypoint back to the first, thus closing the loop. The default is False. steps - Number of (mis)orientations to return along the path + Number of misorientations to return along the path between each pair of waypoints. The default is 100. Returns ------- path - quaternions that map a path between the given waypoints. + misorientations that map a path between the given waypoints. """ + # Confirm `points` are (mis)orientations. + if not isinstance(points, Misorientation): + raise TypeError( + f"Points must be a Misorientation, not of type {type(points)}" + ) + # Create a path through Quaternion space, then reapply the symmetry. out = super().from_path_ends(points=points, closed=closed, steps=steps) path = cls(out.data) - # copy the symmetry if it exists. - if hasattr(points, "_symmetry"): - path._symmetry = points._symmetry + path._symmetry = points._symmetry return path @classmethod diff --git a/orix/quaternion/orientation.py b/orix/quaternion/orientation.py index 598208c75..d93b524d5 100644 --- a/orix/quaternion/orientation.py +++ b/orix/quaternion/orientation.py @@ -346,6 +346,41 @@ def from_scipy_rotation( O.symmetry = symmetry return O + @classmethod + def from_path_ends( + cls, points: Orientation, closed: bool = False, steps: int = 100 + ) -> Misorientation: + """Return orientations tracing the shortest path between + two or more consecutive points. + + Parameters + ---------- + points + Two or more orientations that define waypoints along + a path through rotation space (SO3). + closed + Option to add a final trip from the last waypoint back to + the first, thus closing the loop. The default is False. + steps + Number of orientations to return along the path + between each pair of waypoints. The default is 100. + + Returns + ------- + path + orientations that map a path between the given waypoints. + """ + # Confirm `points` are orientations. + if not isinstance(points, Orientation): + raise TypeError( + f"Points must be an Orientation instance, not of type {type(points)}" + ) + # Create a path through Quaternion space, then reapply the symmetry. + out = super().from_path_ends(points=points, closed=closed, steps=steps) + path = cls(out.data) + path._symmetry = points._symmetry + return path + @classmethod def random( cls, shape: Union[int, tuple] = 1, symmetry: Optional[Symmetry] = None diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index c4e93c661..a259fc998 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -333,25 +333,48 @@ def test_from_path_ends(): o = Orientation.random(10, Oh) m = Misorientation.random(10, [D3, Oh]) - # make sure the result is the correct class. + # Quaternion sanity checks a = Quaternion.from_path_ends(q) - b = Rotation.from_path_ends(q) - c = Orientation.from_path_ends(r) - d = Quaternion.from_path_ends(o) - e = Orientation.from_path_ends(q) - f = Misorientation.from_path_ends(m) - g = Orientation.from_path_ends(o) assert isinstance(a, Quaternion) - assert isinstance(b, Rotation) - assert isinstance(c, Orientation) + b = Quaternion.from_path_ends(r) + assert isinstance(b, Quaternion) + c = Quaternion.from_path_ends(o) + assert isinstance(c, Quaternion) + d = Quaternion.from_path_ends(m) assert isinstance(d, Quaternion) - assert isinstance(e, Orientation) - assert isinstance(f, Misorientation) - assert isinstance(g, Orientation) - # make sure symmetry information is preserved. - assert f.symmetry == m.symmetry - assert g.symmetry == o.symmetry + # Rotation sanity checks + a = Rotation.from_path_ends(q) + assert isinstance(a, Rotation) + b = Rotation.from_path_ends(r) + assert isinstance(b, Rotation) + c = Rotation.from_path_ends(o) + assert isinstance(c, Rotation) + d = Rotation.from_path_ends(m) + assert isinstance(d, Rotation) + + # Misorientation sanity checks + with pytest.raises(TypeError, match="Points must be a Misorientation"): + a = Misorientation.from_path_ends(q) + with pytest.raises(TypeError, match="Points must be a Misorientation"): + b = Misorientation.from_path_ends(r) + c = Misorientation.from_path_ends(o) + assert isinstance(c, Misorientation) + d = Misorientation.from_path_ends(m) + assert isinstance(d, Misorientation) + assert c.symmetry[1] == o.symmetry + assert d.symmetry == m.symmetry + + # Orientation sanity checks + with pytest.raises(TypeError, match="Points must be an Orientation"): + a = Orientation.from_path_ends(q) + with pytest.raises(TypeError, match="Points must be an Orientation"): + b = Orientation.from_path_ends(r) + c = Orientation.from_path_ends(o) + assert c.symmetry == o.symmetry + assert isinstance(c, Orientation) + with pytest.raises(TypeError, match="Points must be an Orientation"): + d = Orientation.from_path_ends(m) class TestMisorientation: From 1aeb7a964985b1109d6654c0ed67d9bdd1216741 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 4 Sep 2025 19:30:57 -0600 Subject: [PATCH 22/23] add HomochroicPlot, cleaned up examples, formatting --- ...across_fundamental_region_boundaries.ipynb | 4 +- .../clustering_misorientations.ipynb | 8 +- doc/tutorials/clustering_orientations.ipynb | 6 +- examples/plotting/plot_non_euclidean_paths.py | 115 -------------- examples/plotting/plotting_paths.py | 141 ++++++++++++++++++ orix/plot/__init__.py | 7 +- orix/plot/rotation_plot.py | 14 +- orix/quaternion/misorientation.py | 5 +- orix/quaternion/orientation.py | 6 +- orix/tests/plot/test_rotation_plot.py | 9 +- orix/tests/test_crystal_map.py | 1 + 11 files changed, 182 insertions(+), 134 deletions(-) delete mode 100644 examples/plotting/plot_non_euclidean_paths.py create mode 100644 examples/plotting/plotting_paths.py diff --git a/doc/tutorials/clustering_across_fundamental_region_boundaries.ipynb b/doc/tutorials/clustering_across_fundamental_region_boundaries.ipynb index 9131004df..ca6dd364b 100644 --- a/doc/tutorials/clustering_across_fundamental_region_boundaries.ipynb +++ b/doc/tutorials/clustering_across_fundamental_region_boundaries.ipynb @@ -89,7 +89,7 @@ "# Stack and map into the Oh fundamental zone\n", "ori = Orientation.stack([cluster1, cluster2, cluster3]).flatten()\n", "ori.symmetry = Oh\n", - "ori = ori.map_into_symmetry_reduced_zone()" + "ori = ori.reduce()" ] }, { @@ -158,7 +158,7 @@ "mori2 = (~ori).outer(ori)\n", "\n", "mori2.symmetry = Oh\n", - "mori2 = mori2.map_into_symmetry_reduced_zone()\n", + "mori2 = mori2.reduce()\n", "\n", "D2 = mori2.angle" ] diff --git a/doc/tutorials/clustering_misorientations.ipynb b/doc/tutorials/clustering_misorientations.ipynb index f13e66385..8845a33c7 100644 --- a/doc/tutorials/clustering_misorientations.ipynb +++ b/doc/tutorials/clustering_misorientations.ipynb @@ -163,7 +163,7 @@ "metadata": {}, "outputs": [], "source": [ - "ori = ori.map_into_symmetry_reduced_zone()" + "ori = ori.reduce()" ] }, { @@ -213,7 +213,7 @@ "outputs": [], "source": [ "mori.symmetry = (D6, D6)\n", - "mori = mori.map_into_symmetry_reduced_zone()" + "mori = mori.reduce()" ] }, { @@ -305,7 +305,7 @@ "\n", " # Map into the fundamental zone\n", " mori_i.symmetry = (D6, D6)\n", - " mori_i = mori_i.map_into_symmetry_reduced_zone()\n", + " mori_i = mori_i.reduce()\n", "\n", " # Get the cluster mean\n", " mori_i = mori_i.mean()\n", @@ -318,7 +318,7 @@ "\n", "# Map into the fundamental zone\n", "cluster_means.symmetry = (D6, D6)\n", - "cluster_means = cluster_means.map_into_symmetry_reduced_zone()\n", + "cluster_means = cluster_means.reduce()\n", "cluster_means" ] }, diff --git a/doc/tutorials/clustering_orientations.ipynb b/doc/tutorials/clustering_orientations.ipynb index ed2c363fe..5b15d014b 100644 --- a/doc/tutorials/clustering_orientations.ipynb +++ b/doc/tutorials/clustering_orientations.ipynb @@ -198,7 +198,7 @@ "metadata": {}, "outputs": [], "source": [ - "ori = ori.map_into_symmetry_reduced_zone()" + "ori = ori.reduce()" ] }, { @@ -320,7 +320,7 @@ "\n", "# Map into the fundamental zone\n", "cluster_means.symmetry = D6\n", - "cluster_means = cluster_means.map_into_symmetry_reduced_zone()\n", + "cluster_means = cluster_means.reduce()\n", "cluster_means" ] }, @@ -393,7 +393,7 @@ "\n", "# Map into the fundamental zone\n", "ori_recentered.symmetry = D6\n", - "ori_recentered = ori_recentered.map_into_symmetry_reduced_zone()\n", + "ori_recentered = ori_recentered.reduce()\n", "\n", "cluster_means_recentered = Orientation.stack(\n", " [ori_recentered[all_labels == l].mean() for l in unique_cluster_labels]\n", diff --git a/examples/plotting/plot_non_euclidean_paths.py b/examples/plotting/plot_non_euclidean_paths.py deleted file mode 100644 index 2768c0a2a..000000000 --- a/examples/plotting/plot_non_euclidean_paths.py +++ /dev/null @@ -1,115 +0,0 @@ -r""" -======================================== -Plot Paths Through Non-Euclidean Spaces -======================================== - -This example shows three variations on how 'from_path_ends' can be -used to plot paths between points in rotational and vector spaces. - -This functionality is available in :class:`~orix.vector.Vector3d`, -:class:`~orix.quaternions.Rotation`, -:class:`~orix.quaternions.Orientation`, -and :class:`~orix.quaternions.Misorientation`. -""" - -import matplotlib.pyplot as plt -from matplotlib import cm -import numpy as np - -from orix.quaternion import Misorientation, Orientation, Rotation -from orix.quaternion.symmetry import D3, Oh -from orix.vector import Vector3d - -fig = plt.figure(figsize=(4, 8)) - -# ========= # -# Example 1: Plotting a path of rotations with no symmetry in homochoric space -# ========= # -rots_along_path = Rotation( - data=np.array( - [ - [1, 0, 0, 0], - [1, 0, 0, 1], - [1, 1, 1, 1], - ] - ) -) -n_steps = 20 -rotation_path = Rotation.from_path_ends(rots_along_path, steps=n_steps) -# create an Orientation loop using this path with no symmetry elements -ori_path = Orientation(rotation_path) -# plot the path in homochoric space -segment_colors = cm.inferno(np.linspace(0, 1, n_steps)) - -path_colors = np.vstack([segment_colors for x in range(rots_along_path.size - 1)]) -ori_path.scatter(figure=fig, position=[3, 1, 1], marker=">", c=path_colors) -fig.axes[0].set_title(r"$90^\circ$ rotation around X, then Y") - -# ========= # -# Example 2: Plotting the rotation of several orientations in m3m Rodrigues -# space around the z axis. -# ========= # -oris = Orientation( - data=np.array( - [ - [0.69, 0.24, 0.68, 0.01], - [0.26, 0.59, 0.32, 0.7], - [0.07, 0.17, 0.93, 0.31], - [0.6, 0.03, 0.61, 0.52], - [0.51, 0.38, 0.34, 0.69], - [0.31, 0.86, 0.22, 0.35], - [0.68, 0.67, 0.06, 0.31], - [0.01, 0.12, 0.05, 0.99], - [0.39, 0.45, 0.34, 0.72], - [0.65, 0.59, 0.46, 0.15], - ] - ), - symmetry=Oh, -).reduce() -# define a 20 degree rotation around the z axis -shift = Orientation.from_axes_angles([0, 0, 1], np.pi / 9) -segment_colors = cm.inferno(np.linspace(0, 1, 10)) - -ori_paths = [] -for ori in oris: - shifted = (shift * ori).reduce() - to_from = Orientation.stack([ori, shifted]).flatten() - ori_paths.append(Orientation.from_path_ends(to_from, steps=10)) -# plot a path in roddrigues space with m-3m (cubic) symmetry. -ori_path = Orientation.stack(ori_paths).flatten() -ori_path.symmetry = Oh -ori_path.scatter( - figure=fig, - position=[3, 1, 2], - marker=">", - c=np.tile(segment_colors, [10, 1]), - projection="rodrigues", -) -fig.axes[1].set_title(r"$20^{\circ}$ rotations around X-axis in m3m") - -# ========= # -# Example 3: creating a customized Wulf Plotting the rotation of several orientations in m3m Rodrigues -# space around the z axis. -# ========= # - - -# plot vectors -ax_upper = plt.subplot(3, 1, 3, projection="stereographic", hemisphere="upper") -r90x = Rotation.from_axes_angles([1, -1, -1], [0, 60], degrees=True) -x_axis_points = r90x * Vector3d.xvector() -y_axis_points = r90x * Vector3d.yvector() -z_axis_points = r90x * Vector3d.zvector() - -x_axis_path = Vector3d.from_path_ends(x_axis_points.unique()) -y_axis_path = Vector3d.from_path_ends(y_axis_points.unique()) -z_axis_path = Vector3d.from_path_ends(z_axis_points.unique()) -cx = cm.Reds(np.linspace(0.1, 1, x_axis_path.size)) -cy = cm.Greens(np.linspace(0.1, 1, y_axis_path.size)) -cz = cm.Blues(np.linspace(0.1, 1, z_axis_path.size)) - -spx = ax_upper.scatter(x_axis_path, figure=fig, marker=">", c=cx, label="X") -spy = ax_upper.scatter(y_axis_path, figure=fig, marker=">", c=cy, label="Y") -spz = ax_upper.scatter(z_axis_path, figure=fig, marker=">", c=cz, label="Z") -ax_upper.legend(loc="lower center", ncols=3) - -plt.tight_layout() diff --git a/examples/plotting/plotting_paths.py b/examples/plotting/plotting_paths.py new file mode 100644 index 000000000..0ba8cf9b2 --- /dev/null +++ b/examples/plotting/plotting_paths.py @@ -0,0 +1,141 @@ +r""" +========================================= + Plot Paths In Rotation and Vector Space +========================================= + +This example shows how paths though either rotation or vector space +can be plotted using ORIX. These are the shortest paths through their +respective spaces, and thus not always straight lines in euclidean +projections (Rodrigues, stereographic, etc.). + +This functionality is available in :class:`~orix.vector.Vector3d`, +:class:`~orix.quaternions.Rotation`, +:class:`~orix.quaternions.Orientation`, +and :class:`~orix.quaternions.Misorientation`. +""" + +from matplotlib import cm +import matplotlib.pyplot as plt +import numpy as np + +from orix import plot +from orix.plot.direction_color_keys import DirectionColorKeyTSL +from orix.quaternion import Orientation, OrientationRegion, Quaternion +from orix.quaternion.symmetry import C1, Oh +from orix.sampling import sample_S2 +from orix.vector import Vector3d + +fig = plt.figure(figsize=(6, 6)) +n_steps = 30 + +# ========= # +# Example 1: Plotting multiple paths into a user defined axis +# ========= # +# This subplot shows several paths through the cubic (m3m) fundamental zone +# created by rotating 20 randomly chosen points 30 degrees around the z axis. +# these paths are drawn in rodrigues space, which is an equal-angle projection +# of rotation space. As such, notice how all lines tracing out axial rotations +# are straight, but lines starting closer to the center of the fundamental zone +# appear shorter. +# the sampe paths are then also plotted on an Inverse Pole Figure (IPF) plot. +rod_ax = fig.add_subplot(2, 2, 1, projection="rodrigues", proj_type="ortho") +ipf_ax = fig.add_subplot(2, 2, 2, projection="ipf", symmetry=Oh) + +# 10 random orientations with the cubic m3m ('Oh' in the schoenflies notation) +# crystal symmetry. +oris = Orientation( + data=np.array( + [ + [0.69, 0.24, 0.68, 0.01], + [0.26, 0.59, 0.32, 0.7], + [0.07, 0.17, 0.93, 0.31], + [0.6, 0.03, 0.61, 0.52], + [0.51, 0.38, 0.34, 0.69], + [0.31, 0.86, 0.22, 0.35], + [0.68, 0.67, 0.06, 0.31], + [0.01, 0.12, 0.05, 0.99], + [0.39, 0.45, 0.34, 0.72], + [0.65, 0.59, 0.46, 0.15], + ] + ), + symmetry=Oh, +) +# reduce them to their crystallographically identical representations +oris = oris.reduce() +# define a 20 degree rotation around the z axis +shift = Orientation.from_axes_angles([0, 0, 1], 30, degrees=True) +# for each orientation, calculate and plot the path they would take during a +# 45 degree shift. +segment_colors = cm.inferno(np.linspace(0, 1, n_steps)) +for ori in oris: + points = Orientation.stack([ori, (shift * ori)]).reduce() + points.symmetry = Oh + path = Orientation.from_path_ends(points, steps=n_steps) + rod_ax.scatter(path, c=segment_colors) + ipf_ax.scatter(path, c=segment_colors) + +# add the wireframe and clean up the plot. +fz = OrientationRegion.from_symmetry(path.symmetry) +rod_ax.plot_wireframe(fz) +rod_ax._correct_aspect_ratio(fz) +rod_ax.axis("off") +rod_ax.set_title(r"Rodrigues, multiple paths") +ipf_ax.set_title(r"IPF, multiple paths ") + + +# %% +# ========= # +# Example 2: Plotting a path using `Rotation.scatter' +# ========= # +# This subplot traces the path of an object rotated 90 degrees around the +# X axis, then 90 degrees around the Y axis. The path is plotted in +# homochoric space, which is an equal-volume projection of rotation space +rots = Orientation.from_axes_angles( + [[1, 0, 0], [1, 0, 0], [0, 1, 0]], [0, 90, 90], degrees=True, symmetry=C1 +) +rots[2] = rots[1] * rots[2] +path = Orientation.from_path_ends(rots, steps=n_steps) +# create a list of RGBA color values for a gradient red line and blue line +path_colors = np.vstack( + [cm.Reds(np.linspace(0.5, 1, n_steps)), cm.Blues(np.linspace(0.5, 1, n_steps))] +) + +# Here, we instead use the in-built plotting tool from +# Orientation.scatter to auto-generate the subplot. This is especially handy when +# plotting only a single Orientation object. +path.scatter(figure=fig, position=[2, 2, 3], marker=">", c=path_colors) +fig.axes[2].set_title(r"Homochoric, two $90^\circ$ rotations") + +# %% + +# ========= # +# Example 3: paths in stereographic plots +# ========= # +# This is similar to the second example, but now vectors are being rotated +# 20 degrees around the [1,1,1] axis on a stereographic plot. + +vec_ax = plt.subplot(2, 2, 4, projection="stereographic", hemisphere="upper") +ipf_colormap = DirectionColorKeyTSL(C1) + +# define a mesh of vectors with approximately 20 degree spacing, and +# within 80 degrees of the Z axis +vecs = sample_S2(20) +vecs = vecs[vecs.polar < (80 * np.pi / 180)] + +# define a 15 degree rotation around [1,1,1] +rots = Quaternion.from_axes_angles([1, 1, 1], [0, 15], degrees=True) + +for vec in vecs: + path_ends = rots * vec + # color each path using a gradient pased on the IPF coloring. + c = ipf_colormap.direction2color(vec) + if np.abs(path_ends.cross(path_ends[::-1])[0].norm) > 1e-12: + path = Vector3d.from_path_ends(path_ends, steps=100) + segment_c = c * np.linspace(0.25, 1, path.size)[:, np.newaxis] + vec_ax.scatter(path, c=segment_c) + else: + vec_ax.scatter(path_ends[0], c=c) + +vec_ax.set_title(r"Stereographic") +vec_ax.set_labels("X", "Y") +plt.tight_layout() diff --git a/orix/plot/__init__.py b/orix/plot/__init__.py index de90d82d4..b024b6cb2 100644 --- a/orix/plot/__init__.py +++ b/orix/plot/__init__.py @@ -29,7 +29,12 @@ import lazy_loader from orix.plot.crystal_map_plot import CrystalMapPlot -from orix.plot.rotation_plot import AxAnglePlot, RodriguesPlot, RotationPlot +from orix.plot.rotation_plot import ( + AxAnglePlot, + HomochoricPlot, + RodriguesPlot, + RotationPlot, +) from orix.plot.stereographic_plot import StereographicPlot # Must be imported below StereographicPlot since it imports it diff --git a/orix/plot/rotation_plot.py b/orix/plot/rotation_plot.py index 0ba8891e4..99a5583a7 100644 --- a/orix/plot/rotation_plot.py +++ b/orix/plot/rotation_plot.py @@ -24,7 +24,7 @@ import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D -from orix.vector import AxAngle, Rodrigues +from orix.vector import AxAngle, Homochoric, Rodrigues class RotationPlot(Axes3D): @@ -62,7 +62,7 @@ def transform( raise TypeError("fundamental_zone is not an OrientationRegion object.") # if any in xs are out of fundamental_zone, calculate symmetry reduction if not (xs < fundamental_zone).all(): - xs = xs.map_into_symmetry_reduced_zone() + xs = xs.reduce() if isinstance(xs, Rotation): if isinstance(xs, OrientationRegion): @@ -152,8 +152,16 @@ class AxAnglePlot(RotationPlot): transformation_class = AxAngle +class HomochoricPlot(RotationPlot): + """Plot rotations in a axis-angle space.""" + + name = "homochoric" + transformation_class = Homochoric + + projections.register_projection(RodriguesPlot) projections.register_projection(AxAnglePlot) +projections.register_projection(HomochoricPlot) def _setup_rotation_plot( @@ -161,7 +169,7 @@ def _setup_rotation_plot( projection: str = "axangle", position: Union[int, tuple, SubplotSpec, None] = (1, 1, 1), figure_kwargs: Optional[dict] = None, -) -> Tuple[plt.Figure, Union[AxAnglePlot, RodriguesPlot]]: +) -> Tuple[plt.Figure, Union[AxAnglePlot, RodriguesPlot, HomochoricPlot]]: """Return a figure and rotation plot axis of the correct type. This is a convenience method used in e.g. diff --git a/orix/quaternion/misorientation.py b/orix/quaternion/misorientation.py index 018d0d06c..456af7248 100644 --- a/orix/quaternion/misorientation.py +++ b/orix/quaternion/misorientation.py @@ -397,7 +397,7 @@ def map_into_symmetry_reduced_zone(self, verbose: bool = False) -> Misorientatio >>> data = np.array([[0.5, 0.5, 0.5, 0.5], [0, 1, 0, 0]]) >>> M = Misorientation(data) >>> M.symmetry = (C4, C2) - >>> M.map_into_symmetry_reduced_zone() + >>> M.reduce() Misorientation (2,) 4, 2 [[-0.7071 0.7071 0. 0. ] [ 0. 1. 0. 0. ]] @@ -479,7 +479,8 @@ def scatter( ---------- projection Which misorientation space to plot misorientations in, - either ``"axangle"`` (default) or ``"rodrigues"``. + either ``"axangle"`` (default), ``"rodrigues"``, or + ``"homochoric"``. figure If given, a new plot axis :class:`~orix.plot.AxAnglePlot` or :class:`~orix.plot.RodriguesPlot` is added to the figure in diff --git a/orix/quaternion/orientation.py b/orix/quaternion/orientation.py index d93b524d5..50049a035 100644 --- a/orix/quaternion/orientation.py +++ b/orix/quaternion/orientation.py @@ -102,7 +102,7 @@ def __sub__(self, other: Orientation) -> Misorientation: # Call to Object3d.squeeze() doesn't carry over symmetry M = Misorientation(self * ~other).squeeze() M.symmetry = (self.symmetry, other.symmetry) - return M.map_into_symmetry_reduced_zone() + return M.reduce() return NotImplemented # ------------------------ Class methods ------------------------- # @@ -772,8 +772,8 @@ def scatter( ---------- projection Which orientation space to plot orientations in, either - "axangle" (default), "rodrigues" or "ipf" (inverse pole - figure). + "axangle" (default), ``"rodrigues"``, ``"homochoric"``, + or ``"ipf"`` (inverse pole figure). figure If given, a new plot axis :class:`~orix.plot.AxAnglePlot` or :class:`~orix.plot.RodriguesPlot` is added to the figure in diff --git a/orix/tests/plot/test_rotation_plot.py b/orix/tests/plot/test_rotation_plot.py index 93a1cb749..93adc22b9 100644 --- a/orix/tests/plot/test_rotation_plot.py +++ b/orix/tests/plot/test_rotation_plot.py @@ -21,7 +21,7 @@ import numpy as np import pytest -from orix.plot import AxAnglePlot, RodriguesPlot, RotationPlot +from orix.plot import AxAnglePlot, HomochoricPlot, RodriguesPlot, RotationPlot from orix.quaternion import Misorientation, Orientation, OrientationRegion from orix.quaternion.symmetry import C1, D6 @@ -33,6 +33,13 @@ def test_creation(self): assert isinstance(ax, RodriguesPlot) +class TestHomochoricPlot: + def test_creation(self): + fig = plt.figure() + ax = fig.add_subplot(projection="homochoric") + assert isinstance(ax, HomochoricPlot) + + class TestAxisAnglePlot: def test_creation(self): fig = plt.figure() diff --git a/orix/tests/test_crystal_map.py b/orix/tests/test_crystal_map.py index f880ad395..9052ab0d0 100644 --- a/orix/tests/test_crystal_map.py +++ b/orix/tests/test_crystal_map.py @@ -674,6 +674,7 @@ def test_orientations_symmetry(self, point_group, rotation, expected_orientation o = xmap.orientations o = o.map_into_symmetry_reduced_zone() + o = o.reduce() o1 = Orientation(r) o1.symmetry = point_group From 3209fbc7e26795d3278a2b9c38553743aef9b66a Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 4 Sep 2025 19:36:44 -0600 Subject: [PATCH 23/23] Delete plot_non_euclidean_paths.py --- examples/plotting/plot_non_euclidean_paths.py | 115 ------------------ 1 file changed, 115 deletions(-) delete mode 100644 examples/plotting/plot_non_euclidean_paths.py diff --git a/examples/plotting/plot_non_euclidean_paths.py b/examples/plotting/plot_non_euclidean_paths.py deleted file mode 100644 index 2768c0a2a..000000000 --- a/examples/plotting/plot_non_euclidean_paths.py +++ /dev/null @@ -1,115 +0,0 @@ -r""" -======================================== -Plot Paths Through Non-Euclidean Spaces -======================================== - -This example shows three variations on how 'from_path_ends' can be -used to plot paths between points in rotational and vector spaces. - -This functionality is available in :class:`~orix.vector.Vector3d`, -:class:`~orix.quaternions.Rotation`, -:class:`~orix.quaternions.Orientation`, -and :class:`~orix.quaternions.Misorientation`. -""" - -import matplotlib.pyplot as plt -from matplotlib import cm -import numpy as np - -from orix.quaternion import Misorientation, Orientation, Rotation -from orix.quaternion.symmetry import D3, Oh -from orix.vector import Vector3d - -fig = plt.figure(figsize=(4, 8)) - -# ========= # -# Example 1: Plotting a path of rotations with no symmetry in homochoric space -# ========= # -rots_along_path = Rotation( - data=np.array( - [ - [1, 0, 0, 0], - [1, 0, 0, 1], - [1, 1, 1, 1], - ] - ) -) -n_steps = 20 -rotation_path = Rotation.from_path_ends(rots_along_path, steps=n_steps) -# create an Orientation loop using this path with no symmetry elements -ori_path = Orientation(rotation_path) -# plot the path in homochoric space -segment_colors = cm.inferno(np.linspace(0, 1, n_steps)) - -path_colors = np.vstack([segment_colors for x in range(rots_along_path.size - 1)]) -ori_path.scatter(figure=fig, position=[3, 1, 1], marker=">", c=path_colors) -fig.axes[0].set_title(r"$90^\circ$ rotation around X, then Y") - -# ========= # -# Example 2: Plotting the rotation of several orientations in m3m Rodrigues -# space around the z axis. -# ========= # -oris = Orientation( - data=np.array( - [ - [0.69, 0.24, 0.68, 0.01], - [0.26, 0.59, 0.32, 0.7], - [0.07, 0.17, 0.93, 0.31], - [0.6, 0.03, 0.61, 0.52], - [0.51, 0.38, 0.34, 0.69], - [0.31, 0.86, 0.22, 0.35], - [0.68, 0.67, 0.06, 0.31], - [0.01, 0.12, 0.05, 0.99], - [0.39, 0.45, 0.34, 0.72], - [0.65, 0.59, 0.46, 0.15], - ] - ), - symmetry=Oh, -).reduce() -# define a 20 degree rotation around the z axis -shift = Orientation.from_axes_angles([0, 0, 1], np.pi / 9) -segment_colors = cm.inferno(np.linspace(0, 1, 10)) - -ori_paths = [] -for ori in oris: - shifted = (shift * ori).reduce() - to_from = Orientation.stack([ori, shifted]).flatten() - ori_paths.append(Orientation.from_path_ends(to_from, steps=10)) -# plot a path in roddrigues space with m-3m (cubic) symmetry. -ori_path = Orientation.stack(ori_paths).flatten() -ori_path.symmetry = Oh -ori_path.scatter( - figure=fig, - position=[3, 1, 2], - marker=">", - c=np.tile(segment_colors, [10, 1]), - projection="rodrigues", -) -fig.axes[1].set_title(r"$20^{\circ}$ rotations around X-axis in m3m") - -# ========= # -# Example 3: creating a customized Wulf Plotting the rotation of several orientations in m3m Rodrigues -# space around the z axis. -# ========= # - - -# plot vectors -ax_upper = plt.subplot(3, 1, 3, projection="stereographic", hemisphere="upper") -r90x = Rotation.from_axes_angles([1, -1, -1], [0, 60], degrees=True) -x_axis_points = r90x * Vector3d.xvector() -y_axis_points = r90x * Vector3d.yvector() -z_axis_points = r90x * Vector3d.zvector() - -x_axis_path = Vector3d.from_path_ends(x_axis_points.unique()) -y_axis_path = Vector3d.from_path_ends(y_axis_points.unique()) -z_axis_path = Vector3d.from_path_ends(z_axis_points.unique()) -cx = cm.Reds(np.linspace(0.1, 1, x_axis_path.size)) -cy = cm.Greens(np.linspace(0.1, 1, y_axis_path.size)) -cz = cm.Blues(np.linspace(0.1, 1, z_axis_path.size)) - -spx = ax_upper.scatter(x_axis_path, figure=fig, marker=">", c=cx, label="X") -spy = ax_upper.scatter(y_axis_path, figure=fig, marker=">", c=cy, label="Y") -spz = ax_upper.scatter(z_axis_path, figure=fig, marker=">", c=cz, label="Z") -ax_upper.legend(loc="lower center", ncols=3) - -plt.tight_layout()