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/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/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 b5010328a..b024b6cb2 100644 --- a/orix/plot/__init__.py +++ b/orix/plot/__init__.py @@ -22,10 +22,25 @@ :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, + HomochoricPlot, + 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", ] 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 d5005865f..456af7248 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 @@ -265,6 +266,41 @@ 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 misorientations tracing the shortest path between + two or more consecutive points. + + Parameters + ---------- + points + 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 misorientations to return along the path + between each pair of waypoints. The default is 100. + + Returns + ------- + path + 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) + path._symmetry = points._symmetry + return path + @classmethod def random( cls, @@ -340,6 +376,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. @@ -360,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. ]] @@ -382,6 +419,48 @@ 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. + + 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", @@ -400,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 598208c75..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 ------------------------- # @@ -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 @@ -737,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/quaternion/quaternion.py b/orix/quaternion/quaternion.py index 099abf872..d11fa2b60 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -246,6 +246,12 @@ def __eq__(self, other: Any | Quaternion) -> bool: # ------------------------ Class methods ------------------------- # + @classmethod + def random(cls, shape: int | tuple = 1) -> Quaternion: + quat = super().random(shape) + quat.data[:, 0] = np.abs(quat.data[:, 0]) + return quat + @classmethod def from_axes_angles( cls, @@ -685,6 +691,49 @@ def from_align_vectors( return out[0] if len(out) == 1 else tuple(out) + @classmethod + 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. + + Parameters + ---------- + points + 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 + 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. + + Returns + ------- + path + quaternions that map a path between the given waypoints. + """ + 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 = 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 = cls(path_data) + 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/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/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index 4ea13b1d9..a259fc998 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 @@ -299,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)) @@ -318,6 +321,62 @@ 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]) + + # Quaternion sanity checks + a = Quaternion.from_path_ends(q) + assert isinstance(a, Quaternion) + 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) + + # 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: def test_get_distance_matrix(self): """Compute distance between every misorientation in an instance @@ -438,6 +497,11 @@ 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): + # 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)) M2 = ~M1 @@ -488,11 +552,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) @@ -504,17 +568,19 @@ 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.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) + 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 +667,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 +760,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 +769,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) @@ -805,6 +871,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 diff --git a/orix/tests/quaternion/test_quaternion.py b/orix/tests/quaternion/test_quaternion.py index 7f6be1d11..fea55e915 100644 --- a/orix/tests/quaternion/test_quaternion.py +++ b/orix/tests/quaternion/test_quaternion.py @@ -308,7 +308,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) @@ -330,6 +331,49 @@ 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, closed=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_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) @@ -410,7 +454,8 @@ def test_from_matrix(self): 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): 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