From a31bca194935d358b93f6fda1785467f0fb1fc9e Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:41:21 -0600 Subject: [PATCH 01/40] Correct C2v to match ITOC see chapter 12, as wel as figure 10.3.2 of International Tables of Crystallography, Volume A, for details on this discrepency, but The Cyclic rotation axis should be the z axis, not the x axis, in the standard form. --- orix/quaternion/symmetry.py | 85 +++++++-- orix/tests/quaternion/test_orientation.py | 43 +++-- .../quaternion/test_orientation_region.py | 10 +- orix/tests/quaternion/test_symmetry.py | 149 +++++++++++---- orix/tests/test_crystal_map.py | 143 +++++++++++---- orix/tests/test_miller.py | 171 +++++++++++++----- 6 files changed, 445 insertions(+), 156 deletions(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index ec3061eff..7658ffe6c 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -17,12 +17,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union from diffpy.structure.spacegroups import GetSpaceGroup import matplotlib.figure as mfigure import numpy as np +from orix._util import deprecated from orix.quaternion.rotation import Rotation from orix.vector import Vector3d @@ -168,7 +169,17 @@ def system(self) -> str | None: name = self.name if name in ["1", "-1"]: return "triclinic" - elif name in ["211", "121", "112", "2", "m11", "1m1", "11m", "m", "2/m"]: + elif name in [ + "211", + "121", + "112", + "2", + "m11", + "1m1", + "11m", + "m", + "2/m", + ]: return "monoclinic" elif name in ["222", "mm2", "mmm"]: return "orthorhombic" @@ -218,7 +229,8 @@ def fundamental_sector(self) -> "FundamentalSector": if self.size > 1 + n.size: angle = 2 * np.pi * (1 + n.size) / self.size new_v = Vector3d.from_polar( - azimuth=[np.pi / 2, angle - np.pi / 2], polar=[np.pi / 2, np.pi / 2] + azimuth=[np.pi / 2, angle - np.pi / 2], + polar=[np.pi / 2, np.pi / 2], ) n = Vector3d(np.vstack([n.data, new_v.data])) @@ -248,7 +260,9 @@ def fundamental_sector(self) -> "FundamentalSector": # Taken from MTEX center = Vector3d([0.707558, -0.000403, 0.706655]) elif name in ["m-3", "432"]: - n = Vector3d(np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data])) + n = Vector3d( + np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data]) + ) # Taken from MTEX center = Vector3d([0.349928, 0.348069, 0.869711]) elif name == "-43m": @@ -356,7 +370,9 @@ def __and__(self, other: Symmetry) -> Symmetry: return Symmetry.from_generators(*generators) def __hash__(self) -> int: - return hash(self.name.encode() + self.data.tobytes() + self.improper.tobytes()) + return hash( + self.name.encode() + self.data.tobytes() + self.improper.tobytes() + ) # ------------------------ Class methods ------------------------- # @@ -414,7 +430,9 @@ def get_axis_orders(self) -> dict[Vector3d, int]: return {} return { Vector3d(a): b + 1 - for a, b in zip(*np.unique(s.axis.data, axis=0, return_counts=True)) + for a, b in zip( + *np.unique(s.axis.data, axis=0, return_counts=True) + ) } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: @@ -534,9 +552,13 @@ def plot( # Triclinic C1 = Symmetry((1, 0, 0, 0)) C1.name = "1" -Ci = Symmetry([(1, 0, 0, 0), (1, 0, 0, 0)]) +Ci = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) Ci.improper = [0, 1] Ci.name = "-1" +# include redundant point group S2 == Ci +S2 = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) +S2.improper = [0, 1] +S2.name = "-1" # Special generators _mirror_xy = Symmetry([(1, 0, 0, 0), (0, 0.75**0.5, -(0.75**0.5), 0)]) @@ -552,6 +574,9 @@ def plot( C2z.name = "112" C2 = Symmetry(C2z) C2.name = "2" +# included redundant point group D1 == C2 +D1 = Symmetry(C2z) +D1.name = "2" # Mirrors Csx = Symmetry([(1, 0, 0, 0), (0, 1, 0, 0)]) @@ -573,7 +598,7 @@ def plot( # Orthorhombic D2 = Symmetry.from_generators(C2z, C2x, C2y) D2.name = "222" -C2v = Symmetry.from_generators(C2x, Csz) +C2v = Symmetry.from_generators(C2z, Csx) C2v.name = "mm2" D2h = Symmetry.from_generators(Csz, Csx, Csy) D2h.name = "mmm" @@ -584,7 +609,7 @@ def plot( (1, 0, 0, 0), (0.5**0.5, 0.5**0.5, 0, 0), (0, 1, 0, 0), - (-(0.5**0.5), 0.5**0.5, 0, 0), + ((0.5**0.5), -(0.5**0.5), 0, 0), ] ) C4y = Symmetry( @@ -592,7 +617,7 @@ def plot( (1, 0, 0, 0), (0.5**0.5, 0, 0.5**0.5, 0), (0, 0, 1, 0), - (-(0.5**0.5), 0, 0.5**0.5, 0), + ((0.5**0.5), -0, 0.5**0.5, 0), ] ) C4z = Symmetry( @@ -600,7 +625,7 @@ def plot( (1, 0, 0, 0), (0.5**0.5, 0, 0, 0.5**0.5), (0, 0, 0, 1), - (-(0.5**0.5), 0, 0, 0.5**0.5), + ((0.5**0.5), 0, 0, -(0.5**0.5)), ] ) C4 = Symmetry(C4z) @@ -610,6 +635,10 @@ def plot( S4 = Symmetry(C4) S4.improper = [0, 1, 0, 1] S4.name = "-4" +# include redundant point group C4i == S4 +C4i = Symmetry(C4) +C4i.improper = [0, 1, 0, 1] +C4i.name = "-4" C4h = Symmetry.from_generators(C4, Cs) C4h.name = "4/m" D4 = Symmetry.from_generators(C4, C2x, C2y) @@ -622,13 +651,22 @@ def plot( D4h.name = "4/mmm" # 3-fold rotations -C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (-0.5, 0.75**0.5, 0, 0)]) -C3y = Symmetry([(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (-0.5, 0, 0.75**0.5, 0)]) -C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (-0.5, 0, 0, 0.75**0.5)]) +C3x = Symmetry( + [(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)] +) +C3y = Symmetry( + [(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)] +) +C3z = Symmetry( + [(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))] +) C3 = Symmetry(C3z) C3.name = "3" # Trigonal +C3i = Symmetry.from_generators(C3, Ci) +C3i.name = "-3" +# include redundant point group S6==C3i S6 = Symmetry.from_generators(C3, Ci) S6.name = "-3" D3x = Symmetry.from_generators(C3, C2x) @@ -714,7 +752,24 @@ def plot( Oh, # Cubic m-3m m-3m 432 ] # fmt: on -_proper_groups = [C1, C2, C2x, C2y, C2z, D2, C4, D4, C3, D3x, D3y, D3, C6, D6, T, O] +_proper_groups = [ + C1, + C2, + C2x, + C2y, + C2z, + D2, + C4, + D4, + C3, + D3x, + D3y, + D3, + C6, + D6, + T, + O, +] def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index f02424500..a8e87ee9b 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -89,11 +89,11 @@ def test_quaternion_subclasses_copy_constructor_casting(): # 7pi/12 -C2-> # 7pi/12 ([(0.6088, 0, 0, 0.7934)], C2, [(-0.7934, 0, 0, 0.6088)]), # 7pi/12 -C3-> # 7pi/12 - ([(0.6088, 0, 0, 0.7934)], C3, [(-0.9914, 0, 0, 0.1305)]), + ([(0.6088, 0, 0, 0.7934)], C3, [(0.9914, 0, 0, -0.1305)]), # 7pi/12 -C4-> # pi/12 - ([(0.6088, 0, 0, 0.7934)], C4, [(-0.9914, 0, 0, -0.1305)]), + ([(0.6088, 0, 0, 0.7934)], C4, [(0.9914, 0, 0, 0.1305)]), # 7pi/12 -O-> # pi/12 - ([(0.6088, 0, 0, 0.7934)], O, [(-0.9914, 0, 0, -0.1305)]), + ([(0.6088, 0, 0, 0.7934)], O, [(0.9914, 0, 0, 0.1305)]), ], indirect=["orientation"], ) @@ -297,7 +297,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)) @@ -309,7 +310,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 @@ -502,13 +505,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.map_into_symmetry_reduced_zone() 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) @@ -595,7 +600,9 @@ def test_from_scipy_rotation(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) @@ -622,11 +629,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) @@ -673,7 +684,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) @@ -708,7 +721,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 @@ -801,7 +816,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_orientation_region.py b/orix/tests/quaternion/test_orientation_region.py index e377cd40c..8a3c0bbf9 100644 --- a/orix/tests/quaternion/test_orientation_region.py +++ b/orix/tests/quaternion/test_orientation_region.py @@ -35,8 +35,8 @@ [ [0.5, 0, 0, 0.866], [-0.5, 0, 0, -0.866], - [-0.5, 0, 0, 0.866], [0.5, 0, 0, -0.866], + [-0.5, 0, 0, 0.866], ], ), ( @@ -45,14 +45,14 @@ [ [0.5, 0.0, 0.0, 0.866], [-0.5, 0.0, 0.0, -0.866], - [-0.5, 0.0, 0.0, 0.866], - [0.5, -0.0, -0.0, -0.866], + [0.5, 0.0, 0.0, -0.866], + [-0.5, -0.0, -0.0, 0.866], [0.0, 1.0, 0.0, 0.0], [-0.0, -1.0, -0.0, -0.0], [0.0, 0.5, 0.866, 0.0], - [-0.0, -0.5, -0.866, 0.0], - [0.0, -0.5, 0.866, 0.0], + [-0.0, -0.5, -0.866, -0.0], [0.0, 0.5, -0.866, 0.0], + [-0.0, -0.5, 0.866, -0.0], ], ), ], diff --git a/orix/tests/quaternion/test_symmetry.py b/orix/tests/quaternion/test_symmetry.py index bd7ac463a..a92ccdc4e 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -64,9 +64,9 @@ def all_symmetries(request): (1, 2, 3), [ (1, 2, 3), + (-1, -2, 3), + (-1, 2, 3), (1, -2, 3), - (1, -2, -3), - (1, 2, -3), ], ), ( @@ -277,7 +277,7 @@ def test_is_proper(symmetry, expected): [ (C1, [C1]), (D2, [C1, C2x, C2y, C2z, D2]), - (C6v, [C1, Csx, Csy, C2z, C3, C3v, C6, C6v]), + (C6v, [C1, C2z, Csx, Csy, C2v, C3, C3v, C6, C6v]), ], ) def test_subgroups(symmetry, expected): @@ -305,7 +305,7 @@ def test_proper_subgroups(symmetry, expected): (Cs, C1), (C2h, C2), (D2, D2), - (C2v, C2x), + (C2v, C2z), (C4, C4), (C4h, C4), (C3h, C3), @@ -444,8 +444,13 @@ def test_get_point_group(): sg = GetSpaceGroup(sg_number) pg = get_point_group(sg_number, proper=False) - assert proper_pg == spacegroup2pointgroup_dict[sg.point_group_name]["proper"] - assert pg == spacegroup2pointgroup_dict[sg.point_group_name]["improper"] + assert ( + proper_pg + == spacegroup2pointgroup_dict[sg.point_group_name]["proper"] + ) + assert ( + pg == spacegroup2pointgroup_dict[sg.point_group_name]["improper"] + ) def test_unique_symmetry_elements_subgroups(all_symmetries): @@ -537,7 +542,9 @@ def test_symmetry_plot(pg): @pytest.mark.parametrize("symmetry", [C1, C4, Oh]) def test_symmetry_plot_raises(symmetry): - with pytest.raises(TypeError, match="Orientation must be a Rotation instance"): + with pytest.raises( + TypeError, match="Orientation must be a Rotation instance" + ): _ = symmetry.plot(return_figure=True, orientation="test") @@ -595,9 +602,9 @@ def test_fundamental_sector_d2(self): def test_fundamental_sector_c2v(self): pg = C2v # mm2 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0]]) - assert np.allclose(fs.vertices.data, [[1, 0, 0], [-1, 0, 0]]) - assert np.allclose(fs.center.data, [[0, 0.5, 0.5]]) + assert np.allclose(fs.data, [[1, 0, 0], [0, 1, 0]]) + assert np.allclose(fs.vertices.data, [[0, 0, -1], [0, 0, 1]]) + assert np.allclose(fs.center.data, [[0.5, 0.5, 0]]) def test_fundamental_sector_d2h(self): pg = D2h # mmm @@ -637,7 +644,9 @@ def test_fundamental_sector_d4(self): def test_fundamental_sector_c4v(self): pg = C4v # 4mm fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 1, 0], [0.7071, -0.7071, 0]], atol=1e-4) + assert np.allclose( + fs.data, [[0, 1, 0], [0.7071, -0.7071, 0]], atol=1e-4 + ) assert np.allclose(fs.vertices.data, [[0, 0, 1], [0, 0, -1]]) assert np.allclose(fs.center.data, [[0.3536, 0.1464, 0]], atol=1e-4) @@ -645,10 +654,13 @@ def test_fundamental_sector_d2d(self): pg = D2d # -42m fs = pg.fundamental_sector assert np.allclose( - fs.data, [[0, 0, 1], [0.7071, 0.7071, 0], [0.7071, -0.7071, 0]], atol=1e-4 + fs.data, + [[0, 0, 1], [0.7071, 0.7071, 0], [0.7071, -0.7071, 0]], + atol=1e-4, ) assert np.allclose( - fs.vertices.data, [[0.7071, -0.7071, 0], [0, 0, 1], [0.7071, 0.7071, 0]] + fs.vertices.data, + [[0.7071, -0.7071, 0], [0, 0, 1], [0.7071, 0.7071, 0]], ) assert np.allclose(fs.center.data, [[0.4714, 0, 1 / 3]], atol=1e-4) @@ -659,7 +671,9 @@ def test_fundamental_sector_d4h(self): fs.data, [[0, 0, 1], [0, 1, 0], [0.7071, -0.7071, 0]], atol=1e-4 ) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.7071, 0.7071, 0]], atol=1e-4 + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [0.7071, 0.7071, 0]], + atol=1e-4, ) assert np.allclose(fs.center.data, [[0.569, 0.2357, 1 / 3]], atol=1e-3) @@ -673,25 +687,35 @@ def test_fundamental_sector_c3(self): def test_fundamental_sector_s6(self): pg = S6 # -3 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], atol=1e-4 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], + atol=1e-4, ) assert np.allclose(fs.center.data, [[1 / 6, 0.2887, 1 / 3]], atol=1e-4) def test_fundamental_sector_d3(self): pg = D3 # 32 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], atol=1e-4 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], + atol=1e-4, ) assert np.allclose(fs.center.data, [[1 / 6, 0.2887, 1 / 3]], atol=1e-4) def test_fundamental_sector_c3v(self): pg = C3v # 3m fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0.5, 0.866, 0], [0.5, -0.866, 0]], atol=1e-3) + assert np.allclose( + fs.data, [[0.5, 0.866, 0], [0.5, -0.866, 0]], atol=1e-3 + ) assert np.allclose(fs.vertices.data, [[0, 0, 1], [0, 0, -1]]) assert np.allclose(fs.center.data, [[0.5, 0, 0]]) @@ -702,7 +726,9 @@ def test_fundamental_sector_d3d(self): fs.data, [[0, 0, 1], [0.5, 0.866, 0], [0.5, -0.866, 0]], atol=1e-3 ) assert np.allclose( - fs.vertices.data, [[0.866, -0.5, 0], [0, 0, 1], [0.866, 0.5, 0]], atol=1e-3 + fs.vertices.data, + [[0.866, -0.5, 0], [0, 0, 1], [0.866, 0.5, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[0.577, 0, 1 / 3]], atol=1e-3) @@ -716,27 +742,39 @@ def test_fundamental_sector_c6(self): def test_fundamental_sector_c3h(self): pg = C3h # -6 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], atol=1e-3 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[1 / 6, 0.2887, 1 / 3]], atol=1e-4) def test_fundamental_sector_c6h(self): pg = C6h # 6/m fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], atol=1e-3 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[0.5, 0.2887, 1 / 3]], atol=1e-4) def test_fundamental_sector_d6(self): pg = D6 # 622 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], atol=1e-3 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[0.5, 0.2887, 1 / 3]], atol=1e-4) @@ -750,31 +788,48 @@ def test_fundamental_sector_c6v(self): def test_fundamental_sector_d3h(self): pg = D3h # -6m2 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], atol=1e-3 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[0.5, 0.2887, 1 / 3]], atol=1e-4) def test_fundamental_sector_d6h(self): pg = D6h # 6/mmm fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.5, -0.866, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.866, 0.5, 0]], atol=1e-3 + fs.data, [[0, 0, 1], [0, 1, 0], [0.5, -0.866, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [0.866, 0.5, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[0.622, 0.1667, 1 / 3]], atol=1e-4) def test_fundamental_sector_t(self): pg = T # 23 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[1, 1, 0], [1, -1, 0], [0, -1, 1], [0, 1, 1]]) + assert np.allclose( + fs.data, [[1, 1, 0], [1, -1, 0], [0, -1, 1], [0, 1, 1]] + ) assert np.allclose( fs.vertices.data, - [[0, 0, 1], [0.5774, 0.5774, 0.5774], [1, 0, 0], [0.5774, -0.5774, 0.5774]], + [ + [0, 0, 1], + [0.5774, 0.5774, 0.5774], + [1, 0, 0], + [0.5774, -0.5774, 0.5774], + ], atol=1e-4, ) - assert np.allclose(fs.center.data, [[0.7076, -0.0004, 0.7067]], atol=1e-4) + assert np.allclose( + fs.center.data, [[0.7076, -0.0004, 0.7067]], atol=1e-4 + ) def test_fundamental_sector_th(self): pg = Th # m-3 @@ -793,7 +848,9 @@ def test_fundamental_sector_th(self): ], atol=1e-3, ) - assert np.allclose(fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4) + assert np.allclose( + fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4 + ) def test_fundamental_sector_o(self): pg = O # 432 @@ -811,7 +868,9 @@ def test_fundamental_sector_o(self): ], atol=1e-3, ) - assert np.allclose(fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4) + assert np.allclose( + fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4 + ) def test_fundamental_sector_td(self): pg = Td # -43m @@ -833,7 +892,9 @@ def test_fundamental_sector_oh(self): [[0.5774, 0.5774, 0.5774], [0.7071, 0, 0.7071], [0, 0, 1]], atol=1e-4, ) - assert np.allclose(fs.center.data, [[0.4282, 0.1925, 0.7615]], atol=1e-4) + assert np.allclose( + fs.center.data, [[0.4282, 0.1925, 0.7615]], atol=1e-4 + ) # ---------- End of the 32 crystallographic point groups --------- # @@ -946,10 +1007,16 @@ def test_primary_axis_order(self): def test_special_rotation(self): for pg in [C1, C2z, C3, C4, C6]: assert np.allclose(pg._special_rotation.data, (1, 0, 0, 0)) - assert np.allclose(C2x._special_rotation.data, ((1, 0, 0, 0), (0, 1, 0, 0))) - assert np.allclose(C2y._special_rotation.data, ((1, 0, 0, 0), (0, 0, 1, 0))) + assert np.allclose( + C2x._special_rotation.data, ((1, 0, 0, 0), (0, 1, 0, 0)) + ) + assert np.allclose( + C2y._special_rotation.data, ((1, 0, 0, 0), (0, 0, 1, 0)) + ) for pg in [D2, D4, D6, D3]: - assert np.allclose(pg._special_rotation.data, ((1, 0, 0, 0), (0, -1, 0, 0))) + assert np.allclose( + pg._special_rotation.data, ((1, 0, 0, 0), (0, -1, 0, 0)) + ) assert np.allclose( D3y._special_rotation.data, ((1, 0, 0, 0), (0, -1 / np.sqrt(2), 1 / np.sqrt(2), 0)), @@ -978,7 +1045,9 @@ def test_special_rotation(self): ) unrecognized_symmetry = Symmetry.random(4) - assert np.allclose(unrecognized_symmetry._special_rotation.data, (1, 0, 0, 0)) + assert np.allclose( + unrecognized_symmetry._special_rotation.data, (1, 0, 0, 0) + ) # All point groups provide at least one rotation for pg in _groups: diff --git a/orix/tests/test_crystal_map.py b/orix/tests/test_crystal_map.py index b8b4a38d6..c7d773048 100644 --- a/orix/tests/test_crystal_map.py +++ b/orix/tests/test_crystal_map.py @@ -20,7 +20,12 @@ import numpy as np import pytest -from orix.crystal_map import CrystalMap, Phase, PhaseList, create_coordinate_arrays +from orix.crystal_map import ( + CrystalMap, + Phase, + PhaseList, + create_coordinate_arrays, +) from orix.crystal_map.crystal_map import _data_slices_from_coordinates from orix.plot import CrystalMapPlot from orix.quaternion import Orientation, Rotation @@ -169,7 +174,9 @@ def test_init_with_phase_list(self, crystal_map_input, expected_presence): ): assert (p1 == p2) == expected - unique_phase_ids = list(np.unique(crystal_map_input["phase_id"]).astype(int)) + unique_phase_ids = list( + np.unique(crystal_map_input["phase_id"]).astype(int) + ) assert xmap.phases.ids == unique_phase_ids def test_init_with_sparse_phase_list(self): @@ -207,8 +214,18 @@ def test_init_with_single_point_group(self, crystal_map_input): "crystal_map_input, phase_names, phase_ids, desired_phase_names", [ (((7, 4), (1, 1), 1, [0]), ["a", "b", "c"], [0, 1, 2], ["a"]), - (((7, 4), (1, 1), 1, [0, 1]), ["a", "b", "c"], [0, 2, 1], ["a", "c"]), - (((7, 4), (1, 1), 1, [0, 2]), ["a", "b", "c"], [0, 2, 1], ["a", "b"]), + ( + ((7, 4), (1, 1), 1, [0, 1]), + ["a", "b", "c"], + [0, 2, 1], + ["a", "c"], + ), + ( + ((7, 4), (1, 1), 1, [0, 2]), + ["a", "b", "c"], + [0, 2, 1], + ["a", "b"], + ), (((7, 4), (1, 1), 1, [3]), ["a", "b", "c"], [0, 2, 1], ["a"]), ], indirect=["crystal_map_input"], @@ -293,21 +310,29 @@ def test_is_in_data(self, crystal_map_input): # All points in data xmap = CrystalMap(is_in_data=is_in_data.flatten(), **crystal_map_input) assert xmap.shape == xmap._original_shape == map_shape - assert np.allclose(xmap.get_map_data("phase_id"), np.zeros(is_in_data.shape)) + assert np.allclose( + xmap.get_map_data("phase_id"), np.zeros(is_in_data.shape) + ) # First row not in data is_in_data1 = is_in_data.copy() is_in_data1[0, :] = False - xmap1 = CrystalMap(is_in_data=is_in_data1.flatten(), **crystal_map_input) + xmap1 = CrystalMap( + is_in_data=is_in_data1.flatten(), **crystal_map_input + ) new_shape1 = (map_shape[0] - 1, map_shape[1]) assert xmap1.shape == new_shape1 assert xmap1._original_shape == map_shape - assert np.allclose(xmap1.get_map_data("phase_id"), np.zeros(new_shape1)) + assert np.allclose( + xmap1.get_map_data("phase_id"), np.zeros(new_shape1) + ) # Second row not in data is_in_data2 = is_in_data.copy() is_in_data2[1, :] = False - xmap2 = CrystalMap(is_in_data=is_in_data2.flatten(), **crystal_map_input) + xmap2 = CrystalMap( + is_in_data=is_in_data2.flatten(), **crystal_map_input + ) assert xmap2.shape == xmap2._original_shape == map_shape desired_phase_id = np.zeros(is_in_data.shape) desired_phase_id[1, :] = np.nan @@ -318,11 +343,15 @@ def test_is_in_data(self, crystal_map_input): # Last column not in data is_in_data3 = is_in_data.copy() is_in_data3[:, -1] = False - xmap3 = CrystalMap(is_in_data=is_in_data3.flatten(), **crystal_map_input) + xmap3 = CrystalMap( + is_in_data=is_in_data3.flatten(), **crystal_map_input + ) new_shape3 = (map_shape[0], map_shape[1] - 1) assert xmap3.shape == new_shape3 assert xmap3._original_shape == map_shape - assert np.allclose(xmap3.get_map_data("phase_id"), np.zeros(new_shape3)) + assert np.allclose( + xmap3.get_map_data("phase_id"), np.zeros(new_shape3) + ) def test_set_phases(self, phase_list): assert phase_list.size == 3 @@ -334,7 +363,9 @@ def test_set_phases(self, phase_list): # Not OK xmap._phase_id = np.array([0, 1, 2, 1, 2, 3]) - with pytest.raises(ValueError, match="There must be at least as many phases "): + with pytest.raises( + ValueError, match="There must be at least as many phases " + ): xmap.phases = phase_list @@ -366,7 +397,9 @@ class TestCrystalMapGetItem: ], indirect=["crystal_map_input"], ) - def test_get_by_slice(self, crystal_map_input, slice_tuple, expected_shape): + def test_get_by_slice( + self, crystal_map_input, slice_tuple, expected_shape + ): xmap = CrystalMap(**crystal_map_input) xmap2 = xmap[slice_tuple] @@ -383,7 +416,9 @@ def test_get_by_phase_name(self, crystal_map_input, phase_list): # Get number of points with each phase ID n_points_per_phase = {} for phase_i, phase in phase_list: - n_points_per_phase[phase.name] = len(np.where(phase_ids == phase_i)[0]) + n_points_per_phase[phase.name] = len( + np.where(phase_ids == phase_i)[0] + ) crystal_map_input.pop("phase_id") xmap = CrystalMap(phase_id=phase_ids, **crystal_map_input) @@ -467,7 +502,9 @@ def test_get_by_integer( point = xmap[integer_slices] expected_point = xmap[xmap.id == expected_id] - assert np.allclose(point.rotations.data, expected_point.rotations.data) + assert np.allclose( + point.rotations.data, expected_point.rotations.data + ) assert point._coordinates == expected_point._coordinates def test_get_twice(self): @@ -587,11 +624,17 @@ def test_set_phase_ids(self, crystal_map, set_phase_id): assert "not_indexed" in xmap.phases.names def test_set_phase_ids_raises(self, crystal_map): - with pytest.raises(ValueError, match="NumPy boolean array indexing assignment"): + with pytest.raises( + ValueError, match="NumPy boolean array indexing assignment" + ): crystal_map[1, 1].phase_id = -1 * np.ones(10) - @pytest.mark.parametrize("set_phase_id, index_error", [(-1, False), (1, True)]) - def test_set_phase_id_with_unknown_id(self, crystal_map, set_phase_id, index_error): + @pytest.mark.parametrize( + "set_phase_id, index_error", [(-1, False), (1, True)] + ) + def test_set_phase_id_with_unknown_id( + self, crystal_map, set_phase_id, index_error + ): xmap = crystal_map condition = xmap.x > 1.5 @@ -625,7 +668,9 @@ def test_phases_in_data(self, crystal_map, phase_list): ) condition1 = xmap.x > 1.5 condition2 = xmap.y > 1.5 - for new_id, condition in zip(ids_not_in_data, [condition1, condition2]): + for new_id, condition in zip( + ids_not_in_data, [condition1, condition2] + ): xmap[condition].phase_id = new_id assert xmap.phases_in_data.names == xmap.phases.names @@ -660,12 +705,14 @@ def test_orientations(self, crystal_map_input, phase_list): "point_group, rotation, expected_orientation", [ (C2, [(0.6088, 0, 0, 0.7934)], [(-0.7934, 0, 0, 0.6088)]), - (C3, [(0.6088, 0, 0, 0.7934)], [(-0.9914, 0, 0, 0.1305)]), - (C4, [(0.6088, 0, 0, 0.7934)], [(-0.9914, 0, 0, -0.1305)]), - (O, [(0.6088, 0, 0, 0.7934)], [(-0.9914, 0, 0, -0.1305)]), + (C3, [(0.6088, 0, 0, 0.7934)], [(0.9914, 0, 0, -0.1305)]), + (C4, [(0.6088, 0, 0, 0.7934)], [(0.9914, 0, 0, 0.1305)]), + (O, [(0.6088, 0, 0, 0.7934)], [(0.9914, 0, 0, 0.1305)]), ], ) - def test_orientations_symmetry(self, point_group, rotation, expected_orientation): + def test_orientations_symmetry( + self, point_group, rotation, expected_orientation + ): r = Rotation(rotation) xmap = CrystalMap(rotations=r, phase_id=np.array([0])) xmap.phases = PhaseList(Phase("a", point_group=point_group)) @@ -686,7 +733,9 @@ def test_orientations_none_symmetry_raises(self, crystal_map_input): with pytest.raises(TypeError, match="Value must be an instance of"): _ = xmap.orientations - def test_orientations_multiple_phases_raises(self, crystal_map, phase_list): + def test_orientations_multiple_phases_raises( + self, crystal_map, phase_list + ): xmap = crystal_map xmap.phases = phase_list @@ -735,7 +784,9 @@ def test_overwrite_crystal_map_property_values(self, crystal_map): new_prop_value = -1 xmap.__setattr__(prop_name, new_prop_value) - assert np.allclose(xmap.prop[prop_name], np.ones(xmap.size) * new_prop_value) + assert np.allclose( + xmap.prop[prop_name], np.ones(xmap.size) * new_prop_value + ) class TestCrystalMapMasking: @@ -762,12 +813,16 @@ class TestCrystalMapGetMapData: ( ((4, 4), (0.5, 1), 2, [0]), "y", - np.array([[i * 0.5] * 4 for i in range(4)]), # [0, 0, 0, 0, 0.5, ...] + np.array( + [[i * 0.5] * 4 for i in range(4)] + ), # [0, 0, 0, 0, 0.5, ...] ), ], indirect=["crystal_map_input"], ) - def test_get_coordinate_array(self, crystal_map_input, to_get, expected_array): + def test_get_coordinate_array( + self, crystal_map_input, to_get, expected_array + ): xmap = CrystalMap(**crystal_map_input) # Get via string @@ -900,7 +955,9 @@ def test_get_boolean_array(self, crystal_map): assert np.issubdtype(xmap.get_map_data("is_indexed").dtype, bool) assert np.issubdtype(xmap.get_map_data(xmap.is_in_data).dtype, bool) - @pytest.mark.parametrize("dtype_in", [np.uint8, int, np.float32, float, bool]) + @pytest.mark.parametrize( + "dtype_in", [np.uint8, int, np.float32, float, bool] + ) def test_preserve_dtype(self, crystal_map, dtype_in): xmap = crystal_map prop_name = "new_prop" @@ -966,11 +1023,15 @@ def test_representation(self, crystal_map, phase_list): class TestCrystalMapCopying: def test_shallowcopy_crystal_map(self, crystal_map): - xmap2 = crystal_map[:] # Everything except `is_in_data` is shallow copied + xmap2 = crystal_map[ + : + ] # Everything except `is_in_data` is shallow copied xmap3 = crystal_map # These are the same objects (of course) assert np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) - assert np.may_share_memory(xmap2._rotations.data, crystal_map._rotations.data) + assert np.may_share_memory( + xmap2._rotations.data, crystal_map._rotations.data + ) crystal_map[3, 0].phase_id = -2 assert np.allclose(xmap2.phase_id, crystal_map.phase_id) @@ -988,7 +1049,10 @@ def test_deepcopy_crystal_map(self, crystal_map): crystal_map[3, 0].phase_id = -2 assert np.allclose(xmap2.phase_id, crystal_map.phase_id) is False - assert np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) is False + assert ( + np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) + is False + ) class TestCrystalMapShape: @@ -1015,7 +1079,9 @@ class TestCrystalMapShape: ], indirect=["crystal_map_input"], ) - def test_data_slices_from_coordinates(self, crystal_map_input, expected_slices): + def test_data_slices_from_coordinates( + self, crystal_map_input, expected_slices + ): xmap = CrystalMap(**crystal_map_input) assert xmap._data_slices_from_coordinates() == expected_slices @@ -1042,7 +1108,12 @@ def test_data_slices_from_coordinates(self, crystal_map_input, expected_slices): indirect=["crystal_map_input"], ) def test_data_slice_from_coordinates_masked( - self, crystal_map_input, slices, expected_size, expected_shape, expected_slices + self, + crystal_map_input, + slices, + expected_size, + expected_shape, + expected_slices, ): xmap = CrystalMap(**crystal_map_input) @@ -1092,7 +1163,9 @@ def test_rotation_shape(self, crystal_map_input, expected_shape): ], indirect=["crystal_map_input"], ) - def test_coordinate_axes(self, crystal_map_input, expected_coordinate_axes): + def test_coordinate_axes( + self, crystal_map_input, expected_coordinate_axes + ): xmap = CrystalMap(**crystal_map_input) assert xmap._coordinate_axes == expected_coordinate_axes @@ -1135,5 +1208,7 @@ def test_plot(self, crystal_map): class TestCreateCoordinateArrays: def test_create_coordinate_arrays_raises(self): - with pytest.raises(ValueError, match="Can only create coordinate arrays for "): + with pytest.raises( + ValueError, match="Can only create coordinate arrays for " + ): _ = create_coordinate_arrays((1, 2, 3)) diff --git a/orix/tests/test_miller.py b/orix/tests/test_miller.py index d16969666..6bc7a5b91 100644 --- a/orix/tests/test_miller.py +++ b/orix/tests/test_miller.py @@ -22,10 +22,16 @@ from orix.crystal_map import Phase from orix.quaternion import Orientation, symmetry from orix.vector import Miller -from orix.vector.miller import _round_indices, _transform_space, _UVTW2uvw, _uvw2UVTW +from orix.vector.miller import ( + _round_indices, + _transform_space, + _UVTW2uvw, + _uvw2UVTW, +) TRIGONAL_PHASE = Phase( - point_group="321", structure=Structure(lattice=Lattice(4.9, 4.9, 5.4, 90, 90, 120)) + point_group="321", + structure=Structure(lattice=Lattice(4.9, 4.9, 5.4, 90, 90, 120)), ) TETRAGONAL_LATTICE = Lattice(0.5, 0.5, 1, 90, 90, 90) TETRAGONAL_PHASE = Phase( @@ -43,17 +49,26 @@ def test_init_raises(self): with pytest.raises(ValueError, match="Exactly *"): _ = Miller(phase=Phase(point_group="m-3m")) with pytest.raises(ValueError, match="Exactly *"): - _ = Miller(xyz=[0, 1, 2], hkl=[3, 4, 5], phase=Phase(point_group="m-3m")) - with pytest.raises(ValueError, match="A phase with a crystal lattice and "): + _ = Miller( + xyz=[0, 1, 2], hkl=[3, 4, 5], phase=Phase(point_group="m-3m") + ) + with pytest.raises( + ValueError, match="A phase with a crystal lattice and " + ): _ = Miller(hkl=[1, 1, 1]) def test_repr(self): m = Miller(hkil=[1, 1, -2, 0], phase=TRIGONAL_PHASE) - assert repr(m) == "Miller (1,), point group 321, hkil\n" "[[ 1. 1. -2. 0.]]" + assert ( + repr(m) == "Miller (1,), point group 321, hkil\n" + "[[ 1. 1. -2. 0.]]" + ) def test_coordinate_format_raises(self): m = Miller(hkl=[1, 1, 1], phase=TETRAGONAL_PHASE) - with pytest.raises(ValueError, match="Available coordinate formats are "): + with pytest.raises( + ValueError, match="Available coordinate formats are " + ): m.coordinate_format = "abc" def test_set_coordinates(self): @@ -78,11 +93,19 @@ def test_set_hkl_hkil(self): assert np.allclose([m1.h, m1.k, m1.l], [[1, 1], [2, 1], [0, 0]]) m2 = Miller(hkil=[[1, 1, -2, 1], [2, 0, -2, 0]], phase=TRIGONAL_PHASE) - assert np.allclose(m2.data, [[0.20, 0.35, 0.19], [0.41, 0.24, 0]], atol=0.01) - assert np.allclose([m2.h, m2.k, m2.i, m2.l], [[1, 2], [1, 0], [-2, -2], [1, 0]]) + assert np.allclose( + m2.data, [[0.20, 0.35, 0.19], [0.41, 0.24, 0]], atol=0.01 + ) + assert np.allclose( + [m2.h, m2.k, m2.i, m2.l], [[1, 2], [1, 0], [-2, -2], [1, 0]] + ) m2.hkil = [[1, 2, -3, 1], [2, 1, -3, 1]] - assert np.allclose(m2.data, [[0.20, 0.59, 0.19], [0.41, 0.47, 0.19]], atol=0.01) - assert np.allclose([m2.h, m2.k, m2.i, m2.l], [[1, 2], [2, 1], [-3, -3], [1, 1]]) + assert np.allclose( + m2.data, [[0.20, 0.59, 0.19], [0.41, 0.47, 0.19]], atol=0.01 + ) + assert np.allclose( + [m2.h, m2.k, m2.i, m2.l], [[1, 2], [2, 1], [-3, -3], [1, 1]] + ) def test_set_uvw_UVTW(self): m1 = Miller(uvw=[[1, 1, 1], [2, 0, 0]], phase=TETRAGONAL_PHASE) @@ -93,11 +116,19 @@ def test_set_uvw_UVTW(self): assert np.allclose([m1.u, m1.v, m1.w], [[1, 1], [2, 1], [0, 0]]) m2 = Miller(UVTW=[[1, 1, -2, 1], [2, 0, -2, 0]], phase=TRIGONAL_PHASE) - assert np.allclose(m2.data, [[7.35, 12.73, 5.4], [14.7, 8.49, 0]], atol=0.01) - assert np.allclose([m2.U, m2.V, m2.T, m2.W], [[1, 2], [1, 0], [-2, -2], [1, 0]]) + assert np.allclose( + m2.data, [[7.35, 12.73, 5.4], [14.7, 8.49, 0]], atol=0.01 + ) + assert np.allclose( + [m2.U, m2.V, m2.T, m2.W], [[1, 2], [1, 0], [-2, -2], [1, 0]] + ) m2.UVTW = [[1, 2, -3, 1], [2, 1, -3, 1]] - assert np.allclose(m2.data, [[7.35, 21.22, 5.4], [14.7, 16.97, 5.4]], atol=0.01) - assert np.allclose([m2.U, m2.V, m2.T, m2.W], [[1, 2], [2, 1], [-3, -3], [1, 1]]) + assert np.allclose( + m2.data, [[7.35, 21.22, 5.4], [14.7, 16.97, 5.4]], atol=0.01 + ) + assert np.allclose( + [m2.U, m2.V, m2.T, m2.W], [[1, 2], [2, 1], [-3, -3], [1, 1]] + ) def test_length(self): # Direct lattice vectors @@ -110,7 +141,9 @@ def test_length(self): assert np.allclose(1 / m2.length, [0.224, 0.156], atol=1e-3) # Vectors - m3 = Miller(xyz=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], phase=TETRAGONAL_PHASE) + m3 = Miller( + xyz=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], phase=TETRAGONAL_PHASE + ) assert np.allclose(m3.length, [1, 1, 1]) def test_init_from_highest_indices(self): @@ -119,15 +152,21 @@ def test_init_from_highest_indices(self): m2 = Miller.from_highest_indices(phase=TETRAGONAL_PHASE, uvw=[1, 2, 3]) assert np.allclose(np.max(m2.uvw, axis=0), [1, 2, 3]) - with pytest.raises(ValueError, match="Either highest `hkl` or `uvw` indices "): + with pytest.raises( + ValueError, match="Either highest `hkl` or `uvw` indices " + ): _ = Miller.from_highest_indices(phase=TETRAGONAL_PHASE) with pytest.raises(ValueError, match="All indices*"): - _ = Miller.from_highest_indices(phase=TETRAGONAL_PHASE, hkl=[3, 2, -1]) + _ = Miller.from_highest_indices( + phase=TETRAGONAL_PHASE, hkl=[3, 2, -1] + ) def test_init_from_min_dspacing(self): # Tested against EMsoft v5.0 - m1 = Miller.from_min_dspacing(phase=TETRAGONAL_PHASE, min_dspacing=0.05) + m1 = Miller.from_min_dspacing( + phase=TETRAGONAL_PHASE, min_dspacing=0.05 + ) assert m1.coordinate_format == "hkl" assert m1.size == 14078 assert np.allclose(np.max(m1.hkl, axis=0), [9, 9, 19]) @@ -237,7 +276,9 @@ def test_unique(self): m3, idx = m2.unit.unique(return_index=True) assert m3.size == 205 assert isinstance(m3, Miller) - assert np.allclose(idx[:10], [65, 283, 278, 269, 255, 235, 208, 282, 272, 276]) + assert np.allclose( + idx[:10], [65, 283, 278, 269, 255, 235, 208, 282, 272, 276] + ) def test_multiply_orientation(self): o = Orientation.from_euler(np.deg2rad([45, 0, 0])) @@ -246,26 +287,38 @@ def test_multiply_orientation(self): m2 = o * m assert isinstance(m2, Miller) assert m2.coordinate_format == "hkl" - assert np.allclose(m2.data, [[np.sqrt(2), 0, 1], [np.sqrt(2), -np.sqrt(2), 0]]) + assert np.allclose( + m2.data, [[np.sqrt(2), 0, 1], [np.sqrt(2), -np.sqrt(2), 0]] + ) def test_overwritten_vector3d_methods(self): lattice = Lattice(1, 1, 1, 90, 90, 90) - phase1 = Phase(point_group="m-3m", structure=Structure(lattice=lattice)) + phase1 = Phase( + point_group="m-3m", structure=Structure(lattice=lattice) + ) phase2 = Phase(point_group="432", structure=Structure(lattice=lattice)) m1 = Miller(hkl=[[1, 1, 1], [2, 0, 0]], phase=phase1) m2 = Miller(hkil=[[1, 1, -2, 0], [2, 1, -3, 1]], phase=phase2) assert not m1._compatible_with(m2) - with pytest.raises(ValueError, match="The crystal lattices and symmetries"): + with pytest.raises( + ValueError, match="The crystal lattices and symmetries" + ): _ = m1.angle_with(m2) - with pytest.raises(ValueError, match="The crystal lattices and symmetries"): + with pytest.raises( + ValueError, match="The crystal lattices and symmetries" + ): _ = m1.cross(m2) - with pytest.raises(ValueError, match="The crystal lattices and symmetries"): + with pytest.raises( + ValueError, match="The crystal lattices and symmetries" + ): _ = m1.dot(m2) - with pytest.raises(ValueError, match="The crystal lattices and symmetries"): + with pytest.raises( + ValueError, match="The crystal lattices and symmetries" + ): _ = m1.dot_outer(m2) m3 = Miller(hkl=[[2, 0, 0], [1, 1, 1]], phase=phase1) @@ -273,10 +326,14 @@ def test_overwritten_vector3d_methods(self): def test_is_hexagonal(self): assert Miller(hkil=[1, 1, -2, 1], phase=TRIGONAL_PHASE).is_hexagonal - assert not Miller(hkil=[1, 1, -2, 1], phase=TETRAGONAL_PHASE).is_hexagonal + assert not Miller( + hkil=[1, 1, -2, 1], phase=TETRAGONAL_PHASE + ).is_hexagonal def test_various_shapes(self): - v = np.array([[1, 2, 0], [3, 1, 1], [1, 1, 1], [2, 0, 0], [1, 2, 3], [2, 2, 2]]) + v = np.array( + [[1, 2, 0], [3, 1, 1], [1, 1, 1], [2, 0, 0], [1, 2, 3], [2, 2, 2]] + ) # Initialization of vectors work as expected shape1 = (2, 3) @@ -386,7 +443,9 @@ def test_transform_space(self): assert not np.may_share_memory(v1, v2) # Incorrect space - with pytest.raises(ValueError, match="`space_in` and `space_out` must be one "): + with pytest.raises( + ValueError, match="`space_in` and `space_out` must be one " + ): _transform_space(v1, "direct", "cartesian", lattice) # uvw -> hkl -> uvw @@ -456,8 +515,12 @@ def test_uvw2UVTW(self): assert np.allclose(m1.unit.data, m2.unit.data) # MTEX convention - assert np.allclose(_uvw2UVTW(uvw, convention="mtex") / 3, _uvw2UVTW(uvw)) - assert np.allclose(_UVTW2uvw(UVTW, convention="mtex") * 3, _UVTW2uvw(UVTW)) + assert np.allclose( + _uvw2UVTW(uvw, convention="mtex") / 3, _uvw2UVTW(uvw) + ) + assert np.allclose( + _UVTW2uvw(UVTW, convention="mtex") * 3, _UVTW2uvw(UVTW) + ) def test_mtex_convention(self): # Same result without convention="mtex" because of rounding... @@ -482,7 +545,8 @@ def test_trigonal_crystal(self): nround = n.round() assert np.allclose(nround.UVTW, [3, 3, -6, 11]) assert np.allclose( - [nround.U[0], nround.V[0], nround.T[0], nround.W[0]], [3, 3, -6, 11] + [nround.U[0], nround.V[0], nround.T[0], nround.W[0]], + [3, 3, -6, 11], ) # Examples from MTEX' documentation: @@ -515,12 +579,18 @@ def test_trigonal_crystal(self): m7 = Miller(hkil=[-1, -1, 2, 0], phase=TRIGONAL_PHASE) assert np.allclose(m6.angle_with(m7, degrees=True)[0], 180) - assert np.allclose(m6.angle_with(m7, use_symmetry=True, degrees=True)[0], 60) + assert np.allclose( + m6.angle_with(m7, use_symmetry=True, degrees=True)[0], 60 + ) def test_convention_not_met(self): - with pytest.raises(ValueError, match="The Miller-Bravais indices convention"): + with pytest.raises( + ValueError, match="The Miller-Bravais indices convention" + ): _ = Miller(hkil=[1, 1, -1, 0], phase=TETRAGONAL_PHASE) - with pytest.raises(ValueError, match="The Miller-Bravais indices convention"): + with pytest.raises( + ValueError, match="The Miller-Bravais indices convention" + ): _ = Miller(UVTW=[1, 1, -1, 0], phase=TETRAGONAL_PHASE) @@ -532,7 +602,9 @@ def test_tetragonal_crystal(self): lattice = TETRAGONAL_LATTICE # Example 1.1: Direct metric tensor - assert np.allclose(lattice.metrics, [[0.25, 0, 0], [0, 0.25, 0], [0, 0, 1]]) + assert np.allclose( + lattice.metrics, [[0.25, 0, 0], [0, 0.25, 0], [0, 0, 1]] + ) # Example 1.2: Distance between two points (length of a vector) answer = np.sqrt(5) / 4 # nm @@ -546,11 +618,15 @@ def test_tetragonal_crystal(self): m2 = Miller(uvw=[1, 2, 0], phase=TETRAGONAL_PHASE) m3 = Miller(uvw=[3, 1, 1], phase=TETRAGONAL_PHASE) assert np.allclose(m2.dot(m3), 5 / 4) # nm^2 - assert np.allclose(m2.angle_with(m3, degrees=True)[0], 53.30, atol=0.01) + assert np.allclose( + m2.angle_with(m3, degrees=True)[0], 53.30, atol=0.01 + ) # Example 1.5: Reciprocal metric tensor lattice_recip = lattice.reciprocal() - assert np.allclose(lattice_recip.metrics, [[4, 0, 0], [0, 4, 0], [0, 0, 1]]) + assert np.allclose( + lattice_recip.metrics, [[4, 0, 0], [0, 4, 0], [0, 0, 1]] + ) # Example 1.6, 1.7: Angle between two plane normals m4 = Miller(hkl=[1, 2, 0], phase=TETRAGONAL_PHASE) @@ -833,18 +909,18 @@ def test_group_mm2(self): m.symmetrise(unique=False).hkl, [ [ 0, 0, 1], - [ 0, 0, -1], - [ 0, 0, -1], + [ 0, 0, 1], + [ 0, 0, 1], [ 0, 0, 1], [ 0, 1, 1], - [ 0, -1, -1], - [ 0, 1, -1], + [ 0, -1, 1], + [ 0, 1, 1], [ 0, -1, 1], [ 1, 1, 1], - [ 1, -1, -1], - [ 1, 1, -1], + [-1, -1, 1], + [-1, 1, 1], [ 1, -1, 1], ], ) @@ -853,23 +929,20 @@ def test_group_mm2(self): m_unique.hkl, [ [ 0, 0, 1], - [ 0, 0, -1], [ 0, 1, 1], - [ 0, -1, -1], - [ 0, 1, -1], [ 0, -1, 1], [ 1, 1, 1], - [ 1, -1, -1], - [ 1, 1, -1], + [-1, -1, 1], + [-1, 1, 1], [ 1, -1, 1], ], ) # fmt: on mult = m.multiplicity - assert np.allclose(mult, [2, 4, 4]) + assert np.allclose(mult, [1, 2, 4]) assert np.sum(mult) == m_unique.size def test_group_2overm_2overm_2overm(self): From ca5a391a8dee2216e8f3ca3dbe0b8b1401ea4024 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:52:26 -0600 Subject: [PATCH 02/40] matching symmetry lists and names to ITOC --- orix/crystal_map/phase_list.py | 13 +- orix/quaternion/symmetry.py | 556 ++++++++++++++++++---- orix/tests/quaternion/test_orientation.py | 40 +- orix/tests/quaternion/test_symmetry.py | 101 ++-- orix/tests/test_crystal_map.py | 109 ++--- orix/tests/test_miller.py | 137 ++---- 6 files changed, 570 insertions(+), 386 deletions(-) diff --git a/orix/crystal_map/phase_list.py b/orix/crystal_map/phase_list.py index d6e831230..7e6b506b7 100644 --- a/orix/crystal_map/phase_list.py +++ b/orix/crystal_map/phase_list.py @@ -34,8 +34,8 @@ from orix.quaternion.symmetry import ( _EDAX_POINT_GROUP_ALIASES, Symmetry, - _groups, get_point_group, + get_point_groups, ) from orix.vector import Miller, Vector3d @@ -239,6 +239,7 @@ def point_group(self) -> Symmetry | None: @point_group.setter def point_group(self, value: int | str | Symmetry | None) -> None: """Set the point group.""" + groups = get_point_groups("all_repeated") if isinstance(value, int): value = str(value) if isinstance(value, str): @@ -246,7 +247,7 @@ def point_group(self, value: int | str | Symmetry | None) -> None: if value in aliases: value = key break - for point_group in _groups: + for point_group in groups: if value == point_group.name: value = point_group break @@ -564,7 +565,13 @@ def __init__( max_entries = max( [ len(i) if i is not None else 0 - for i in [names, space_groups, point_groups, ids, structures] + for i in [ + names, + space_groups, + point_groups, + ids, + structures, + ] ] ) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 7658ffe6c..3819ea76d 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -73,7 +73,8 @@ def is_proper(self) -> bool: @property def subgroups(self) -> list[Symmetry]: """Return the list groups that are subgroups of this group.""" - return [g for g in _groups if g._tuples <= self._tuples] + groups = get_point_groups("all") + return [g for g in groups if g._tuples <= self._tuples] @property def proper_subgroups(self) -> list[Symmetry]: @@ -137,10 +138,12 @@ def euler_fundamental_region(self) -> tuple: "211": (360, 90, 360), # Monoclinic "121": (360, 90, 360), "112": (360, 180, 180), + "2": (360, 180, 180), "222": (360, 90, 180), # Orthorhombic "4": (360, 180, 90), # Tetragonal "422": (360, 90, 90), "3": (360, 180, 120), # Trigonal + "321": (360, 90, 120), "312": (360, 90, 120), "32": (360, 90, 120), "6": (360, 180, 60), # Hexagonal @@ -166,20 +169,11 @@ def system(self) -> str | None: system ``None`` is returned if the symmetry name is not recognized. """ + # fmt: off name = self.name if name in ["1", "-1"]: return "triclinic" - elif name in [ - "211", - "121", - "112", - "2", - "m11", - "1m1", - "11m", - "m", - "2/m", - ]: + elif name in ["211", "121", "112", "2", "m11", "1m1", "11m", "m", "2/m"]: return "monoclinic" elif name in ["222", "mm2", "mmm"]: return "orthorhombic" @@ -193,6 +187,7 @@ def system(self) -> str | None: return "cubic" else: return None + # fmt: on @property def _tuples(self) -> set: @@ -260,9 +255,7 @@ def fundamental_sector(self) -> "FundamentalSector": # Taken from MTEX center = Vector3d([0.707558, -0.000403, 0.706655]) elif name in ["m-3", "432"]: - n = Vector3d( - np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data]) - ) + n = Vector3d(np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data])) # Taken from MTEX center = Vector3d([0.349928, 0.348069, 0.869711]) elif name == "-43m": @@ -293,9 +286,9 @@ def _primary_axis_order(self) -> int | None: name = self.proper_subgroup.name if name in ["1", "211", "121"]: return 1 - elif name in ["112", "222", "23"]: + elif name in ["112", "222", "23", "2"]: return 2 - elif name in ["3", "312", "32"]: + elif name in ["3", "312", "321", "32"]: return 3 elif name in ["4", "422", "432"]: return 4 @@ -336,14 +329,14 @@ def symmetry_axis(v: Vector3d, n: int) -> Rotation: if name in ["1", "211", "121"]: # All proper operations rot = self[~self.improper] - elif name in ["112", "3", "4", "6"]: + elif name in ["2", "112", "3", "4", "6"]: # Identity rot = self[0] - elif name in ["222", "422", "622", "32"]: + elif name in ["222", "422", "622", "32", "321"]: # Two-fold rotation about a-axis perpendicular to c-axis rot = symmetry_axis(-vx, 2) elif name == "312": - # Mirror plane perpendicular to c-axis? + # Mirror plane perpendicular to c-axis rot = symmetry_axis(-mirror, 2) elif name in ["23", "432"]: # Three-fold rotation about [111] @@ -370,9 +363,7 @@ def __and__(self, other: Symmetry) -> Symmetry: return Symmetry.from_generators(*generators) def __hash__(self) -> int: - return hash( - self.name.encode() + self.data.tobytes() + self.improper.tobytes() - ) + return hash(self.name.encode() + self.data.tobytes() + self.improper.tobytes()) # ------------------------ Class methods ------------------------- # @@ -430,9 +421,7 @@ def get_axis_orders(self) -> dict[Vector3d, int]: return {} return { Vector3d(a): b + 1 - for a, b in zip( - *np.unique(s.axis.data, axis=0, return_counts=True) - ) + for a, b in zip(*np.unique(s.axis.data, axis=0, return_counts=True)) } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: @@ -549,6 +538,33 @@ def plot( return figure +# ---------------- Proceedural definitions of Point Groups ---------------- # +# NOTE: ORIX uses Schoenflies symbols to define point groups. This is partly +# because the notation is short and always starts with a letter (ie, they +# make convenient python variables), and partly because it helps limit +# accidental misinterpretation of of Herman-Mauguin symbols as space group +# numbers. For example. "222" could be interpreted as SG#222 == Pn-3n, or +# as PG'222'== D3. there are similar examples with 2, 3, 4, 32, etc. + +# Additionally, there are 43 crystallographically valid Schonflies group +# notations, but only 32 unique ones, meaning certain point groups have +# redundant representations in Schonflies notation(S4==C4i, Ci==S2, S6==C3i, +# and C2==D1, for example). The International Tables of Crystallography, +# Volume A, Section 12.1 defines the 32 standard representations, but a few of +# the commonly used redundant ones are given below for convenience. + +# Finally, while there are 32 Point groups, ITOC names several additional +# projections for the non-centrosymmetric groups (ie, using x and/or y as the +# rotation axis instead of z). These are included below as well, following +# the ITOC naming convention (for example, a 2-fold cyclic rotation around +# the x axis instead of the z axis is called C2x.) + +# For more details on how point groups can be generated, the following three +# resources lay out three different but equally valid approaches: +# 1)"Structure of Materials", De Graef et al, Section 9.2 +# 2)"International Tables of Crystallography: Volume A" Section 12.1 +# 3)"Crystallogrpahic Texture and Group Representations", Chi-Sing Man,Ch2 + # Triclinic C1 = Symmetry((1, 0, 0, 0)) C1.name = "1" @@ -651,15 +667,9 @@ def plot( D4h.name = "4/mmm" # 3-fold rotations -C3x = Symmetry( - [(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)] -) -C3y = Symmetry( - [(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)] -) -C3z = Symmetry( - [(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))] -) +C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)]) +C3y = Symmetry([(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)]) +C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))]) C3 = Symmetry(C3z) C3.name = "3" @@ -708,68 +718,374 @@ def plot( Oh = Symmetry.from_generators(O, Ci) Oh.name = "m-3m" -# Collections of groups for convenience -# fmt: off -_groups = [ - # Schoenflies Crystal system International Laue class Proper point group - C1, # Triclinic 1 -1 1 - Ci, # Triclinic -1 -1 1 - C2x, # Monoclinic 211 2/m 211 - C2y, # Monoclinic 121 2/m 121 - C2z, # Monoclinic 112 2/m 112 - Csx, # Monoclinic m11 2/m 1 - Csy, # Monoclinic 1m1 2/m 1 - Csz, # Monoclinic 11m 2/m 1 - C2h, # Monoclinic 2/m 2/m 112 - D2, # Orthorhombic 222 mmm 222 - C2v, # Orthorhombic mm2 mmm 211 - D2h, # Orthorhombic mmm mmm 222 - C4, # Tetragonal 4 4/m 4 - S4, # Tetragonal -4 4/m 112 - C4h, # Tetragonal 4/m 4/m 4 - D4, # Tetragonal 422 4/mmm 422 - C4v, # Tetragonal 4mm 4/mmm 4 - D2d, # Tetragonal -42m 4/mmm 222 - D4h, # Tetragonal 4/mmm 4/mmm 422 - C3, # Trigonal 3 -3 3 - S6, # Trigonal -3 -3 3 - D3x, # Trigonal 321 -3m 32 - D3y, # Trigonal 312 -3m 312 - D3, # Trigonal 32 -3m 32 - C3v, # Trigonal 3m -3m 3 - D3d, # Trigonal -3m -3m 32 - C6, # Hexagonal 6 6/m 6 - C3h, # Hexagonal -6 6/m 6 - C6h, # Hexagonal 6/m 6/m 622 - D6, # Hexagonal 622 6/mmm 622 - C6v, # Hexagonal 6mm 6/mmm 6 - D3h, # Hexagonal -6m2 6/mmm 312 - D6h, # Hexagonal 6/mmm 6/mmm 622 - T, # Cubic 23 m-3 23 - Th, # Cubic m-3 m-3 23 - O, # Cubic 432 m-3m 432 - Td, # Cubic -43m m-3m 23 - Oh, # Cubic m-3m m-3m 432 -] -# fmt: on -_proper_groups = [ - C1, - C2, - C2x, - C2y, - C2z, - D2, - C4, - D4, - C3, - D3x, - D3y, - D3, - C6, - D6, - T, - O, -] + +def get_point_groups(subset: str = "unique"): + """ + returns different subsets of the 32 crystallographic point groups. By + default, this returns all 32 in the order they appear in the + International Tables of Crystallography (ITOC). + + Parameters + ---------- + subset : str, optional + the point group list to return. The options are as follows: + "unique" (default): + All 32 point groups in the order they appear in space groups. + Thus, they are grouped by crystal system and laue class + "all": + All 32 points groups, plus common axis-specific permutations + for non-centrosymmetric groups (ie, C2 plus C2x and C2y) for + a total of 37 point group projections. These + are given in the same order as ITOC Table 10.2.2 + "all_repeated": + The 37 point group projections, plus the redundant Schonflies + and Herman-Mauguin group names. For example, both Ci and S2 + are included, as well as D3 =="32" and D3x == "321". NOTE: + this means several of the entries symmetrically identical. + "proper": + The 11 proper point groups given in the same order as ITOC + table 10.2.2. + same order as "unique", which in turn aligns with Table 3.1 + of ITOC + "proper_all": + The 11 proper point groups, plus axis-specific permutations. + "laue": + The point groups corresponding to the 11 Laue groups, using + the same ordering and definitions as Table 3.1 of ITOC. These + are equivalent to adding an inversion symmetry to each op + the 11 proper point groups + "procedural": + The 32 point groups, but presented in the procedural ordering + described in "Structure of Materials" and other books, where + point groups are created from successive applications of + symmetry elements to the Cyclic (C_n) and Dihedral (D_n) + groups. + + Returns + ------- + point groups: [Symmetry,] + a list of point group symmetries + + Notes + ------- + For convenience, the following table shows the 38 point groups contained + in "all", of which the other lists are subsets of. + + +-------------+--------+---------------------+------------+------------+ + | Schoenflies | System | ITOC/Herman_Mauguin | Laue class | Proper PG | + +-------------+--------+---------------------+------------+------------+ + | C1 | Triclinic | 1 | -1 | 1 | + +-------------+--------+---------------------+------------+------------+ + | Ci | Triclinic | -1 | -1 | 1 | + +-------------+--------+---------------------+------------+------------+ + | C2 | Monoclinic | 2 or 112 | 2/m | 112 | + +-------------+--------+---------------------+------------+------------+ + | C2x | Monoclinic | 211 | 2/m | 211 | + +-------------+--------+---------------------+------------+------------+ + | C2y | Monoclinic | 121 | 2/m | 121 | + +-------------+--------+---------------------+------------+------------+ + | Cs | Monoclinic | 11m or m | 2/m | 1 | + +-------------+--------+---------------------+------------+------------+ + | Csx | Monoclinic | m11 | 2/m | 1 | + +-------------+--------+---------------------+------------+------------+ + | Csy | Monoclinic | 1m1 | 2/m | 1 | + +-------------+--------+---------------------+------------+------------+ + | C2h | Monoclinic | 2/m | 2/m | 112 | + +-------------+--------+---------------------+------------+------------+ + | D2 | Orthorhombic | 222 | mmm | 222 | + +-------------+--------+---------------------+------------+------------+ + | C2v | Orthorhombic | mm2 | mmm | 211 | + +-------------+--------+---------------------+------------+------------+ + | D2h | Orthorhombic | mmm | mmm | 222 | + +-------------+--------+---------------------+------------+------------+ + | C4 | Tetragonal | 4 | 4/m | 4 | + +-------------+--------+---------------------+------------+------------+ + | S4 | Tetragonal | -4 | 4/m | 112 | + +-------------+--------+---------------------+------------+------------+ + | C4h | Tetragonal | 4/m | 4/m | 4 | + +-------------+--------+---------------------+------------+------------+ + | D4 | Tetragonal | 422 | 4/mmm | 422 | + +-------------+--------+---------------------+------------+------------+ + | C4v | Tetragonal | 4mm | 4/mmm | 4 | + +-------------+--------+---------------------+------------+------------+ + | D2d | Tetragonal | -42m | 4/mmm | 222 | + +-------------+--------+---------------------+------------+------------+ + | D4h | Tetragonal | 4/mmm | 4/mmm | 422 | + +-------------+--------+---------------------+------------+------------+ + | C3 | Trigonal | 3 | -3 | 3 | + +-------------+--------+---------------------+------------+------------+ + | C3i | Trigonal | -3 | -3 | 3 | + +-------------+--------+---------------------+------------+------------+ + | D3 | Trigonal | 32 or 321 | -3m | 32 | + +-------------+--------+---------------------+------------+------------+ + | D3y | Trigonal | 312 | -3m | 312 | + +-------------+--------+---------------------+------------+------------+ + | C3v | Trigonal | 3m | -3m | 3 | + +-------------+--------+---------------------+------------+------------+ + | D3d | Trigonal | -3m | -3m | 32 | + +-------------+--------+---------------------+------------+------------+ + | C6 | Hexagonal | 6 | 6/m | 6 | + +-------------+--------+---------------------+------------+------------+ + | C3h | Hexagonal | -6 | 6/m | 6 | + +-------------+--------+---------------------+------------+------------+ + | C6h | Hexagonal | 6/m | 6/m | 622 | + +-------------+--------+---------------------+------------+------------+ + | D6 | Hexagonal | 622 | 6/mmm | 622 | + +-------------+--------+---------------------+------------+------------+ + | C6v | Hexagonal | 6mm | 6/mmm | 6 | + +-------------+--------+---------------------+------------+------------+ + | D3h | Hexagonal | -6m2 | 6/mmm | 312 | + +-------------+--------+---------------------+------------+------------+ + | D6h | Hexagonal | 6/mmm | 6/mmm | 622 | + +-------------+--------+---------------------+------------+------------+ + | T | Cubic | 23 | m-3 | 23 | + +-------------+--------+---------------------+------------+------------+ + | Th | Cubic | m-3 | m-3 | 23 | + +-------------+--------+---------------------+------------+------------+ + | O | Cubic | 432 | m-3m | 432 | + +-------------+--------+---------------------+------------+------------+ + | Td | Cubic | -43m | m-3m | 23 | + +-------------+--------+---------------------+------------+------------+ + | Oh | Cubic | m-3m | m-3m | 432 | + +-------------+--------+---------------------+------------+------------+ + + """ + subset = str(subset).lower() + if subset == "all_repeated": + return [ + # Triclinic + C1, + Ci, + S2, # redundant + # Monoclinic + C2, + D1, # redundant + C2x, + C2y, + C2z, # redundant + Cs, + Csx, + Csy, + Csz, # redundant + C2h, + # Orthorhombic + D2, + C2v, + D2h, + # Tetragonal + C4, + S4, + C4i, # redundant + C4h, + D4, + C4v, + D2d, + D4h, + # Trigonal + C3, + C3i, + S6, # redundant + D3, + D3x, + D3y, + C3v, + D3d, + # Hexagonal + C6, + C3h, + C6h, + D6, + C6v, + D3h, + D6h, + # cubic + T, + Th, + O, + Td, + Oh, + ] + elif subset == "all": + return [ + # Triclinic + C1, + Ci, + # Monoclinic + C2, + C2x, + C2y, + Cs, + Csx, + Csy, + C2h, + # Orthorhombic + D2, + C2v, + D2h, + # Tetragonal + C4, + S4, + C4h, + D4, + C4v, + D2d, + D4h, + # Trigonal + C3, + C3i, + D3, + D3y, + C3v, + D3d, + # Hexagonal + C6, + C3h, + C6h, + D6, + C6v, + D3h, + D6h, + # cubic + T, + Th, + O, + Td, + Oh, + ] + elif subset == "unique": + return [ + # Triclinic + C1, + Ci, + # Monoclinic + C2, + Cs, + C2h, + # Orthorhombic + D2, + C2v, + D2h, + # Tetragonal + C4, + S4, + C4h, + D4, + C4v, + D2d, + D4h, + # Trigonal + C3, + C3i, + D3, + C3v, + D3d, + # Hexagonal + C6, + C3h, + C6h, + D6, + C6v, + D3h, + D6h, + # cubic + T, + Th, + O, + Td, + Oh, + ] + elif subset == "proper": + return [ + # Triclinic + C1, + # Monoclinic + C2, + # Orthorhombic + D2, + # Tetragonal + C4, + # Trigonal + C3, + D3, + # Hexagonal + C6, + D6, + # cubic + T, + O, + ] + elif subset == "laue": + return [x * Ci for x in get_point_groups("proper")] + elif subset == "proper_all": + return [ + # Triclinic + C1, + # Monoclinic + C2, + C2x, + C2y, + # Orthorhombic + D2, + # Tetragonal + C4, + # Trigonal + C3, + D3, + D3x, + D3y, + # Hexagonal + C6, + D6, + # cubic + T, + O, + ] + elif subset == "SoM": + return [ + # Cyclic + C1, + C2, + C3, + C4, + C6, + # Dihedral + D2, + D3, + D4, + D6, + # Cyclic plus inversion (\ba{n}) + Ci, + Cs, + C3i, + S4, + C3h, + # Cyclic plus perpendicular mirrors (n/m) + C2h, + C4h, + C6h, + # Cyclic plus vertical mirrors (nm) + C2v, + C3v, + C4v, + C6v, + # Dihedral plus diagonal mirrors (\bar{n} m) + D3d, + D2d, + D3h, + # Dihedral with vertical and perpendicular mirros (n/m m) + D2h, + D4h, + D6h, + # Combining cyclic (n1 n2) + T, + O, + # combining cyclic and mirrors + Th, + Td, + Oh, + ] + else: + ValueError("{} is not a valid subset option".format(subset)) def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: @@ -835,16 +1151,40 @@ def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: } +@deprecated( + since="0.14", + removal="0.15", + alternative="symmetry.get_point_group_from_space_group", +) def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: - """Map a space group number to its (proper) point group. + r""" + This function has been renamed to + `orix.quaternion.symmetry.get_point_group_from_space_group`. + for clairity""" + return get_point_group_from_space_group(space_group_number, proper) + + +def get_point_group_from_space_group( + space_group_number: Union(int, str), proper: bool = False +) -> Symmetry: + """ + Maps a space group number or name to a crystallographic point group. Parameters ---------- - space_group_number - Between 1 and 231. - proper + space_group_number: int between 1-231, or str + If is an int(n) or str(int(n)) where n is between 1 and 231, it will + return the point group of the nth space group, as defined by the + International Tables of Crystallogrphy. Otherwise, it will be passed + to diffpy's dictionary of space group names for interpretation. + + Thus, 222 and '222' will both return symmetry.Oh (ie "432", the point + group of SG#222==Pn-3n), but 'P222' will return symmetry.D2 + (ie "222", the proper point group of SG#16=='P222'). + + proper: bool Whether to return the point group with proper rotations only - (``True``), or just the point group (``False``). Default is + (``True``), or the full point group (``False``). Default is ``False``. Returns @@ -852,6 +1192,18 @@ def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: point_group One of the 11 proper or 32 point groups. + Notes: + ---------- + This function uses diffpy.structure.spacegroups to convert names to + space group IDs, and has some allowances for spelling and spacing + differences. Thus, variations like "Pm-3m", 221, "PM3m", and "Pn3n" all + map to symmetry.Oh == 'm-3m'. To see a full list of all name options + avaiable, use the following snippet: + + >>> import diffpy.structure.spacegroups as sg + >>> sg._buildSGLookupTable() + >>> sg._sg_lookup_table.keys() + Examples -------- >>> from orix.quaternion.symmetry import get_point_group diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index a8e87ee9b..a0e4df286 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -37,13 +37,15 @@ T, O, Oh, - _groups, - _proper_groups, + get_point_groups, ) -from orix.vector import Miller, Vector3d +from orix.vector import Miller, Vector3d # isort: on # fmt: on +groups = get_point_groups("all") +proper_groups = get_point_groups("proper") + @pytest.fixture def vector(request): @@ -310,9 +312,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 @@ -366,7 +366,7 @@ def test_get_distance_matrix_progressbar_chunksize(self): angle2 = m.get_distance_matrix(chunk_size=10, progressbar=False) assert np.allclose(angle1, angle2) - @pytest.mark.parametrize("symmetry", _groups[:-1]) + @pytest.mark.parametrize("symmetry", groups[:-1]) def test_get_distance_matrix_equal_explicit_calculation(self, symmetry): # do not test Oh, as this takes ~4 GB m = Misorientation.random((5,)) @@ -600,9 +600,7 @@ def test_from_scipy_rotation(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) @@ -629,15 +627,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) @@ -684,9 +678,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) @@ -721,9 +713,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 @@ -813,12 +803,10 @@ def test_in_fundamental_region(self): (-0.3874, 0.6708, -0.1986, 0.6004), ) ) - for pg in _proper_groups: + 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_symmetry.py b/orix/tests/quaternion/test_symmetry.py index a92ccdc4e..923890a06 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -34,12 +34,16 @@ C3, S6, D3x, D3y, D3, C3v, D3d, # trigonal C6, C3h, C6h, D6, C6v, D3h, D6h, # hexagonal T, Th, O, Td, Oh, # cubic - spacegroup2pointgroup_dict, _groups, _get_unique_symmetry_elements + spacegroup2pointgroup_dict, + get_point_groups, + _get_unique_symmetry_elements ) # isort: on # fmt: on from orix.vector import Vector3d +_groups = get_point_groups("all") + @pytest.fixture(params=[(1, 2, 3)]) def vector(request): @@ -276,8 +280,8 @@ def test_is_proper(symmetry, expected): "symmetry, expected", [ (C1, [C1]), - (D2, [C1, C2x, C2y, C2z, D2]), - (C6v, [C1, C2z, Csx, Csy, C2v, C3, C3v, C6, C6v]), + (D2, [C1, C2, C2x, C2y, D2]), + (C6v, [C1, C2, Csx, Csy, C2v, C3, C3v, C6, C6v]), ], ) def test_subgroups(symmetry, expected): @@ -288,8 +292,8 @@ def test_subgroups(symmetry, expected): "symmetry, expected", [ (C1, [C1]), - (D2, [C1, C2x, C2y, C2z, D2]), - (C6v, [C1, C2z, C3, C6]), + (D2, [C1, C2x, C2y, C2, D2]), + (C6v, [C1, C2, C3, C6]), ], ) def test_proper_subgroups(symmetry, expected): @@ -444,13 +448,8 @@ def test_get_point_group(): sg = GetSpaceGroup(sg_number) pg = get_point_group(sg_number, proper=False) - assert ( - proper_pg - == spacegroup2pointgroup_dict[sg.point_group_name]["proper"] - ) - assert ( - pg == spacegroup2pointgroup_dict[sg.point_group_name]["improper"] - ) + assert proper_pg == spacegroup2pointgroup_dict[sg.point_group_name]["proper"] + assert pg == spacegroup2pointgroup_dict[sg.point_group_name]["improper"] def test_unique_symmetry_elements_subgroups(all_symmetries): @@ -542,9 +541,7 @@ def test_symmetry_plot(pg): @pytest.mark.parametrize("symmetry", [C1, C4, Oh]) def test_symmetry_plot_raises(symmetry): - with pytest.raises( - TypeError, match="Orientation must be a Rotation instance" - ): + with pytest.raises(TypeError, match="Orientation must be a Rotation instance"): _ = symmetry.plot(return_figure=True, orientation="test") @@ -644,9 +641,7 @@ def test_fundamental_sector_d4(self): def test_fundamental_sector_c4v(self): pg = C4v # 4mm fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 1, 0], [0.7071, -0.7071, 0]], atol=1e-4 - ) + assert np.allclose(fs.data, [[0, 1, 0], [0.7071, -0.7071, 0]], atol=1e-4) assert np.allclose(fs.vertices.data, [[0, 0, 1], [0, 0, -1]]) assert np.allclose(fs.center.data, [[0.3536, 0.1464, 0]], atol=1e-4) @@ -687,9 +682,7 @@ def test_fundamental_sector_c3(self): def test_fundamental_sector_s6(self): pg = S6 # -3 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], @@ -700,9 +693,7 @@ def test_fundamental_sector_s6(self): def test_fundamental_sector_d3(self): pg = D3 # 32 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], @@ -713,9 +704,7 @@ def test_fundamental_sector_d3(self): def test_fundamental_sector_c3v(self): pg = C3v # 3m fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0.5, 0.866, 0], [0.5, -0.866, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0.5, 0.866, 0], [0.5, -0.866, 0]], atol=1e-3) assert np.allclose(fs.vertices.data, [[0, 0, 1], [0, 0, -1]]) assert np.allclose(fs.center.data, [[0.5, 0, 0]]) @@ -742,9 +731,7 @@ def test_fundamental_sector_c6(self): def test_fundamental_sector_c3h(self): pg = C3h # -6 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], @@ -755,9 +742,7 @@ def test_fundamental_sector_c3h(self): def test_fundamental_sector_c6h(self): pg = C6h # 6/m fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], @@ -768,9 +753,7 @@ def test_fundamental_sector_c6h(self): def test_fundamental_sector_d6(self): pg = D6 # 622 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], @@ -788,9 +771,7 @@ def test_fundamental_sector_c6v(self): def test_fundamental_sector_d3h(self): pg = D3h # -6m2 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], @@ -801,9 +782,7 @@ def test_fundamental_sector_d3h(self): def test_fundamental_sector_d6h(self): pg = D6h # 6/mmm fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.5, -0.866, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.5, -0.866, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.866, 0.5, 0]], @@ -814,9 +793,7 @@ def test_fundamental_sector_d6h(self): def test_fundamental_sector_t(self): pg = T # 23 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[1, 1, 0], [1, -1, 0], [0, -1, 1], [0, 1, 1]] - ) + assert np.allclose(fs.data, [[1, 1, 0], [1, -1, 0], [0, -1, 1], [0, 1, 1]]) assert np.allclose( fs.vertices.data, [ @@ -827,9 +804,7 @@ def test_fundamental_sector_t(self): ], atol=1e-4, ) - assert np.allclose( - fs.center.data, [[0.7076, -0.0004, 0.7067]], atol=1e-4 - ) + assert np.allclose(fs.center.data, [[0.7076, -0.0004, 0.7067]], atol=1e-4) def test_fundamental_sector_th(self): pg = Th # m-3 @@ -848,9 +823,7 @@ def test_fundamental_sector_th(self): ], atol=1e-3, ) - assert np.allclose( - fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4 - ) + assert np.allclose(fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4) def test_fundamental_sector_o(self): pg = O # 432 @@ -868,9 +841,7 @@ def test_fundamental_sector_o(self): ], atol=1e-3, ) - assert np.allclose( - fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4 - ) + assert np.allclose(fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4) def test_fundamental_sector_td(self): pg = Td # -43m @@ -892,9 +863,7 @@ def test_fundamental_sector_oh(self): [[0.5774, 0.5774, 0.5774], [0.7071, 0, 0.7071], [0, 0, 1]], atol=1e-4, ) - assert np.allclose( - fs.center.data, [[0.4282, 0.1925, 0.7615]], atol=1e-4 - ) + assert np.allclose(fs.center.data, [[0.4282, 0.1925, 0.7615]], atol=1e-4) # ---------- End of the 32 crystallographic point groups --------- # @@ -980,7 +949,7 @@ def test_euler_fundamental_region(self): # All point groups provide a region for pg in _groups: angles = pg.euler_fundamental_region - if pg.name in ["1", "-1", "2", "m11", "1m1", "11m"]: + if pg.name in ["1", "-1", "m11", "1m1", "11m", "m"]: assert np.allclose(angles, (360, 180, 360)) else: assert not np.allclose(angles, (360, 180, 360)) @@ -1007,16 +976,10 @@ def test_primary_axis_order(self): def test_special_rotation(self): for pg in [C1, C2z, C3, C4, C6]: assert np.allclose(pg._special_rotation.data, (1, 0, 0, 0)) - assert np.allclose( - C2x._special_rotation.data, ((1, 0, 0, 0), (0, 1, 0, 0)) - ) - assert np.allclose( - C2y._special_rotation.data, ((1, 0, 0, 0), (0, 0, 1, 0)) - ) + assert np.allclose(C2x._special_rotation.data, ((1, 0, 0, 0), (0, 1, 0, 0))) + assert np.allclose(C2y._special_rotation.data, ((1, 0, 0, 0), (0, 0, 1, 0))) for pg in [D2, D4, D6, D3]: - assert np.allclose( - pg._special_rotation.data, ((1, 0, 0, 0), (0, -1, 0, 0)) - ) + assert np.allclose(pg._special_rotation.data, ((1, 0, 0, 0), (0, -1, 0, 0))) assert np.allclose( D3y._special_rotation.data, ((1, 0, 0, 0), (0, -1 / np.sqrt(2), 1 / np.sqrt(2), 0)), @@ -1045,9 +1008,7 @@ def test_special_rotation(self): ) unrecognized_symmetry = Symmetry.random(4) - assert np.allclose( - unrecognized_symmetry._special_rotation.data, (1, 0, 0, 0) - ) + assert np.allclose(unrecognized_symmetry._special_rotation.data, (1, 0, 0, 0)) # All point groups provide at least one rotation for pg in _groups: diff --git a/orix/tests/test_crystal_map.py b/orix/tests/test_crystal_map.py index c7d773048..9d4ad2e4a 100644 --- a/orix/tests/test_crystal_map.py +++ b/orix/tests/test_crystal_map.py @@ -174,9 +174,7 @@ def test_init_with_phase_list(self, crystal_map_input, expected_presence): ): assert (p1 == p2) == expected - unique_phase_ids = list( - np.unique(crystal_map_input["phase_id"]).astype(int) - ) + unique_phase_ids = list(np.unique(crystal_map_input["phase_id"]).astype(int)) assert xmap.phases.ids == unique_phase_ids def test_init_with_sparse_phase_list(self): @@ -310,29 +308,21 @@ def test_is_in_data(self, crystal_map_input): # All points in data xmap = CrystalMap(is_in_data=is_in_data.flatten(), **crystal_map_input) assert xmap.shape == xmap._original_shape == map_shape - assert np.allclose( - xmap.get_map_data("phase_id"), np.zeros(is_in_data.shape) - ) + assert np.allclose(xmap.get_map_data("phase_id"), np.zeros(is_in_data.shape)) # First row not in data is_in_data1 = is_in_data.copy() is_in_data1[0, :] = False - xmap1 = CrystalMap( - is_in_data=is_in_data1.flatten(), **crystal_map_input - ) + xmap1 = CrystalMap(is_in_data=is_in_data1.flatten(), **crystal_map_input) new_shape1 = (map_shape[0] - 1, map_shape[1]) assert xmap1.shape == new_shape1 assert xmap1._original_shape == map_shape - assert np.allclose( - xmap1.get_map_data("phase_id"), np.zeros(new_shape1) - ) + assert np.allclose(xmap1.get_map_data("phase_id"), np.zeros(new_shape1)) # Second row not in data is_in_data2 = is_in_data.copy() is_in_data2[1, :] = False - xmap2 = CrystalMap( - is_in_data=is_in_data2.flatten(), **crystal_map_input - ) + xmap2 = CrystalMap(is_in_data=is_in_data2.flatten(), **crystal_map_input) assert xmap2.shape == xmap2._original_shape == map_shape desired_phase_id = np.zeros(is_in_data.shape) desired_phase_id[1, :] = np.nan @@ -343,15 +333,11 @@ def test_is_in_data(self, crystal_map_input): # Last column not in data is_in_data3 = is_in_data.copy() is_in_data3[:, -1] = False - xmap3 = CrystalMap( - is_in_data=is_in_data3.flatten(), **crystal_map_input - ) + xmap3 = CrystalMap(is_in_data=is_in_data3.flatten(), **crystal_map_input) new_shape3 = (map_shape[0], map_shape[1] - 1) assert xmap3.shape == new_shape3 assert xmap3._original_shape == map_shape - assert np.allclose( - xmap3.get_map_data("phase_id"), np.zeros(new_shape3) - ) + assert np.allclose(xmap3.get_map_data("phase_id"), np.zeros(new_shape3)) def test_set_phases(self, phase_list): assert phase_list.size == 3 @@ -363,9 +349,7 @@ def test_set_phases(self, phase_list): # Not OK xmap._phase_id = np.array([0, 1, 2, 1, 2, 3]) - with pytest.raises( - ValueError, match="There must be at least as many phases " - ): + with pytest.raises(ValueError, match="There must be at least as many phases "): xmap.phases = phase_list @@ -397,9 +381,7 @@ class TestCrystalMapGetItem: ], indirect=["crystal_map_input"], ) - def test_get_by_slice( - self, crystal_map_input, slice_tuple, expected_shape - ): + def test_get_by_slice(self, crystal_map_input, slice_tuple, expected_shape): xmap = CrystalMap(**crystal_map_input) xmap2 = xmap[slice_tuple] @@ -416,9 +398,7 @@ def test_get_by_phase_name(self, crystal_map_input, phase_list): # Get number of points with each phase ID n_points_per_phase = {} for phase_i, phase in phase_list: - n_points_per_phase[phase.name] = len( - np.where(phase_ids == phase_i)[0] - ) + n_points_per_phase[phase.name] = len(np.where(phase_ids == phase_i)[0]) crystal_map_input.pop("phase_id") xmap = CrystalMap(phase_id=phase_ids, **crystal_map_input) @@ -502,9 +482,7 @@ def test_get_by_integer( point = xmap[integer_slices] expected_point = xmap[xmap.id == expected_id] - assert np.allclose( - point.rotations.data, expected_point.rotations.data - ) + assert np.allclose(point.rotations.data, expected_point.rotations.data) assert point._coordinates == expected_point._coordinates def test_get_twice(self): @@ -624,17 +602,11 @@ def test_set_phase_ids(self, crystal_map, set_phase_id): assert "not_indexed" in xmap.phases.names def test_set_phase_ids_raises(self, crystal_map): - with pytest.raises( - ValueError, match="NumPy boolean array indexing assignment" - ): + with pytest.raises(ValueError, match="NumPy boolean array indexing assignment"): crystal_map[1, 1].phase_id = -1 * np.ones(10) - @pytest.mark.parametrize( - "set_phase_id, index_error", [(-1, False), (1, True)] - ) - def test_set_phase_id_with_unknown_id( - self, crystal_map, set_phase_id, index_error - ): + @pytest.mark.parametrize("set_phase_id, index_error", [(-1, False), (1, True)]) + def test_set_phase_id_with_unknown_id(self, crystal_map, set_phase_id, index_error): xmap = crystal_map condition = xmap.x > 1.5 @@ -668,9 +640,7 @@ def test_phases_in_data(self, crystal_map, phase_list): ) condition1 = xmap.x > 1.5 condition2 = xmap.y > 1.5 - for new_id, condition in zip( - ids_not_in_data, [condition1, condition2] - ): + for new_id, condition in zip(ids_not_in_data, [condition1, condition2]): xmap[condition].phase_id = new_id assert xmap.phases_in_data.names == xmap.phases.names @@ -710,9 +680,7 @@ def test_orientations(self, crystal_map_input, phase_list): (O, [(0.6088, 0, 0, 0.7934)], [(0.9914, 0, 0, 0.1305)]), ], ) - def test_orientations_symmetry( - self, point_group, rotation, expected_orientation - ): + def test_orientations_symmetry(self, point_group, rotation, expected_orientation): r = Rotation(rotation) xmap = CrystalMap(rotations=r, phase_id=np.array([0])) xmap.phases = PhaseList(Phase("a", point_group=point_group)) @@ -733,9 +701,7 @@ def test_orientations_none_symmetry_raises(self, crystal_map_input): with pytest.raises(TypeError, match="Value must be an instance of"): _ = xmap.orientations - def test_orientations_multiple_phases_raises( - self, crystal_map, phase_list - ): + def test_orientations_multiple_phases_raises(self, crystal_map, phase_list): xmap = crystal_map xmap.phases = phase_list @@ -784,9 +750,7 @@ def test_overwrite_crystal_map_property_values(self, crystal_map): new_prop_value = -1 xmap.__setattr__(prop_name, new_prop_value) - assert np.allclose( - xmap.prop[prop_name], np.ones(xmap.size) * new_prop_value - ) + assert np.allclose(xmap.prop[prop_name], np.ones(xmap.size) * new_prop_value) class TestCrystalMapMasking: @@ -813,16 +777,12 @@ class TestCrystalMapGetMapData: ( ((4, 4), (0.5, 1), 2, [0]), "y", - np.array( - [[i * 0.5] * 4 for i in range(4)] - ), # [0, 0, 0, 0, 0.5, ...] + np.array([[i * 0.5] * 4 for i in range(4)]), # [0, 0, 0, 0, 0.5, ...] ), ], indirect=["crystal_map_input"], ) - def test_get_coordinate_array( - self, crystal_map_input, to_get, expected_array - ): + def test_get_coordinate_array(self, crystal_map_input, to_get, expected_array): xmap = CrystalMap(**crystal_map_input) # Get via string @@ -955,9 +915,7 @@ def test_get_boolean_array(self, crystal_map): assert np.issubdtype(xmap.get_map_data("is_indexed").dtype, bool) assert np.issubdtype(xmap.get_map_data(xmap.is_in_data).dtype, bool) - @pytest.mark.parametrize( - "dtype_in", [np.uint8, int, np.float32, float, bool] - ) + @pytest.mark.parametrize("dtype_in", [np.uint8, int, np.float32, float, bool]) def test_preserve_dtype(self, crystal_map, dtype_in): xmap = crystal_map prop_name = "new_prop" @@ -1023,15 +981,11 @@ def test_representation(self, crystal_map, phase_list): class TestCrystalMapCopying: def test_shallowcopy_crystal_map(self, crystal_map): - xmap2 = crystal_map[ - : - ] # Everything except `is_in_data` is shallow copied + xmap2 = crystal_map[:] # Everything except `is_in_data` is shallow copied xmap3 = crystal_map # These are the same objects (of course) assert np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) - assert np.may_share_memory( - xmap2._rotations.data, crystal_map._rotations.data - ) + assert np.may_share_memory(xmap2._rotations.data, crystal_map._rotations.data) crystal_map[3, 0].phase_id = -2 assert np.allclose(xmap2.phase_id, crystal_map.phase_id) @@ -1049,10 +1003,7 @@ def test_deepcopy_crystal_map(self, crystal_map): crystal_map[3, 0].phase_id = -2 assert np.allclose(xmap2.phase_id, crystal_map.phase_id) is False - assert ( - np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) - is False - ) + assert np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) is False class TestCrystalMapShape: @@ -1079,9 +1030,7 @@ class TestCrystalMapShape: ], indirect=["crystal_map_input"], ) - def test_data_slices_from_coordinates( - self, crystal_map_input, expected_slices - ): + def test_data_slices_from_coordinates(self, crystal_map_input, expected_slices): xmap = CrystalMap(**crystal_map_input) assert xmap._data_slices_from_coordinates() == expected_slices @@ -1163,9 +1112,7 @@ def test_rotation_shape(self, crystal_map_input, expected_shape): ], indirect=["crystal_map_input"], ) - def test_coordinate_axes( - self, crystal_map_input, expected_coordinate_axes - ): + def test_coordinate_axes(self, crystal_map_input, expected_coordinate_axes): xmap = CrystalMap(**crystal_map_input) assert xmap._coordinate_axes == expected_coordinate_axes @@ -1208,7 +1155,5 @@ def test_plot(self, crystal_map): class TestCreateCoordinateArrays: def test_create_coordinate_arrays_raises(self): - with pytest.raises( - ValueError, match="Can only create coordinate arrays for " - ): + with pytest.raises(ValueError, match="Can only create coordinate arrays for "): _ = create_coordinate_arrays((1, 2, 3)) diff --git a/orix/tests/test_miller.py b/orix/tests/test_miller.py index 6bc7a5b91..03046f808 100644 --- a/orix/tests/test_miller.py +++ b/orix/tests/test_miller.py @@ -49,26 +49,17 @@ def test_init_raises(self): with pytest.raises(ValueError, match="Exactly *"): _ = Miller(phase=Phase(point_group="m-3m")) with pytest.raises(ValueError, match="Exactly *"): - _ = Miller( - xyz=[0, 1, 2], hkl=[3, 4, 5], phase=Phase(point_group="m-3m") - ) - with pytest.raises( - ValueError, match="A phase with a crystal lattice and " - ): + _ = Miller(xyz=[0, 1, 2], hkl=[3, 4, 5], phase=Phase(point_group="m-3m")) + with pytest.raises(ValueError, match="A phase with a crystal lattice and "): _ = Miller(hkl=[1, 1, 1]) def test_repr(self): m = Miller(hkil=[1, 1, -2, 0], phase=TRIGONAL_PHASE) - assert ( - repr(m) == "Miller (1,), point group 321, hkil\n" - "[[ 1. 1. -2. 0.]]" - ) + assert repr(m) == "Miller (1,), point group 321, hkil\n" "[[ 1. 1. -2. 0.]]" def test_coordinate_format_raises(self): m = Miller(hkl=[1, 1, 1], phase=TETRAGONAL_PHASE) - with pytest.raises( - ValueError, match="Available coordinate formats are " - ): + with pytest.raises(ValueError, match="Available coordinate formats are "): m.coordinate_format = "abc" def test_set_coordinates(self): @@ -93,19 +84,11 @@ def test_set_hkl_hkil(self): assert np.allclose([m1.h, m1.k, m1.l], [[1, 1], [2, 1], [0, 0]]) m2 = Miller(hkil=[[1, 1, -2, 1], [2, 0, -2, 0]], phase=TRIGONAL_PHASE) - assert np.allclose( - m2.data, [[0.20, 0.35, 0.19], [0.41, 0.24, 0]], atol=0.01 - ) - assert np.allclose( - [m2.h, m2.k, m2.i, m2.l], [[1, 2], [1, 0], [-2, -2], [1, 0]] - ) + assert np.allclose(m2.data, [[0.20, 0.35, 0.19], [0.41, 0.24, 0]], atol=0.01) + assert np.allclose([m2.h, m2.k, m2.i, m2.l], [[1, 2], [1, 0], [-2, -2], [1, 0]]) m2.hkil = [[1, 2, -3, 1], [2, 1, -3, 1]] - assert np.allclose( - m2.data, [[0.20, 0.59, 0.19], [0.41, 0.47, 0.19]], atol=0.01 - ) - assert np.allclose( - [m2.h, m2.k, m2.i, m2.l], [[1, 2], [2, 1], [-3, -3], [1, 1]] - ) + assert np.allclose(m2.data, [[0.20, 0.59, 0.19], [0.41, 0.47, 0.19]], atol=0.01) + assert np.allclose([m2.h, m2.k, m2.i, m2.l], [[1, 2], [2, 1], [-3, -3], [1, 1]]) def test_set_uvw_UVTW(self): m1 = Miller(uvw=[[1, 1, 1], [2, 0, 0]], phase=TETRAGONAL_PHASE) @@ -116,19 +99,11 @@ def test_set_uvw_UVTW(self): assert np.allclose([m1.u, m1.v, m1.w], [[1, 1], [2, 1], [0, 0]]) m2 = Miller(UVTW=[[1, 1, -2, 1], [2, 0, -2, 0]], phase=TRIGONAL_PHASE) - assert np.allclose( - m2.data, [[7.35, 12.73, 5.4], [14.7, 8.49, 0]], atol=0.01 - ) - assert np.allclose( - [m2.U, m2.V, m2.T, m2.W], [[1, 2], [1, 0], [-2, -2], [1, 0]] - ) + assert np.allclose(m2.data, [[7.35, 12.73, 5.4], [14.7, 8.49, 0]], atol=0.01) + assert np.allclose([m2.U, m2.V, m2.T, m2.W], [[1, 2], [1, 0], [-2, -2], [1, 0]]) m2.UVTW = [[1, 2, -3, 1], [2, 1, -3, 1]] - assert np.allclose( - m2.data, [[7.35, 21.22, 5.4], [14.7, 16.97, 5.4]], atol=0.01 - ) - assert np.allclose( - [m2.U, m2.V, m2.T, m2.W], [[1, 2], [2, 1], [-3, -3], [1, 1]] - ) + assert np.allclose(m2.data, [[7.35, 21.22, 5.4], [14.7, 16.97, 5.4]], atol=0.01) + assert np.allclose([m2.U, m2.V, m2.T, m2.W], [[1, 2], [2, 1], [-3, -3], [1, 1]]) def test_length(self): # Direct lattice vectors @@ -141,9 +116,7 @@ def test_length(self): assert np.allclose(1 / m2.length, [0.224, 0.156], atol=1e-3) # Vectors - m3 = Miller( - xyz=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], phase=TETRAGONAL_PHASE - ) + m3 = Miller(xyz=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], phase=TETRAGONAL_PHASE) assert np.allclose(m3.length, [1, 1, 1]) def test_init_from_highest_indices(self): @@ -152,21 +125,15 @@ def test_init_from_highest_indices(self): m2 = Miller.from_highest_indices(phase=TETRAGONAL_PHASE, uvw=[1, 2, 3]) assert np.allclose(np.max(m2.uvw, axis=0), [1, 2, 3]) - with pytest.raises( - ValueError, match="Either highest `hkl` or `uvw` indices " - ): + with pytest.raises(ValueError, match="Either highest `hkl` or `uvw` indices "): _ = Miller.from_highest_indices(phase=TETRAGONAL_PHASE) with pytest.raises(ValueError, match="All indices*"): - _ = Miller.from_highest_indices( - phase=TETRAGONAL_PHASE, hkl=[3, 2, -1] - ) + _ = Miller.from_highest_indices(phase=TETRAGONAL_PHASE, hkl=[3, 2, -1]) def test_init_from_min_dspacing(self): # Tested against EMsoft v5.0 - m1 = Miller.from_min_dspacing( - phase=TETRAGONAL_PHASE, min_dspacing=0.05 - ) + m1 = Miller.from_min_dspacing(phase=TETRAGONAL_PHASE, min_dspacing=0.05) assert m1.coordinate_format == "hkl" assert m1.size == 14078 assert np.allclose(np.max(m1.hkl, axis=0), [9, 9, 19]) @@ -276,9 +243,7 @@ def test_unique(self): m3, idx = m2.unit.unique(return_index=True) assert m3.size == 205 assert isinstance(m3, Miller) - assert np.allclose( - idx[:10], [65, 283, 278, 269, 255, 235, 208, 282, 272, 276] - ) + assert np.allclose(idx[:10], [65, 283, 278, 269, 255, 235, 208, 282, 272, 276]) def test_multiply_orientation(self): o = Orientation.from_euler(np.deg2rad([45, 0, 0])) @@ -287,38 +252,26 @@ def test_multiply_orientation(self): m2 = o * m assert isinstance(m2, Miller) assert m2.coordinate_format == "hkl" - assert np.allclose( - m2.data, [[np.sqrt(2), 0, 1], [np.sqrt(2), -np.sqrt(2), 0]] - ) + assert np.allclose(m2.data, [[np.sqrt(2), 0, 1], [np.sqrt(2), -np.sqrt(2), 0]]) def test_overwritten_vector3d_methods(self): lattice = Lattice(1, 1, 1, 90, 90, 90) - phase1 = Phase( - point_group="m-3m", structure=Structure(lattice=lattice) - ) + phase1 = Phase(point_group="m-3m", structure=Structure(lattice=lattice)) phase2 = Phase(point_group="432", structure=Structure(lattice=lattice)) m1 = Miller(hkl=[[1, 1, 1], [2, 0, 0]], phase=phase1) m2 = Miller(hkil=[[1, 1, -2, 0], [2, 1, -3, 1]], phase=phase2) assert not m1._compatible_with(m2) - with pytest.raises( - ValueError, match="The crystal lattices and symmetries" - ): + with pytest.raises(ValueError, match="The crystal lattices and symmetries"): _ = m1.angle_with(m2) - with pytest.raises( - ValueError, match="The crystal lattices and symmetries" - ): + with pytest.raises(ValueError, match="The crystal lattices and symmetries"): _ = m1.cross(m2) - with pytest.raises( - ValueError, match="The crystal lattices and symmetries" - ): + with pytest.raises(ValueError, match="The crystal lattices and symmetries"): _ = m1.dot(m2) - with pytest.raises( - ValueError, match="The crystal lattices and symmetries" - ): + with pytest.raises(ValueError, match="The crystal lattices and symmetries"): _ = m1.dot_outer(m2) m3 = Miller(hkl=[[2, 0, 0], [1, 1, 1]], phase=phase1) @@ -326,14 +279,10 @@ def test_overwritten_vector3d_methods(self): def test_is_hexagonal(self): assert Miller(hkil=[1, 1, -2, 1], phase=TRIGONAL_PHASE).is_hexagonal - assert not Miller( - hkil=[1, 1, -2, 1], phase=TETRAGONAL_PHASE - ).is_hexagonal + assert not Miller(hkil=[1, 1, -2, 1], phase=TETRAGONAL_PHASE).is_hexagonal def test_various_shapes(self): - v = np.array( - [[1, 2, 0], [3, 1, 1], [1, 1, 1], [2, 0, 0], [1, 2, 3], [2, 2, 2]] - ) + v = np.array([[1, 2, 0], [3, 1, 1], [1, 1, 1], [2, 0, 0], [1, 2, 3], [2, 2, 2]]) # Initialization of vectors work as expected shape1 = (2, 3) @@ -443,9 +392,7 @@ def test_transform_space(self): assert not np.may_share_memory(v1, v2) # Incorrect space - with pytest.raises( - ValueError, match="`space_in` and `space_out` must be one " - ): + with pytest.raises(ValueError, match="`space_in` and `space_out` must be one "): _transform_space(v1, "direct", "cartesian", lattice) # uvw -> hkl -> uvw @@ -515,12 +462,8 @@ def test_uvw2UVTW(self): assert np.allclose(m1.unit.data, m2.unit.data) # MTEX convention - assert np.allclose( - _uvw2UVTW(uvw, convention="mtex") / 3, _uvw2UVTW(uvw) - ) - assert np.allclose( - _UVTW2uvw(UVTW, convention="mtex") * 3, _UVTW2uvw(UVTW) - ) + assert np.allclose(_uvw2UVTW(uvw, convention="mtex") / 3, _uvw2UVTW(uvw)) + assert np.allclose(_UVTW2uvw(UVTW, convention="mtex") * 3, _UVTW2uvw(UVTW)) def test_mtex_convention(self): # Same result without convention="mtex" because of rounding... @@ -579,18 +522,12 @@ def test_trigonal_crystal(self): m7 = Miller(hkil=[-1, -1, 2, 0], phase=TRIGONAL_PHASE) assert np.allclose(m6.angle_with(m7, degrees=True)[0], 180) - assert np.allclose( - m6.angle_with(m7, use_symmetry=True, degrees=True)[0], 60 - ) + assert np.allclose(m6.angle_with(m7, use_symmetry=True, degrees=True)[0], 60) def test_convention_not_met(self): - with pytest.raises( - ValueError, match="The Miller-Bravais indices convention" - ): + with pytest.raises(ValueError, match="The Miller-Bravais indices convention"): _ = Miller(hkil=[1, 1, -1, 0], phase=TETRAGONAL_PHASE) - with pytest.raises( - ValueError, match="The Miller-Bravais indices convention" - ): + with pytest.raises(ValueError, match="The Miller-Bravais indices convention"): _ = Miller(UVTW=[1, 1, -1, 0], phase=TETRAGONAL_PHASE) @@ -602,9 +539,7 @@ def test_tetragonal_crystal(self): lattice = TETRAGONAL_LATTICE # Example 1.1: Direct metric tensor - assert np.allclose( - lattice.metrics, [[0.25, 0, 0], [0, 0.25, 0], [0, 0, 1]] - ) + assert np.allclose(lattice.metrics, [[0.25, 0, 0], [0, 0.25, 0], [0, 0, 1]]) # Example 1.2: Distance between two points (length of a vector) answer = np.sqrt(5) / 4 # nm @@ -618,15 +553,11 @@ def test_tetragonal_crystal(self): m2 = Miller(uvw=[1, 2, 0], phase=TETRAGONAL_PHASE) m3 = Miller(uvw=[3, 1, 1], phase=TETRAGONAL_PHASE) assert np.allclose(m2.dot(m3), 5 / 4) # nm^2 - assert np.allclose( - m2.angle_with(m3, degrees=True)[0], 53.30, atol=0.01 - ) + assert np.allclose(m2.angle_with(m3, degrees=True)[0], 53.30, atol=0.01) # Example 1.5: Reciprocal metric tensor lattice_recip = lattice.reciprocal() - assert np.allclose( - lattice_recip.metrics, [[4, 0, 0], [0, 4, 0], [0, 0, 1]] - ) + assert np.allclose(lattice_recip.metrics, [[4, 0, 0], [0, 4, 0], [0, 0, 1]]) # Example 1.6, 1.7: Angle between two plane normals m4 = Miller(hkl=[1, 2, 0], phase=TETRAGONAL_PHASE) From 37a9db727bc88d033bba2f6091549df60b801df0 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 16:08:59 -0600 Subject: [PATCH 03/40] update _get_laue_group_name to be proceedural instead of a name-based lookup table --- orix/quaternion/symmetry.py | 66 ++++++++++++++++---------- orix/tests/quaternion/test_symmetry.py | 3 +- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 3819ea76d..e933e3973 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1002,6 +1002,7 @@ def get_point_groups(subset: str = "unique"): C2, # Orthorhombic D2, + D4, # Tetragonal C4, # Trigonal @@ -1015,7 +1016,26 @@ def get_point_groups(subset: str = "unique"): O, ] elif subset == "laue": - return [x * Ci for x in get_point_groups("proper")] + return [ + # Triclinic + Ci, + # Monoclinic + C2h, + # Orthorhombic + D2h, + D4h, + # Tetragonal + C4h, + # Trigonal + C3i, + D3d, + # Hexagonal + C6h, + D6h, + # cubic + Th, + Oh, + ] elif subset == "proper_all": return [ # Triclinic @@ -1238,30 +1258,26 @@ def get_point_group_from_space_group( def _get_laue_group_name(name: str) -> str | None: - if name in ["1", "-1"]: - return "-1" - elif name in ["2", "211", "121", "112", "m11", "1m1", "11m", "2/m"]: - return "2/m" - elif name in ["222", "mm2", "mmm"]: - return "mmm" - elif name in ["4", "-4", "4/m"]: - return "4/m" - elif name in ["422", "4mm", "-42m", "4/mmm"]: - return "4/mmm" - elif name in ["3", "-3"]: - return "-3" - elif name in ["321", "312", "32", "3m", "-3m"]: - return "-3m" - elif name in ["6", "-6", "6/m"]: - return "6/m" - elif name in ["6mm", "-6m2", "6/mmm", "622"]: - return "6/mmm" - elif name in ["23", "m-3"]: - return "m-3" - elif name in ["432", "-43m", "m-3m"]: - return "m-3m" - else: - return None + # search through all the point groups defined in orix for one with a + # matching name. + valid_name = False + for g in get_point_groups("all_repeated"): + if g.name == name: + valid_name = True + break + if valid_name == False: + raise ValueError("{} is not a valid point group name") + # add an inversion to get the laue group. + g_laue = _get_unique_symmetry_elements(g, Ci) + # find a laue group with matching operators + for laue in get_point_groups("laue"): + # first check for length + if g_laue.shape != laue.shape: + continue + # then check for identical operators (regardless of order) + if np.min(g_laue.outer(laue).angle ** 2, 1).max() < 1e-4: + return laue.name + raise ValueError("Could not find Laue group name for {}".format(g.name)) def _get_unique_symmetry_elements( diff --git a/orix/tests/quaternion/test_symmetry.py b/orix/tests/quaternion/test_symmetry.py index 923890a06..070045e56 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -913,7 +913,8 @@ def test_laue_group_name(self): assert D6h.laue.name == "6/mmm" assert Th.laue.name == "m-3" assert Oh.laue.name == "m-3m" - assert Symmetry(((1, 0, 0, 0), (1, 1, 0, 0))).laue.name is None + with pytest.raises(ValueError): + Symmetry(((1, 0, 0, 0), (1, 1, 0, 0))).laue.name class TestEulerFundamentalRegion: From 445435f963b23202709433e4d20ad89d12a4a38c Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:39:17 -0600 Subject: [PATCH 04/40] expand symmetry marker and add example --- .../plot_symmetry_operations.py | 161 ++++++++++++---- orix/plot/_symmetry_marker.py | 125 ------------- orix/plot/stereographic_plot.py | 173 +++++++++++++++--- orix/quaternion/symmetry.py | 8 +- orix/tests/plot/test_stereographic_plot.py | 117 ++++-------- orix/tests/quaternion/test_symmetry.py | 42 +++++ 6 files changed, 350 insertions(+), 276 deletions(-) delete mode 100644 orix/plot/_symmetry_marker.py diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index f50c03150..ed4460041 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -3,51 +3,134 @@ Plot symmetry operations ======================== -This example shows how to draw proper symmetry operations :math:`s` -(no reflections or inversions). +This example is one method for producing stereographic projections with +symmetry operators for the 32 crystallographic point groups. This method +loosely follows the one laid out in section 9.2 of "Structures of Materials" +(DeGraef et.al, 2nd edition, 2012). + +There are generated proceedurally, and vary slightly from some other +approaches. Consider, for example, more curated +approaches. Consider, for example, the plot for the D2h == "mmm" point group, +which displays the inversion center as a dot in the center 2-fold marker, +whereas Figure 9.9 of "Structure of Materials" leaves these markers out. +Additionally, like the International tables of crystallography (ITOC) Table +10.2.2, which is the generally accepted standard for stereographic projections, +the inversion symmetry dots have been removed from markers along the +equator. """ import matplotlib.pyplot as plt +import numpy as np from orix import plot +from orix.quaternion.symmetry import * from orix.vector import Vector3d -marker_size = 200 -fig, (ax0, ax1) = plt.subplots( - ncols=2, - subplot_kw={"projection": "stereographic"}, - layout="tight", -) +# generate a list of the 32 crystallographic point groups. +# NOTE: This could instead be done with "get_point_groups()", but the +# following list shows a more logical addition of symmetry operators. +point_groups = get_point_groups("procedural") + + +# Set marker sizes and colors to help differentiate elements +s = 160 +colors = {1: "magenta", 2: "green", 3: "red", 4: "purple", 6: "black"} +mirror_linewidth = 1 +mirror_color = "blue" + -ax0.set_title("432", pad=20) -# 4-fold (outer markers will be clipped a bit...) -v4fold = Vector3d([[0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]]) -ax0.symmetry_marker(v4fold, fold=4, c="C4", s=marker_size) -ax0.draw_circle(v4fold, color="C4") -# 3-fold -v3fold = Vector3d([[1, 1, 1], [1, -1, 1], [-1, -1, 1], [-1, 1, 1]]) -ax0.symmetry_marker(v3fold, fold=3, c="C3", s=marker_size) -ax0.draw_circle(v3fold, color="C3") -# 2-fold -# fmt: off -v2fold = Vector3d( - [ - [ 1, 0, 1], - [ 0, 1, 1], - [-1, 0, 1], - [ 0, -1, 1], - [ 1, 1, 0], - [-1, -1, 0], - [-1, 1, 0], - [ 1, -1, 0], - ] +# Create the plot and subplots using ORIX's stereographic projection +fig, ax = plt.subplots( + 4, 8, subplot_kw={"projection": "stereographic"}, figsize=[14, 10] ) -# fmt: on -ax0.symmetry_marker(v2fold, fold=2, c="C2", s=marker_size) -ax0.draw_circle(v2fold, color="C2") - -ax1.set_title("222", pad=20) -# 2-fold -v2fold = Vector3d([[0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]]) -ax1.symmetry_marker(v2fold, fold=2, c="C2", s=2 * marker_size) -ax1.draw_circle(v2fold, color="C2") +ax = ax.flatten() + + +# Iterate through the 32 Point groups +for i, pg in enumerate(point_groups): + ax[i].set_title(pg.name) + # get unique axis families (should just be <100>, <110>, and/or <111>) + unique_axes = Vector3d( + np.unique(np.around(pg.axis.in_fundamental_sector(pg).data, 5), axis=0) + ) + # create masks to sort out which elements are rotations, mirrors, + # inversions, and rotoinversions + p_mask = ~pg.improper + m_mask = (np.abs(pg.angle - np.pi) < 1e-4) * pg.improper + r_mask = pg.angle**2 > 1e-4 + roto_mask = r_mask * ~p_mask * ~m_mask + i_mask = (~r_mask) * pg.improper + # to avoid repetition, look at only the unique fundamental representations + # of the possible rotation axes + fs_axes = pg.axis.in_fundamental_sector(pg) + decorated_axes = [] + + # iterate through each primary axis, plotting their symmetry elements + # as we go. + for axis in unique_axes: + axis_mask = np.sum(np.abs((fs_axes - axis).data), 1) < 1e-4 + # plot any mirror planes perpendicular to the axis first + if np.any(m_mask * axis_mask): + for v in pg * axis: + ax[i].plot( + v.get_circle(), + color=mirror_color, + linewidth=mirror_linewidth, + ) + # if all rotations are proper rotations, plot the appropriate symbol + if np.all(p_mask[axis_mask]): + # if the only element is identity, move on. + if not np.any(r_mask * axis_mask): + continue + min_ang = np.abs(pg[r_mask * axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + c = colors[f] + ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) + decorated_axes.append(axis * 1) + # if there is an inversion center, plot the appropriate symbol + elif np.any(i_mask): + # this might just be the 1-fold inversion center + if not np.any(r_mask * p_mask): + f = 1 + else: + min_ang = np.abs(pg[r_mask * axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + c = colors[f] + if axis.z[0] ** 2 > 1e-4: + ax[i].symmetry_marker( + (pg * axis), fold=f, s=s, color=c, inner="dot" + ) + else: + ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) + + decorated_axes.append(axis * 1) + # the other option (besides empty) is a rotoinversion + elif np.any(roto_mask[axis_mask]): + min_ang = np.abs(pg[roto_mask * axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + c = colors[f] + if axis.z[0] ** 2 > 1e-4: + ax[i].symmetry_marker( + (pg * axis), fold=f, s=s, color=c, inner="half" + ) + else: + ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) + decorated_axes.append(axis * 1) + # Three-fold rotations around the 111 create a special subset of mirror + # planes, which for ease we will add in by hand. + if np.any(unique_axes.dot(Vector3d([1, 1, 1])) > 1.73): + m_vectors = Vector3d([[0, 0, 1], [0, 1, 1]]) + for v in (pg.outer(m_vectors)).flatten().unique(): + ax[i].plot(v.get_circle(), color="blue", linewidth=1) + + # Finally, the combination of inversion center and mirror planes + # creates 2-fold symmetries not on the primary axes. Let's add in any + # that didn't already get included from other operations + if np.sum(m_mask) > 1 and np.sum(i_mask) > 0: + dax = Vector3d([x.data for x in decorated_axes]) + dax_unique = dax.flatten().unique() + two_folds = pg.axis[m_mask].in_fundamental_sector(pg) + mask = np.abs(two_folds.dot_outer(dax_unique)).max(axis=1) < 0.99 + new_two_folds = two_folds[mask] + symm_two_folds = pg.outer(new_two_folds).flatten().unique() + ax[i].symmetry_marker(symm_two_folds, fold=2, s=s, color="g") diff --git a/orix/plot/_symmetry_marker.py b/orix/plot/_symmetry_marker.py deleted file mode 100644 index b02472c19..000000000 --- a/orix/plot/_symmetry_marker.py +++ /dev/null @@ -1,125 +0,0 @@ -# Copyright 2018-2024 the orix developers -# -# This file is part of orix. -# -# orix is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# orix is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with orix. If not, see . - -"""Symmetry element markers to plot in stereographic representations of -crystallographic point groups. - -Meant to be used indirectly in -:func:`~orix.plot.StereographicPlot.symmetry_marker`. -""" - -from typing import Union - -import matplotlib.path as mpath -import matplotlib.transforms as mtransforms -import numpy as np - -from orix.vector import Vector3d - - -class SymmetryMarker: - """Symmetry marker for use in plotting. - - Parameters - ---------- - v - size - """ - - fold = None - _marker = None - - def __init__(self, v: Union[Vector3d, np.ndarray, list, tuple], size: int = 1): - self._vector = Vector3d(v) - self._size = size - - @property - def angle_deg(self) -> np.ndarray: - """Position in degrees.""" - return np.rad2deg(self._vector.azimuth) + 90 - - @property - def size(self) -> np.ndarray: - """Multiplicity of each symmetry marker.""" - return np.ones(self.n) * self._size - - @property - def n(self) -> int: - """Number of symmetry markers.""" - return self._vector.size - - def __iter__(self): - for v, marker, size in zip(self._vector, self._marker, self.size): - yield v, marker, size - - -class TwoFoldMarker(SymmetryMarker): - """Two-fold symmetry marker.""" - - fold = 2 - - @property - def size(self) -> np.ndarray: - """Multiplicity of each symmetry marker.""" - # Assuming maximum polar angle is 90 degrees - radial = np.tan(self._vector.polar / 2) - radial = np.where(radial == 0, 1, radial) - return self._size / np.sqrt(radial) - - @property - def _marker(self): - # Make an ellipse path (https://matplotlib.org/stable/api/path_api.html) - circle = mpath.Path.circle() - verts = np.copy(circle.vertices) # Paths considered immutable - verts[:, 0] *= 2 - ellipse = mpath.Path(verts, circle.codes) - - # Set up rotations of ellipse - azimuth = self._vector.azimuth - trans = [mtransforms.Affine2D().rotate(a + (np.pi / 2)) for a in azimuth] - - return [ellipse.deepcopy().transformed(i) for i in trans] - - -class ThreeFoldMarker(SymmetryMarker): - """Three-fold symmetry marker.""" - - fold = 3 - - @property - def _marker(self): - return [(3, 0, angle) for angle in self.angle_deg] - - -class FourFoldMarker(SymmetryMarker): - """Four-fold symmetry marker.""" - - fold = 4 - - @property - def _marker(self): - return ["D"] * self.n - - -class SixFoldMarker(SymmetryMarker): - """Six-fold symmetry marker.""" - - fold = 6 - - @property - def _marker(self): - return ["h"] * self.n diff --git a/orix/plot/stereographic_plot.py b/orix/plot/stereographic_plot.py index 188532441..d4da01138 100644 --- a/orix/plot/stereographic_plot.py +++ b/orix/plot/stereographic_plot.py @@ -29,20 +29,14 @@ import matplotlib.path as mpath import matplotlib.projections as mprojections import matplotlib.pyplot as plt +import matplotlib.transforms as mtransforms import numpy as np -# fmt: off -# isort: off from orix.measure import pole_density_function -from orix.plot._symmetry_marker import ( - TwoFoldMarker, - ThreeFoldMarker, - FourFoldMarker, - SixFoldMarker, +from orix.projections import ( + InverseStereographicProjection, + StereographicProjection, ) -# isort: on -# fmt: on -from orix.projections import InverseStereographicProjection, StereographicProjection from orix.vector import FundamentalSector, Vector3d from orix.vector.fundamental_sector import _closed_edges_in_hemisphere @@ -458,7 +452,12 @@ def draw_circle( other_hemisphere = {"upper": "lower", "lower": "upper"} self._hemisphere = other_hemisphere[self._hemisphere] for i, c in enumerate(circles): - self.plot(c.azimuth, c.polar, color=color2[i], **reproject_plot_kwargs) + self.plot( + c.azimuth, + c.polar, + color=color2[i], + **reproject_plot_kwargs, + ) self._hemisphere = other_hemisphere[self._hemisphere] def restrict_to_sector( @@ -518,7 +517,11 @@ def restrict_to_sector( self.patches[0].set_visible(False) if show_edges: - for k, v in [("facecolor", "none"), ("edgecolor", "k"), ("linewidth", 1)]: + for k, v in [ + ("facecolor", "none"), + ("edgecolor", "k"), + ("linewidth", 1), + ]: kwargs.setdefault(k, v) patch = mpatches.PathPatch( mpath.Path(np.column_stack([x, y]), closed=True), @@ -630,34 +633,35 @@ def stereographic_grid( self.collections[index_polar].remove() self._stereographic_grid = False - def symmetry_marker(self, v: Vector3d, fold: int, **kwargs): - """Plot 2-, 3- 4- or 6-fold symmetry marker(s). + def symmetry_marker(self, v: Vector3d, fold: int, inner="fill", **kwargs): + """Plot symmetry marker(s). Parameters ---------- v Position of the marker(s) to plot. fold - Which symmetry element to plot, can be either 2, 3, 4 or 6. + Which symmetry element to plot, can be either 1, 2, 3, 4 or 6. **kwargs Keyword arguments passed to :meth:`scatter`. """ - if fold not in [2, 3, 4, 6]: - raise ValueError("Can only plot 2-, 3-, 4- or 6-fold elements.") + if fold not in [1, 2, 3, 4, 6]: + raise ValueError("Can only plot 1-, 2-, 3-, 4- or 6-fold elements.") - marker_classes = { - "2": TwoFoldMarker, - "3": ThreeFoldMarker, - "4": FourFoldMarker, - "6": SixFoldMarker, - } - marker = marker_classes[str(fold)](v, size=kwargs.pop("s", 1)) + marker = SymmetryMarker(v, size=kwargs.pop("s", 1), folds=fold, inner=inner) new_kwargs = dict(zorder=ZORDER["symmetry_marker"], clip_on=False) for k, v in new_kwargs.items(): kwargs.setdefault(k, v) for vec, marker, marker_size in marker: + if fold == 1: + marker_size = marker_size / 2 + if inner != "fill": + bg_kwargs = deepcopy(kwargs) + if "color" in bg_kwargs: + bg_kwargs.pop("color") + self.scatter(vec, color="w", s=marker_size * 0.5, **bg_kwargs) self.scatter(vec, marker=marker, s=marker_size, **kwargs) # TODO: Find a way to control padding, so that markers aren't @@ -968,3 +972,124 @@ def _order_in_hemisphere(polar: np.ndarray, pole: int) -> Union[np.ndarray, None order = np.roll(order, shift=-(indices[-1] + 1)) return order + + +class SymmetryMarker: + """ + Symmetry element markers to plot in stereographic representations of + crystallographic point groups. + + Intended to be used indirectly in + :func:`~orix.plot.StereographicPlot.symmetry_marker`. + + Parameters + ---------- + v (Vector3d object) + Vector3d object used for placing marker in StereographicPlot + + size (int or float) + value passed to matplotlib to determine relative marker size + + folds (integer) + rotational symmetry (typically 1, 2, 3, 4, or 6) for the + symmetry marker to represent with it's shape (circle, elliplse, + triangle, square, or hex)' + + inner ('fill' or 'dot' or 'half') + The shape to put inside the symmetry marker to express additional + symmetry information 'fill' adds nothing, 'dot' adds the dot + traditionally used for inversion centers and mirrors, and 'half' adds + an empty polygon with half as many folds as the marker within the + marker, such as what is used for rotoinversions. + """ + + def __init__( + self, + v: Union[Vector3d, np.ndarray, list, tuple], + size: int = 1, + folds=2, + inner="fill", + ): + assert np.isin(folds, [1, 2, 3, 4, 6]) + assert np.isin(inner, ["fill", "dot", "half"]) + self._vector = Vector3d(v) + self._size = size + self._folds = folds + self._inner_shape = inner + + @property + def angle_deg(self) -> np.ndarray: + """Position in degrees.""" + return np.rad2deg(self._vector.azimuth) + 90 + + @property + def size(self) -> np.ndarray: + """Multiplicity of each symmetry marker.""" + return np.ones(self.n) * self._size + + @property + def n(self) -> int: + """Number of symmetry markers.""" + return self._vector.size + + def __iter__(self): + for v, marker, size in zip(self._vector, self._marker, self.size): + yield v, marker, size + + @property + def _marker(self): + azimuth = self._vector.azimuth + # pre-define inner cirle (reused) + inner_circle = mpath.Path.circle((0, 0), 0.5) + i_vert = np.copy(inner_circle.vertices) + i_code = inner_circle.codes + # pre-define 2-fold ellipse (reused) + e_vert = np.copy(i_vert * 2) + e_code = np.copy(i_code) + e_vert[:, 1] = e_vert[:, 1] * ((1 - e_vert[:, 0] ** 2) ** 0.5) * 0.35 + if self._folds == 1: + # return either a normal circle, or a cirle with a dot in the + # center if this also anie, inversion center + circle = mpath.Path.circle((0, 0), 0.75) + vert = np.copy(circle.vertices) + code = circle.codes + if self._inner_shape == "dot": + verts = np.concatenate([vert, i_vert[::-1]]) + codes = np.concatenate([code, i_code]) + marker = mpath.Path(verts, codes) + else: + marker = mpath.Path(vert, code) + return [marker.deepcopy() for ang in azimuth] + if self._folds == 2: + # use the ellipse defined above + marker = mpath.Path(e_vert, e_code) + else: + # if it's not 2-fold, just use a default polygon + marker = mpath.Path.unit_regular_polygon(self._folds) + if self._inner_shape == "dot": + # add the inner circle + verts = np.concatenate([marker.vertices, i_vert[::-1]]) + codes = np.concatenate([marker.codes, i_code]) + marker = mpath.Path(verts, codes) + elif self._inner_shape == "half": + # add an inner shape with half the folds + vert = np.copy(marker.vertices) + code = np.copy(marker.codes) + if self._folds == 4: + inner = mpath.Path(e_vert, e_code) + else: + inner = mpath.Path.unit_regular_polygon(int(self._folds / 2)) + i_vert = np.copy(inner.vertices[::-1]) + i_code = np.copy(inner.codes) + verts = np.concatenate([vert, i_vert]) + codes = np.concatenate([code, i_code]) + marker = mpath.Path(verts, codes) + # rotate each marker to align with local symmetry lines + # icons are not, by default, properly aligned. Let's fix that. + if self._folds == 3: + azimuth -= np.pi / 2 + azimuth[self._vector.polar > 1e-6] -= np.pi / 2 + + trans = [mtransforms.Affine2D().rotate(a + (np.pi / 2)) for a in azimuth] + + return [marker.deepcopy().transformed(i) for i in trans] diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index e933e3973..a2d0dbe30 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1060,7 +1060,7 @@ def get_point_groups(subset: str = "unique"): T, O, ] - elif subset == "SoM": + elif subset == "procedural": return [ # Cyclic C1, @@ -1105,7 +1105,7 @@ def get_point_groups(subset: str = "unique"): Oh, ] else: - ValueError("{} is not a valid subset option".format(subset)) + raise ValueError("{} is not a valid subset option".format(subset)) def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: @@ -1276,8 +1276,8 @@ def _get_laue_group_name(name: str) -> str | None: continue # then check for identical operators (regardless of order) if np.min(g_laue.outer(laue).angle ** 2, 1).max() < 1e-4: - return laue.name - raise ValueError("Could not find Laue group name for {}".format(g.name)) + if np.min(g_laue.outer(laue).angle ** 2, 0).max() < 1e-4: + return laue.name def _get_unique_symmetry_elements( diff --git a/orix/tests/plot/test_stereographic_plot.py b/orix/tests/plot/test_stereographic_plot.py index b05c520ba..8f672b002 100644 --- a/orix/tests/plot/test_stereographic_plot.py +++ b/orix/tests/plot/test_stereographic_plot.py @@ -23,17 +23,7 @@ import pytest from orix import plot - -# fmt: off -# isort: off -from orix.plot.stereographic_plot import ( - TwoFoldMarker, - ThreeFoldMarker, - FourFoldMarker, - SixFoldMarker, -) -# isort: on -# fmt: on +from orix.plot.stereographic_plot import SymmetryMarker from orix.quaternion import symmetry from orix.vector import Vector3d @@ -182,7 +172,8 @@ def test_show_hemisphere_label(self): plt.close("all") @pytest.mark.parametrize( - "hemisphere, pole, hemi_str", [("uPPer", -1, "upper"), ("loweR", 1, "lower")] + "hemisphere, pole, hemi_str", + [("uPPer", -1, "upper"), ("loweR", 1, "lower")], ) def test_hemisphere_pole(self, hemisphere, pole, hemi_str): _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) @@ -296,83 +287,34 @@ def test_size_parameter(self): class TestSymmetryMarker: - def test_properties(self): - v2fold = Vector3d([[1, 0, 1], [0, 1, 1]]) - marker2fold = TwoFoldMarker(v2fold) - assert np.allclose(v2fold.data, marker2fold._vector.data) - assert marker2fold.fold == 2 - assert marker2fold.n == 2 - assert np.allclose(marker2fold.size, [1.55, 1.55], atol=1e-2) - assert isinstance(marker2fold._marker[0], mpath.Path) - - v3fold = Vector3d([1, 1, 1]) - marker3fold = ThreeFoldMarker(v3fold, size=5) - assert np.allclose(v3fold.data, marker3fold._vector.data) - assert marker3fold.fold == 3 - assert marker3fold.n == 1 - assert np.allclose(marker3fold.size, 5) - - # Iterating over markers - for i, (vec, mark, size) in enumerate(marker3fold): - assert np.allclose(vec.data, v3fold[i].data) - assert np.allclose(mark, (3, 0, 45 + 90)) - assert size == 5 - - v4fold = Vector3d([[0, 0, 1], [1, 0, 0], [0, 1, 0]]) - marker4fold = FourFoldMarker(v4fold, size=11) - assert np.allclose(v4fold.data, marker4fold._vector.data) - assert marker4fold.fold == 4 - assert marker4fold.n == 3 - assert np.allclose(marker4fold.size, [11, 11, 11]) - assert marker4fold._marker == ["D"] * 3 - - marker6fold = SixFoldMarker([0, 0, 1], size=15) - assert isinstance(marker6fold._vector, Vector3d) - assert np.allclose(marker6fold._vector.data, [0, 0, 1]) - assert marker6fold.fold == 6 - assert marker6fold.n == 1 - assert marker6fold.size == 15 - assert marker6fold._marker == ["h"] - - plt.close("all") + @pytest.mark.parametrize("v_data", [[0, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]) + @pytest.mark.parametrize("folds", [1, 2, 3, 4, 6]) + @pytest.mark.parametrize("fill", ["fill", "dot", "half"]) + def test_main_properties(self, v_data, folds, fill): + v = Vector3d(v_data) + marker = SymmetryMarker(v, folds=folds, inner=fill) + assert np.allclose(v.data, marker._vector.data) + assert marker._folds == folds + assert marker._inner_shape == fill + assert (marker.angle_deg - (np.rad2deg(v.azimuth) + 90)) ** 2 < 1e-4 def test_plot_symmetry_marker(self): _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) ax.stereographic_grid(False) marker_size = 500 - v4fold = Vector3d([[0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]]) - ax.symmetry_marker(v4fold, fold=4, c="C4", s=marker_size) - - v3fold = Vector3d([[1, 1, 1], [1, -1, 1], [-1, -1, 1], [-1, 1, 1]]) - ax.symmetry_marker(v3fold, fold=3, c="C3", s=marker_size) - - v2fold = Vector3d( - [ - [1, 0, 1], - [0, 1, 1], - [-1, 0, 1], - [0, -1, 1], - [1, 1, 0], - [-1, -1, 0], - [-1, 1, 0], - [1, -1, 0], - ] - ) - ax.symmetry_marker(v2fold, fold=2, c="C2", s=marker_size) - - ax.symmetry_marker([0, 0, 1], fold=6, s=marker_size) + v = Vector3d([[1, 0, 0], [0, 1, 1]]) + for i in [1, 2, 3, 4, 6]: + ax.symmetry_marker(v[0], fold=i, s=marker_size, color="k") + ax.symmetry_marker(v[1], fold=i, inner="dot", s=marker_size) + ax.symmetry_marker(v, fold=1, inner="dot", color="C1", s=marker_size) + for i in [4, 6]: + ax.symmetry_marker(v, fold=i, inner="half", s=marker_size) markers = ax.collections - assert len(markers) == 18 - assert np.allclose(markers[0]._sizes, marker_size) - assert np.allclose(markers[-1]._sizes, marker_size) - assert np.allclose(markers[0]._facecolors, mcolors.to_rgba("C4")) - assert np.allclose(markers[5]._facecolors, mcolors.to_rgba("C3")) - assert np.allclose(markers[-2]._facecolors, mcolors.to_rgba("C2")) - assert np.allclose(markers[-1]._facecolors, mcolors.to_rgba("C0")) - - with pytest.raises(ValueError, match="Can only plot 2"): + assert len(markers) == 43 + + with pytest.raises(ValueError, match="Can only plot 1"): ax.symmetry_marker([0, 0, 1], fold=5) plt.close("all") @@ -432,8 +374,14 @@ def test_draw_circle(self): assert len(ax[1].lines) == 3 assert ax[0].lines[0]._path._vertices.shape == (upper_steps, 2) assert ax[1].lines[0]._path._vertices.shape == (lower_steps, 2) - assert ax[1].lines[1]._path._vertices.shape == (lower_steps // 2 + 1, 2) - assert ax[1].lines[1]._path._vertices.shape == (lower_steps // 2 + 1, 2) + assert ax[1].lines[1]._path._vertices.shape == ( + lower_steps // 2 + 1, + 2, + ) + assert ax[1].lines[1]._path._vertices.shape == ( + lower_steps // 2 + 1, + 2, + ) plt.close("all") @@ -482,7 +430,8 @@ def test_pdf_args(self): def test_pdf_args_raises(self): fig, ax = plt.subplots(subplot_kw=dict(projection="stereographic")) with pytest.raises( - TypeError, match="If one argument is passed it must be an instance of " + TypeError, + match="If one argument is passed it must be an instance of ", ): ax.pole_density_function("test") diff --git a/orix/tests/quaternion/test_symmetry.py b/orix/tests/quaternion/test_symmetry.py index 070045e56..67dff03de 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -890,6 +890,48 @@ def test_equality(symmetry): assert Rotation(symmetry) == symmetry +@pytest.mark.parametrize( + ["subset", "length", "proper_count"], + [ + ["unique", 32, 11], + ["all", 37, 14], + ["all_repeated", 44, 17], + ["proper", 11, 11], + ["proper_all", 14, 14], + ["laue", 11, 0], + ["procedural", 32, 11], + ], +) +def test_get_point_groups(subset, length, proper_count): + # check that we get the expected number of proper and total point groups. + group = get_point_groups(subset) + assert len(group) == length + assert np.sum([x.is_proper for x in group]) == proper_count + + +def test_get_point_groups_unique(): + group = get_point_groups() + # this is just a check to see if each element is unique, and if there are + # 32 of them. + assert np.all( + np.sum( + [ + [ + _get_unique_symmetry_elements(a, b) == b + and _get_unique_symmetry_elements(a, b) == a + for a in group + ] + for b in group + ], + 1, + ) + == np.ones(32) + ) + # additional test that nonsense returns nonsense + with pytest.raises(ValueError): + get_point_groups("banana") + + class TestLaueGroup: def test_crystal_system(self): assert Ci.system == "triclinic" From 8e3e49031b66c1e240683333c975cc5545ff3dbe Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:56:00 -0600 Subject: [PATCH 05/40] formatting --- .../stereographic_projection/plot_symmetry_operations.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index ed4460041..7d4c28947 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -97,9 +97,7 @@ f = np.around(2 * np.pi / min_ang).astype(int) c = colors[f] if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker( - (pg * axis), fold=f, s=s, color=c, inner="dot" - ) + ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c, inner="dot") else: ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) @@ -110,9 +108,7 @@ f = np.around(2 * np.pi / min_ang).astype(int) c = colors[f] if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker( - (pg * axis), fold=f, s=s, color=c, inner="half" - ) + ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c, inner="half") else: ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) decorated_axes.append(axis * 1) From c9d17abbc4b880d7a5918927f81b65b17a35384a Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:15:01 -0600 Subject: [PATCH 06/40] make tables sphinx-compatable --- orix/quaternion/symmetry.py | 105 ++++++++++++++++++++---------------- 1 file changed, 59 insertions(+), 46 deletions(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index a2d0dbe30..d3a2b18b4 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -255,7 +255,9 @@ def fundamental_sector(self) -> "FundamentalSector": # Taken from MTEX center = Vector3d([0.707558, -0.000403, 0.706655]) elif name in ["m-3", "432"]: - n = Vector3d(np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data])) + n = Vector3d( + np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data]) + ) # Taken from MTEX center = Vector3d([0.349928, 0.348069, 0.869711]) elif name == "-43m": @@ -363,7 +365,9 @@ def __and__(self, other: Symmetry) -> Symmetry: return Symmetry.from_generators(*generators) def __hash__(self) -> int: - return hash(self.name.encode() + self.data.tobytes() + self.improper.tobytes()) + return hash( + self.name.encode() + self.data.tobytes() + self.improper.tobytes() + ) # ------------------------ Class methods ------------------------- # @@ -421,7 +425,9 @@ def get_axis_orders(self) -> dict[Vector3d, int]: return {} return { Vector3d(a): b + 1 - for a, b in zip(*np.unique(s.axis.data, axis=0, return_counts=True)) + for a, b in zip( + *np.unique(s.axis.data, axis=0, return_counts=True) + ) } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: @@ -667,9 +673,15 @@ def plot( D4h.name = "4/mmm" # 3-fold rotations -C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)]) -C3y = Symmetry([(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)]) -C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))]) +C3x = Symmetry( + [(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)] +) +C3y = Symmetry( + [(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)] +) +C3z = Symmetry( + [(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))] +) C3 = Symmetry(C3z) C3.name = "3" @@ -771,83 +783,84 @@ def get_point_groups(subset: str = "unique"): For convenience, the following table shows the 38 point groups contained in "all", of which the other lists are subsets of. - +-------------+--------+---------------------+------------+------------+ - | Schoenflies | System | ITOC/Herman_Mauguin | Laue class | Proper PG | - +-------------+--------+---------------------+------------+------------+ + + +-------------+--------------+---------------+------------+------------+ + | Schoenflies | System | HM | Laue Class | Proper PG | + +=============+==============+===============+============+============+ | C1 | Triclinic | 1 | -1 | 1 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Ci | Triclinic | -1 | -1 | 1 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C2 | Monoclinic | 2 or 112 | 2/m | 112 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C2x | Monoclinic | 211 | 2/m | 211 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C2y | Monoclinic | 121 | 2/m | 121 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Cs | Monoclinic | 11m or m | 2/m | 1 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Csx | Monoclinic | m11 | 2/m | 1 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Csy | Monoclinic | 1m1 | 2/m | 1 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C2h | Monoclinic | 2/m | 2/m | 112 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D2 | Orthorhombic | 222 | mmm | 222 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C2v | Orthorhombic | mm2 | mmm | 211 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D2h | Orthorhombic | mmm | mmm | 222 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C4 | Tetragonal | 4 | 4/m | 4 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | S4 | Tetragonal | -4 | 4/m | 112 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C4h | Tetragonal | 4/m | 4/m | 4 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D4 | Tetragonal | 422 | 4/mmm | 422 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C4v | Tetragonal | 4mm | 4/mmm | 4 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D2d | Tetragonal | -42m | 4/mmm | 222 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D4h | Tetragonal | 4/mmm | 4/mmm | 422 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C3 | Trigonal | 3 | -3 | 3 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C3i | Trigonal | -3 | -3 | 3 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D3 | Trigonal | 32 or 321 | -3m | 32 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D3y | Trigonal | 312 | -3m | 312 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C3v | Trigonal | 3m | -3m | 3 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D3d | Trigonal | -3m | -3m | 32 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C6 | Hexagonal | 6 | 6/m | 6 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C3h | Hexagonal | -6 | 6/m | 6 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C6h | Hexagonal | 6/m | 6/m | 622 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D6 | Hexagonal | 622 | 6/mmm | 622 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C6v | Hexagonal | 6mm | 6/mmm | 6 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D3h | Hexagonal | -6m2 | 6/mmm | 312 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D6h | Hexagonal | 6/mmm | 6/mmm | 622 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | T | Cubic | 23 | m-3 | 23 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Th | Cubic | m-3 | m-3 | 23 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | O | Cubic | 432 | m-3m | 432 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Td | Cubic | -43m | m-3m | 23 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Oh | Cubic | m-3m | m-3m | 432 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ """ subset = str(subset).lower() From c01f09b4b5d4fa263082785e9e091900cb799ff5 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:12:18 -0600 Subject: [PATCH 07/40] formatting --- orix/quaternion/symmetry.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index d3a2b18b4..955ff9b2a 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -255,9 +255,7 @@ def fundamental_sector(self) -> "FundamentalSector": # Taken from MTEX center = Vector3d([0.707558, -0.000403, 0.706655]) elif name in ["m-3", "432"]: - n = Vector3d( - np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data]) - ) + n = Vector3d(np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data])) # Taken from MTEX center = Vector3d([0.349928, 0.348069, 0.869711]) elif name == "-43m": @@ -365,9 +363,7 @@ def __and__(self, other: Symmetry) -> Symmetry: return Symmetry.from_generators(*generators) def __hash__(self) -> int: - return hash( - self.name.encode() + self.data.tobytes() + self.improper.tobytes() - ) + return hash(self.name.encode() + self.data.tobytes() + self.improper.tobytes()) # ------------------------ Class methods ------------------------- # @@ -425,9 +421,7 @@ def get_axis_orders(self) -> dict[Vector3d, int]: return {} return { Vector3d(a): b + 1 - for a, b in zip( - *np.unique(s.axis.data, axis=0, return_counts=True) - ) + for a, b in zip(*np.unique(s.axis.data, axis=0, return_counts=True)) } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: @@ -673,15 +667,9 @@ def plot( D4h.name = "4/mmm" # 3-fold rotations -C3x = Symmetry( - [(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)] -) -C3y = Symmetry( - [(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)] -) -C3z = Symmetry( - [(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))] -) +C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)]) +C3y = Symmetry([(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)]) +C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))]) C3 = Symmetry(C3z) C3.name = "3" From ecf5a5a2804c9a0f8989908eb6bb858d61225e3c Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:26:52 -0600 Subject: [PATCH 08/40] add suggestions from #563 review --- .../plot_symmetry_operations.py | 14 ++- orix/plot/stereographic_plot.py | 108 ++++++++++-------- orix/tests/plot/test_stereographic_plot.py | 26 +++-- 3 files changed, 86 insertions(+), 62 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 7d4c28947..18b8f74a3 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -85,7 +85,7 @@ min_ang = np.abs(pg[r_mask * axis_mask].angle).min() f = np.around(2 * np.pi / min_ang).astype(int) c = colors[f] - ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) + ax[i].symmetry_marker((pg * axis), folds=f, s=s, color=c) decorated_axes.append(axis * 1) # if there is an inversion center, plot the appropriate symbol elif np.any(i_mask): @@ -97,9 +97,11 @@ f = np.around(2 * np.pi / min_ang).astype(int) c = colors[f] if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c, inner="dot") + ax[i].symmetry_marker( + (pg * axis), folds=f, s=s, color=c, modifier="inv" + ) else: - ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) + ax[i].symmetry_marker((pg * axis), folds=f, s=s, color=c) decorated_axes.append(axis * 1) # the other option (besides empty) is a rotoinversion @@ -108,7 +110,9 @@ f = np.around(2 * np.pi / min_ang).astype(int) c = colors[f] if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c, inner="half") + ax[i].symmetry_marker( + (pg * axis), folds=f, s=s, color=c, modifier="roto" + ) else: ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) decorated_axes.append(axis * 1) @@ -129,4 +133,4 @@ mask = np.abs(two_folds.dot_outer(dax_unique)).max(axis=1) < 0.99 new_two_folds = two_folds[mask] symm_two_folds = pg.outer(new_two_folds).flatten().unique() - ax[i].symmetry_marker(symm_two_folds, fold=2, s=s, color="g") + ax[i].symmetry_marker(symm_two_folds, folds=2, s=s, color="g") diff --git a/orix/plot/stereographic_plot.py b/orix/plot/stereographic_plot.py index d4da01138..03954e186 100644 --- a/orix/plot/stereographic_plot.py +++ b/orix/plot/stereographic_plot.py @@ -19,8 +19,8 @@ plotting :class:`~orix.vector.Vector3d`. """ -from copy import deepcopy -from typing import Any, List, Optional, Tuple, Union +from copy import copy, deepcopy +from typing import Any, List, Literal, Optional, Tuple, Union from matplotlib import rcParams import matplotlib.axes as maxes @@ -633,7 +633,7 @@ def stereographic_grid( self.collections[index_polar].remove() self._stereographic_grid = False - def symmetry_marker(self, v: Vector3d, fold: int, inner="fill", **kwargs): + def symmetry_marker(self, v: Vector3d, folds: int, modifier="none", **kwargs): """Plot symmetry marker(s). Parameters @@ -645,22 +645,25 @@ def symmetry_marker(self, v: Vector3d, fold: int, inner="fill", **kwargs): **kwargs Keyword arguments passed to :meth:`scatter`. """ - if fold not in [1, 2, 3, 4, 6]: - raise ValueError("Can only plot 1-, 2-, 3-, 4- or 6-fold elements.") - - marker = SymmetryMarker(v, size=kwargs.pop("s", 1), folds=fold, inner=inner) + marker = _SymmetryMarker( + v, size=kwargs.pop("s", 1), folds=folds, modifier=modifier + ) new_kwargs = dict(zorder=ZORDER["symmetry_marker"], clip_on=False) for k, v in new_kwargs.items(): kwargs.setdefault(k, v) for vec, marker, marker_size in marker: - if fold == 1: + if folds == 1: marker_size = marker_size / 2 - if inner != "fill": - bg_kwargs = deepcopy(kwargs) + # It is not (currently) possible to make two-tone markers using custom- + # defined Path objects in matplotlib. Instead, for inversion and + # rotoinversion markers, a background white dot is plotted first, whereas + # the top markers themselves have empty interiors. + if modifier != "none": + bg_kwargs = copy(kwargs) if "color" in bg_kwargs: - bg_kwargs.pop("color") + _ = bg_kwargs.pop("color") self.scatter(vec, color="w", s=marker_size * 0.5, **bg_kwargs) self.scatter(vec, marker=marker, s=marker_size, **kwargs) @@ -974,48 +977,55 @@ def _order_in_hemisphere(polar: np.ndarray, pole: int) -> Union[np.ndarray, None return order -class SymmetryMarker: - """ - Symmetry element markers to plot in stereographic representations of - crystallographic point groups. +class _SymmetryMarker: + """A class for creating Symmetry element markers. Intended for making + stereographic plots of the crystallographic point groups. Intended to be used indirectly in :func:`~orix.plot.StereographicPlot.symmetry_marker`. Parameters ---------- - v (Vector3d object) - Vector3d object used for placing marker in StereographicPlot - - size (int or float) - value passed to matplotlib to determine relative marker size - - folds (integer) - rotational symmetry (typically 1, 2, 3, 4, or 6) for the - symmetry marker to represent with it's shape (circle, elliplse, - triangle, square, or hex)' - - inner ('fill' or 'dot' or 'half') - The shape to put inside the symmetry marker to express additional - symmetry information 'fill' adds nothing, 'dot' adds the dot - traditionally used for inversion centers and mirrors, and 'half' adds - an empty polygon with half as many folds as the marker within the - marker, such as what is used for rotoinversions. + v + Vector(s) giving the positions of markers in a stereographic plot + size + Value(s) passed to matplotlib to determine relative marker size + folds + The rotational symmetry (typically 1, 2, 3, 4, or 6) that determines the + symmetry marker's shape(circle, elliplse, triangle, square, or hex)'. + modifier + Determines what alterations, if any, should be added to the marker. "none" or + None will add nothing. "roto" will add a white rotoinversion symbol inside + the marker, which for even-fold rotations is a polygon with half as many + corners, and for an odd-fold rotation is a white dot. "inv" will add + an inversion symbol, which is a white dot. The default is "none". """ def __init__( self, - v: Union[Vector3d, np.ndarray, list, tuple], + v: Vector3d | np.ndarray | list | tuple, size: int = 1, - folds=2, - inner="fill", + folds: Literal[1, 2, 3, 4, 6] = 2, + modifier: Literal["none", "roto", "inv"] = "none", ): - assert np.isin(folds, [1, 2, 3, 4, 6]) - assert np.isin(inner, ["fill", "dot", "half"]) + fold_opt = [1, 2, 3, 4, 6] + if folds not in fold_opt: + # NOTE: If anyone is interested insupoorting 5-fold, 7-fold,etc. rotations + # in the future, be aware you will also need to modify the affine rotation + # applied at the end of the self._marker property based on your axis + # choices, or the markers will not properly align. + raise ValueError( + f"Folds must be one of {', '.join(map(str, fold_opt))}, not {folds}" + ) + mod_opt = ["none", "roto", "inv"] + if modifier not in mod_opt: + raise ValueError( + f"Modifier must be one of {', '.join(map(str, mod_opt))}, not {modifier}" + ) self._vector = Vector3d(v) self._size = size self._folds = folds - self._inner_shape = inner + self._inner_shape = modifier @property def angle_deg(self) -> np.ndarray: @@ -1023,8 +1033,9 @@ def angle_deg(self) -> np.ndarray: return np.rad2deg(self._vector.azimuth) + 90 @property - def size(self) -> np.ndarray: - """Multiplicity of each symmetry marker.""" + def multiplicity(self) -> np.ndarray: + """Multiplicity of each symmetry marker, ie, how many symmetrically + equivalent markers will be plotted.""" return np.ones(self.n) * self._size @property @@ -1033,11 +1044,18 @@ def n(self) -> int: return self._vector.size def __iter__(self): - for v, marker, size in zip(self._vector, self._marker, self.size): + """Dunder function for iterating through multiple markers defined within a + single _SymmetryMarker Class. + + For example, if a _SymmetryMarker is created with 4 vertices, this allows for + iteration over those vertices in a 'for' loop.""" + for v, marker, size in zip(self._vector, self._marker, self.multiplicity): yield v, marker, size @property - def _marker(self): + def _marker(self) -> mpath.Path: + """Returns a matplotlib Path object that describes the symmetry marker's + shape""" azimuth = self._vector.azimuth # pre-define inner cirle (reused) inner_circle = mpath.Path.circle((0, 0), 0.5) @@ -1053,7 +1071,7 @@ def _marker(self): circle = mpath.Path.circle((0, 0), 0.75) vert = np.copy(circle.vertices) code = circle.codes - if self._inner_shape == "dot": + if self._inner_shape == "inv": verts = np.concatenate([vert, i_vert[::-1]]) codes = np.concatenate([code, i_code]) marker = mpath.Path(verts, codes) @@ -1066,12 +1084,12 @@ def _marker(self): else: # if it's not 2-fold, just use a default polygon marker = mpath.Path.unit_regular_polygon(self._folds) - if self._inner_shape == "dot": + if self._inner_shape == "inv": # add the inner circle verts = np.concatenate([marker.vertices, i_vert[::-1]]) codes = np.concatenate([marker.codes, i_code]) marker = mpath.Path(verts, codes) - elif self._inner_shape == "half": + elif self._inner_shape == "roto": # add an inner shape with half the folds vert = np.copy(marker.vertices) code = np.copy(marker.codes) diff --git a/orix/tests/plot/test_stereographic_plot.py b/orix/tests/plot/test_stereographic_plot.py index 8f672b002..cea15cfa3 100644 --- a/orix/tests/plot/test_stereographic_plot.py +++ b/orix/tests/plot/test_stereographic_plot.py @@ -23,7 +23,7 @@ import pytest from orix import plot -from orix.plot.stereographic_plot import SymmetryMarker +from orix.plot.stereographic_plot import _SymmetryMarker from orix.quaternion import symmetry from orix.vector import Vector3d @@ -289,14 +289,19 @@ def test_size_parameter(self): class TestSymmetryMarker: @pytest.mark.parametrize("v_data", [[0, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]) @pytest.mark.parametrize("folds", [1, 2, 3, 4, 6]) - @pytest.mark.parametrize("fill", ["fill", "dot", "half"]) - def test_main_properties(self, v_data, folds, fill): + @pytest.mark.parametrize("modifier", ["none", "roto", "inv"]) + def test_main_properties(self, v_data, folds, modifier): v = Vector3d(v_data) - marker = SymmetryMarker(v, folds=folds, inner=fill) + marker = _SymmetryMarker(v, folds=folds, modifier=modifier) assert np.allclose(v.data, marker._vector.data) assert marker._folds == folds - assert marker._inner_shape == fill + assert marker._inner_shape == modifier assert (marker.angle_deg - (np.rad2deg(v.azimuth) + 90)) ** 2 < 1e-4 + # check errors + with pytest.raises(ValueError, match="Folds must"): + _SymmetryMarker([0, 0, 1], folds=5) + with pytest.raises(ValueError, match="Modifier must"): + _SymmetryMarker([0, 0, 1], modifier="banana") def test_plot_symmetry_marker(self): _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) @@ -305,18 +310,15 @@ def test_plot_symmetry_marker(self): v = Vector3d([[1, 0, 0], [0, 1, 1]]) for i in [1, 2, 3, 4, 6]: - ax.symmetry_marker(v[0], fold=i, s=marker_size, color="k") - ax.symmetry_marker(v[1], fold=i, inner="dot", s=marker_size) - ax.symmetry_marker(v, fold=1, inner="dot", color="C1", s=marker_size) + ax.symmetry_marker(v[0], folds=i, s=marker_size, color="k") + ax.symmetry_marker(v[1], folds=i, modifier="inv", s=marker_size) + ax.symmetry_marker(v, folds=1, modifier="inv", color="C1", s=marker_size) for i in [4, 6]: - ax.symmetry_marker(v, fold=i, inner="half", s=marker_size) + ax.symmetry_marker(v, folds=i, modifier="roto", s=marker_size) markers = ax.collections assert len(markers) == 43 - with pytest.raises(ValueError, match="Can only plot 1"): - ax.symmetry_marker([0, 0, 1], fold=5) - plt.close("all") From a9af7067157bc03333f9b9ea2ddc1ca5e73f12a1 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 3 Jul 2025 18:30:16 -0600 Subject: [PATCH 09/40] moving plot_symmetry_operations algo to Symmetry function --- .../plot_symmetry_operations.py | 2 +- orix/quaternion/symmetry.py | 73 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 18b8f74a3..789c1af1a 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -56,7 +56,7 @@ # create masks to sort out which elements are rotations, mirrors, # inversions, and rotoinversions p_mask = ~pg.improper - m_mask = (np.abs(pg.angle - np.pi) < 1e-4) * pg.improper + m_mask = (np.abs(pg.angle) - np.pi < 1e-4) * pg.improper r_mask = pg.angle**2 > 1e-4 roto_mask = r_mask * ~p_mask * ~m_mask i_mask = (~r_mask) * pg.improper diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 955ff9b2a..2e810d328 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Union +from copy import copy from diffpy.structure.spacegroups import GetSpaceGroup import matplotlib.figure as mfigure import numpy as np @@ -415,6 +416,76 @@ def from_generators(cls, *generators: Rotation) -> Symmetry: # --------------------- Other public methods --------------------- # + def get_symmetry_markers(self) -> list: + mirror_list = [] + marker_list = [] + has_inversion = False + # create masks to sort out which elements are what. + # proper elements + p_mask = ~self.improper + # mirror planes + m_mask = (np.abs(self.angle - np.pi) < 1e-4) * self.improper + # rotations (both proper and improper) + r_mask = np.abs(self.angle) > 1e-4 + # rotoinversions + roto_mask = r_mask * ~p_mask * ~m_mask + # the inversion symmetry + i_mask = (~r_mask) * self.improper + + # Find the unique axis familes. For the standard crystallographic point + # groups, this will be <100>, <110>, and/or <111>. + axis_families = (self.axis.in_fundamental_sector(self)).unique() + # to avoid repetition, look at only the unique fundamental representations + # of the possible symmetry elements (ie, no need to look at [111] and [11-1]) + fs_axes = self.axis.in_fundamental_sector(self) + # change the axis of the identity and (if present) inversion elements to + # [0,0,0], as neither have markers (inversion only modifies other markers) + fs_axes[p_mask * ~r_mask] = np.zeros(3) + if np.sum(i_mask) > 0: + has_inversion = True + fs_axes[i_mask] = np.zeros(3) + + # iterate through each primary axis and find what elements to add + for axis in axis_families: + axis_mask = np.sum(np.abs((fs_axes - axis).data), 1) < 1e-4 + # check for mirrors + if np.any(m_mask * axis_mask): + mirror_list.append(copy(axis)) + # if there are no rotations, continue to the next axis. + if np.sum(axis_mask * r_mask) == 0: + continue + # check to see if there are only proper rotations + if np.all(p_mask[axis_mask]): + min_ang = np.abs(self[axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + marker_list.append([copy(axis), f, "none"]) + # If there is also an inversion, append the appropriate symbol + elif has_inversion: + min_ang = np.abs(self[r_mask * axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + marker_list.append([copy(axis), f, "inv"]) + # The only othero option is improper rotations and no inversion, which + # is a rotoinversion. + else: + min_ang = np.abs(self[roto_mask * axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + + # Three-fold rotations around the 111 create <011> mirror planes, which for + # ease we will add in by hand. + if np.any(axis_families.dot(Vector3d([1, 1, 1])) > 1.73): + mirror_list.append(Vector3d([0, 1, 1])) + + # Finally, the combination of inversion center and mirror planes + # creates 2-fold symmetries not on the primary axes. Let's add in any + # that didn't already get included from other operations + if np.sum(m_mask) > 1 and has_inversion: + current_markers = Vector3d([x[0].data for x in marker_list]) + two_folds = self[m_mask].in_fundamental_sector(self) + mask = np.abs(two_folds.dot_outer(current_markers)).max(axis=1) < 0.99 + new_two_folds = two_folds[mask] + for ntf in new_two_folds: + marker_list.append([copy(ntf), 2]) + def get_axis_orders(self) -> dict[Vector3d, int]: s = self[self.angle > 0] if s.size == 0: @@ -542,7 +613,7 @@ def plot( # NOTE: ORIX uses Schoenflies symbols to define point groups. This is partly # because the notation is short and always starts with a letter (ie, they # make convenient python variables), and partly because it helps limit -# accidental misinterpretation of of Herman-Mauguin symbols as space group +# accidental misinterpretation of Herman-Mauguin symbols as space group # numbers. For example. "222" could be interpreted as SG#222 == Pn-3n, or # as PG'222'== D3. there are similar examples with 2, 3, 4, 32, etc. From b46e9772d1f10746eb68a83b9b55067124b00d1e Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 3 Jul 2025 18:31:16 -0600 Subject: [PATCH 10/40] formatting --- orix/quaternion/symmetry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 2e810d328..2bda948c2 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -17,9 +17,9 @@ from __future__ import annotations +from copy import copy from typing import TYPE_CHECKING, Union -from copy import copy from diffpy.structure.spacegroups import GetSpaceGroup import matplotlib.figure as mfigure import numpy as np From d9bbfd5db55b91009d64db389702ed8bb42b9cb0 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Tue, 8 Jul 2025 20:26:10 -0600 Subject: [PATCH 11/40] create PointGroups class, improve Symmetry.plot, and fix example --- .../plot_symmetry_operations.py | 137 +-- orix/crystal_map/phase_list.py | 11 +- orix/plot/stereographic_plot.py | 24 +- orix/quaternion/symmetry.py | 819 +++++++++++------- 4 files changed, 538 insertions(+), 453 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 789c1af1a..a27442033 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -3,134 +3,33 @@ Plot symmetry operations ======================== -This example is one method for producing stereographic projections with -symmetry operators for the 32 crystallographic point groups. This method -loosely follows the one laid out in section 9.2 of "Structures of Materials" -(DeGraef et.al, 2nd edition, 2012). - -There are generated proceedurally, and vary slightly from some other -approaches. Consider, for example, more curated -approaches. Consider, for example, the plot for the D2h == "mmm" point group, -which displays the inversion center as a dot in the center 2-fold marker, -whereas Figure 9.9 of "Structure of Materials" leaves these markers out. -Additionally, like the International tables of crystallography (ITOC) Table -10.2.2, which is the generally accepted standard for stereographic projections, -the inversion symmetry dots have been removed from markers along the -equator. +This example shows how stereographic projections with symmetry operators can be +automatically generated using orix for the 32 crystallographic point groups. + +The ordering follows the one given in section 9.2 of "Structures of Materials" +(DeGraef et.al, 2nd edition, 2012), starting with the cyclic groups, then the +dihedral groups, then those same groups plus inversion centers, then the successive +application of mirror planes and secondary rotational symmetries. + +The plots themselves as well as their labels follow the standards given in +Table 10.2.2 of the "International Tables of Crystallography, Volume A" (ITOC). +Both the nomenclature and marker styles thus differ slightly from some textbooks, as +there are some arbitrary convention choices in both Schoenflies notation and marker +styles. """ import matplotlib.pyplot as plt -import numpy as np - -from orix import plot -from orix.quaternion.symmetry import * -from orix.vector import Vector3d - -# generate a list of the 32 crystallographic point groups. -# NOTE: This could instead be done with "get_point_groups()", but the -# following list shows a more logical addition of symmetry operators. -point_groups = get_point_groups("procedural") - - -# Set marker sizes and colors to help differentiate elements -s = 160 -colors = {1: "magenta", 2: "green", 3: "red", 4: "purple", 6: "black"} -mirror_linewidth = 1 -mirror_color = "blue" +import orix.plot +from orix.quaternion.symmetry import PointGroups +# create a list of the 32 crystallographic point groups +point_groups = PointGroups.get_set("procedural") -# Create the plot and subplots using ORIX's stereographic projection fig, ax = plt.subplots( 4, 8, subplot_kw={"projection": "stereographic"}, figsize=[14, 10] ) ax = ax.flatten() - # Iterate through the 32 Point groups for i, pg in enumerate(point_groups): - ax[i].set_title(pg.name) - # get unique axis families (should just be <100>, <110>, and/or <111>) - unique_axes = Vector3d( - np.unique(np.around(pg.axis.in_fundamental_sector(pg).data, 5), axis=0) - ) - # create masks to sort out which elements are rotations, mirrors, - # inversions, and rotoinversions - p_mask = ~pg.improper - m_mask = (np.abs(pg.angle) - np.pi < 1e-4) * pg.improper - r_mask = pg.angle**2 > 1e-4 - roto_mask = r_mask * ~p_mask * ~m_mask - i_mask = (~r_mask) * pg.improper - # to avoid repetition, look at only the unique fundamental representations - # of the possible rotation axes - fs_axes = pg.axis.in_fundamental_sector(pg) - decorated_axes = [] - - # iterate through each primary axis, plotting their symmetry elements - # as we go. - for axis in unique_axes: - axis_mask = np.sum(np.abs((fs_axes - axis).data), 1) < 1e-4 - # plot any mirror planes perpendicular to the axis first - if np.any(m_mask * axis_mask): - for v in pg * axis: - ax[i].plot( - v.get_circle(), - color=mirror_color, - linewidth=mirror_linewidth, - ) - # if all rotations are proper rotations, plot the appropriate symbol - if np.all(p_mask[axis_mask]): - # if the only element is identity, move on. - if not np.any(r_mask * axis_mask): - continue - min_ang = np.abs(pg[r_mask * axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - c = colors[f] - ax[i].symmetry_marker((pg * axis), folds=f, s=s, color=c) - decorated_axes.append(axis * 1) - # if there is an inversion center, plot the appropriate symbol - elif np.any(i_mask): - # this might just be the 1-fold inversion center - if not np.any(r_mask * p_mask): - f = 1 - else: - min_ang = np.abs(pg[r_mask * axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - c = colors[f] - if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker( - (pg * axis), folds=f, s=s, color=c, modifier="inv" - ) - else: - ax[i].symmetry_marker((pg * axis), folds=f, s=s, color=c) - - decorated_axes.append(axis * 1) - # the other option (besides empty) is a rotoinversion - elif np.any(roto_mask[axis_mask]): - min_ang = np.abs(pg[roto_mask * axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - c = colors[f] - if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker( - (pg * axis), folds=f, s=s, color=c, modifier="roto" - ) - else: - ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) - decorated_axes.append(axis * 1) - # Three-fold rotations around the 111 create a special subset of mirror - # planes, which for ease we will add in by hand. - if np.any(unique_axes.dot(Vector3d([1, 1, 1])) > 1.73): - m_vectors = Vector3d([[0, 0, 1], [0, 1, 1]]) - for v in (pg.outer(m_vectors)).flatten().unique(): - ax[i].plot(v.get_circle(), color="blue", linewidth=1) - - # Finally, the combination of inversion center and mirror planes - # creates 2-fold symmetries not on the primary axes. Let's add in any - # that didn't already get included from other operations - if np.sum(m_mask) > 1 and np.sum(i_mask) > 0: - dax = Vector3d([x.data for x in decorated_axes]) - dax_unique = dax.flatten().unique() - two_folds = pg.axis[m_mask].in_fundamental_sector(pg) - mask = np.abs(two_folds.dot_outer(dax_unique)).max(axis=1) < 0.99 - new_two_folds = two_folds[mask] - symm_two_folds = pg.outer(new_two_folds).flatten().unique() - ax[i].symmetry_marker(symm_two_folds, folds=2, s=s, color="g") + pg.plot_elements(plt_axis=ax[i], itoc_style=True) diff --git a/orix/crystal_map/phase_list.py b/orix/crystal_map/phase_list.py index 7a3686dae..bf0539441 100644 --- a/orix/crystal_map/phase_list.py +++ b/orix/crystal_map/phase_list.py @@ -31,12 +31,7 @@ import matplotlib.colors as mcolors import numpy as np -from orix.quaternion.symmetry import ( - _EDAX_POINT_GROUP_ALIASES, - Symmetry, - get_point_group, - get_point_groups, -) +from orix.quaternion.symmetry import _EDAX_POINT_GROUP_ALIASES, Symmetry, PointGroups from orix.vector import Miller, Vector3d # All named Matplotlib colors (tableau and xkcd already lower case hex) @@ -232,14 +227,14 @@ def point_group(self) -> Symmetry | None: Point group. """ if self.space_group is not None: - return get_point_group(self.space_group.number) + return PointGroups.from_space_group(self.space_group.number) else: return self._point_group @point_group.setter def point_group(self, value: int | str | Symmetry | None) -> None: """Set the point group.""" - groups = get_point_groups("all_repeated") + groups = PointGroups._pg_sets["all_repeated"] if isinstance(value, int): value = str(value) if isinstance(value, str): diff --git a/orix/plot/stereographic_plot.py b/orix/plot/stereographic_plot.py index 03954e186..3926d07ef 100644 --- a/orix/plot/stereographic_plot.py +++ b/orix/plot/stereographic_plot.py @@ -664,7 +664,7 @@ def symmetry_marker(self, v: Vector3d, folds: int, modifier="none", **kwargs): bg_kwargs = copy(kwargs) if "color" in bg_kwargs: _ = bg_kwargs.pop("color") - self.scatter(vec, color="w", s=marker_size * 0.5, **bg_kwargs) + self.scatter(vec, color="w", s=marker_size * 0.2, **bg_kwargs) self.scatter(vec, marker=marker, s=marker_size, **kwargs) # TODO: Find a way to control padding, so that markers aren't @@ -995,10 +995,10 @@ class _SymmetryMarker: symmetry marker's shape(circle, elliplse, triangle, square, or hex)'. modifier Determines what alterations, if any, should be added to the marker. "none" or - None will add nothing. "roto" will add a white rotoinversion symbol inside - the marker, which for even-fold rotations is a polygon with half as many - corners, and for an odd-fold rotation is a white dot. "inv" will add - an inversion symbol, which is a white dot. The default is "none". + None will add nothing. "rotoinversion" will add a white rotoinversion symbol + inside the marker, which for even-fold rotations is a polygon with half as + many corners, and for an odd-fold rotation is a white dot. "inversion" will + add an inversion symbol, which is a white dot. The default is "none". """ def __init__( @@ -1006,7 +1006,9 @@ def __init__( v: Vector3d | np.ndarray | list | tuple, size: int = 1, folds: Literal[1, 2, 3, 4, 6] = 2, - modifier: Literal["none", "roto", "inv"] = "none", + modifier: Literal[ + None, "none", "rotation", "rotoinversion", "inversion" + ] = "none", ): fold_opt = [1, 2, 3, 4, 6] if folds not in fold_opt: @@ -1017,7 +1019,7 @@ def __init__( raise ValueError( f"Folds must be one of {', '.join(map(str, fold_opt))}, not {folds}" ) - mod_opt = ["none", "roto", "inv"] + mod_opt = [None, "none", "rotation", "rotoinversion", "inversion"] if modifier not in mod_opt: raise ValueError( f"Modifier must be one of {', '.join(map(str, mod_opt))}, not {modifier}" @@ -1067,11 +1069,11 @@ def _marker(self) -> mpath.Path: e_vert[:, 1] = e_vert[:, 1] * ((1 - e_vert[:, 0] ** 2) ** 0.5) * 0.35 if self._folds == 1: # return either a normal circle, or a cirle with a dot in the - # center if this also anie, inversion center + # center if this also an inversion center circle = mpath.Path.circle((0, 0), 0.75) vert = np.copy(circle.vertices) code = circle.codes - if self._inner_shape == "inv": + if self._inner_shape == "inversion": verts = np.concatenate([vert, i_vert[::-1]]) codes = np.concatenate([code, i_code]) marker = mpath.Path(verts, codes) @@ -1084,12 +1086,12 @@ def _marker(self) -> mpath.Path: else: # if it's not 2-fold, just use a default polygon marker = mpath.Path.unit_regular_polygon(self._folds) - if self._inner_shape == "inv": + if self._inner_shape == "inversion": # add the inner circle verts = np.concatenate([marker.vertices, i_vert[::-1]]) codes = np.concatenate([marker.codes, i_code]) marker = mpath.Path(verts, codes) - elif self._inner_shape == "roto": + elif self._inner_shape == "rotoinversion": # add an inner shape with half the folds vert = np.copy(marker.vertices) code = np.copy(marker.codes) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 2bda948c2..b32602006 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -18,10 +18,10 @@ from __future__ import annotations from copy import copy -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Literal, Union from diffpy.structure.spacegroups import GetSpaceGroup -import matplotlib.figure as mfigure +import matplotlib.pyplot as plt import numpy as np from orix._util import deprecated @@ -58,6 +58,7 @@ class Symmetry(Rotation): """ name = "" + _s_name = "" # -------------------------- Properties -------------------------- # @@ -74,7 +75,7 @@ def is_proper(self) -> bool: @property def subgroups(self) -> list[Symmetry]: """Return the list groups that are subgroups of this group.""" - groups = get_point_groups("all") + groups = PointGroups._pg_sets["all"] return [g for g in groups if g._tuples <= self._tuples] @property @@ -416,75 +417,130 @@ def from_generators(cls, *generators: Rotation) -> Symmetry: # --------------------- Other public methods --------------------- # - def get_symmetry_markers(self) -> list: - mirror_list = [] - marker_list = [] - has_inversion = False + def _get_symmetry_elements(self) -> list: + """ + Returns all the crystallographically unique axes and their associated + symmetry elements (mirrors, rotations, rotoinversions, etc). + + Returns + ------- + axes Vector3d + Vector(s) that are each either parallel to an axis of rotation or + perpendicular to a mirror plane, or both. + is_mirror bool + Indicates whether each primary_axis is or is not perpendicular to a + mirror plane + s_type str ("inversion", "rotoinversion", "rotation", or"none") + Indicates whether the rotational symmetry associated with the axis + contains an inversion, a rotoinversion, purely rotational, or just an + 1-fold axis perpendicular to a mirror. + folds + Indicats the order of the rotational symmetry (1,2,3,4, or 6) + + Notes + ------- + This function does not return ALL the axes and angles, + (that function would be `Symmetry.to_axes_angles`), nor does it return + the minimum generating elements. Instead, it returns all the primary axes + plus information about the rotations, inversions, and/or mirrors associated + with each. + """ # create masks to sort out which elements are what. # proper elements p_mask = ~self.improper # mirror planes - m_mask = (np.abs(self.angle - np.pi) < 1e-4) * self.improper + m_mask = (np.abs(np.abs(self.angle) - np.pi) < 1e-4) * self.improper # rotations (both proper and improper) r_mask = np.abs(self.angle) > 1e-4 # rotoinversions roto_mask = r_mask * ~p_mask * ~m_mask # the inversion symmetry i_mask = (~r_mask) * self.improper - - # Find the unique axis familes. For the standard crystallographic point - # groups, this will be <100>, <110>, and/or <111>. - axis_families = (self.axis.in_fundamental_sector(self)).unique() - # to avoid repetition, look at only the unique fundamental representations - # of the possible symmetry elements (ie, no need to look at [111] and [11-1]) - fs_axes = self.axis.in_fundamental_sector(self) - # change the axis of the identity and (if present) inversion elements to - # [0,0,0], as neither have markers (inversion only modifies other markers) - fs_axes[p_mask * ~r_mask] = np.zeros(3) if np.sum(i_mask) > 0: has_inversion = True - fs_axes[i_mask] = np.zeros(3) + else: + has_inversion = False + + elements = [] + # Find the unique axis familes. + axis_families = (self.axis.in_fundamental_sector(self)).unique() # iterate through each primary axis and find what elements to add for axis in axis_families: - axis_mask = np.sum(np.abs((fs_axes - axis).data), 1) < 1e-4 + # mask out just axes elements in the fundamental sector to avoid repeats + axis_mask = ( + np.sum(np.abs((self.axis.in_fundamental_sector(self) - axis).data), 1) + < 1e-4 + ) + m_flag = False + folds = 0 + s_type = "empty" # check for mirrors if np.any(m_mask * axis_mask): - mirror_list.append(copy(axis)) - # if there are no rotations, continue to the next axis. - if np.sum(axis_mask * r_mask) == 0: - continue + m_flag = True # check to see if there are only proper rotations if np.all(p_mask[axis_mask]): - min_ang = np.abs(self[axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - marker_list.append([copy(axis), f, "none"]) - # If there is also an inversion, append the appropriate symbol + # This might just be the identity. + if not np.any(r_mask * axis_mask): + elements.append( + (copy(axis), m_flag, 1, "none"), + ) + continue + min_ang = np.abs(self[r_mask * axis_mask].angle).min() + folds = np.around(2 * np.pi / min_ang).astype(int) + elements.append( + (copy(axis), m_flag, folds, "rotation"), + ) + continue + # Check if there is a rotation with an inversion elif has_inversion: + # this might just be the 1-fold inversion center + if not np.any(r_mask * axis_mask): + elements.append( + (copy(axis), m_flag, 1, "inversion"), + ) + continue min_ang = np.abs(self[r_mask * axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - marker_list.append([copy(axis), f, "inv"]) - # The only othero option is improper rotations and no inversion, which - # is a rotoinversion. - else: + folds = np.around(2 * np.pi / min_ang).astype(int) + elements.append( + (copy(axis), m_flag, folds, "inversion"), + ) + continue + # the only other important option is a rotoinversion + elif np.any(roto_mask[axis_mask]): min_ang = np.abs(self[roto_mask * axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - - # Three-fold rotations around the 111 create <011> mirror planes, which for - # ease we will add in by hand. - if np.any(axis_families.dot(Vector3d([1, 1, 1])) > 1.73): - mirror_list.append(Vector3d([0, 1, 1])) - - # Finally, the combination of inversion center and mirror planes - # creates 2-fold symmetries not on the primary axes. Let's add in any - # that didn't already get included from other operations - if np.sum(m_mask) > 1 and has_inversion: - current_markers = Vector3d([x[0].data for x in marker_list]) - two_folds = self[m_mask].in_fundamental_sector(self) - mask = np.abs(two_folds.dot_outer(current_markers)).max(axis=1) < 0.99 - new_two_folds = two_folds[mask] - for ntf in new_two_folds: - marker_list.append([copy(ntf), 2]) + folds = np.around(2 * np.pi / min_ang).astype(int) + elements.append( + (copy(axis), m_flag, folds, "rotoinversion"), + ) + continue + # if it it not a rotational symmetry of any type, it's a mirror + else: + elements.append( + (copy(axis), m_flag, 1, "none"), + ) + # Finally, 3-fold rotations around the 111 create <110> mirrors, and + # inversion combined with mirror planes can create 2-fold symmetries + # not on the primary axis. These we can add by hand if they are missing. + if np.any(np.abs(Vector3d([1, 1, 1]).angle_with(self.axis)) < 1e-4): + catch = False + v = Vector3d([0, 1, 1]).in_fundamental_sector(self) + for i, e in enumerate(elements): + if np.abs(v.data - e[0].data).sum() < 1e-4: + elements[i][1] = True + catch = True + break + if catch is False: + elements.append( + (v, True, 1, "none"), + ) + + # split the list of lists into 4 variables. + axes = [x[0] for x in elements] + is_mirror = [x[1] for x in elements] + s_type = [x[3] for x in elements] + folds = [x[2] for x in elements] + return axes, is_mirror, s_type, folds def get_axis_orders(self) -> dict[Vector3d, int]: s = self[self.angle > 0] @@ -539,12 +595,88 @@ def fundamental_zone(self) -> Vector3d: sr = SphericalRegion(n.unique()) return sr + def plot_elements( + self, + color_dict: dict = {}, + symmetry_color: str | None = None, + mirror_color: str | None = None, + s: float = 160, + mirror_lw: float = 1, + plt_axis: plt.Axes | None = None, + return_figure: bool = False, + itoc_style: bool = True, + show_name: bool = True, + ): + # import orix.plot so matplotlib knows what the stereographic projection is. + import orix.plot + + # dictionary of default colors + colors = { + 1: "black", + 2: "green", + 3: "red", + 4: "purple", + 6: "magenta", + "m": "blue", + } + # if a symmetry or mirror color was passed in, reset default values. + if symmetry_color is not None: + for i in [1, 2, 3, 4, 6]: + colors[i] = symmetry_color + if mirror_color is not None: + colors["m"] = mirror_color + # after resetting defaults, update color choices passed in via color_dict + colors.update(color_dict) + # if the user did not pass in an axis, generate one + if plt_axis is None: + fig, plt_axis = plt.subplots(subplot_kw={"projection": "stereographic"}) + else: + fig = plt_axis.get_figure() + + # add titles and labels if requested + if show_name: + plt_axis.set_title(self._s_name + " | " + self.name) + + # determine the symnmetry elements and plot them. + elements = self._get_symmetry_elements() + for v, m, t, f in zip(*elements): + # plot each symmetrically equivalent mirror plane only once + if m: + for mv in (self * v).unique(): + m_circ = mv.get_circle() + plt_axis.plot(m_circ, color=colors["m"], linewidth=mirror_lw) + # plot each symmetrically equivalent rotation element only once + c = colors[f] + if f > 1: + for sv in (self * v).unique(): + # ITOC doesn't plot inversion or rotoinversion markers for + # symmetry elements with axes perpendicular to the out-of plane + # direction, as the information is redundant. + z_ang = np.abs(sv.angle_with(Vector3d.zvector())) + is_perp = np.abs(z_ang - (np.pi / 2)) < 1e-4 + if itoc_style and is_perp: + plt_axis.symmetry_marker( + sv, folds=f, s=s, color=c, modifier=None + ) + else: + plt_axis.symmetry_marker(sv, folds=f, s=s, color=c, modifier=t) + # if this is the primary axis and there is no rotation but an inversion + # (ie, this is symmetry.Ci, the `-1` PG), add the appropriate marker. + elif f == 1 and np.abs(v.angle_with(Vector3d.zvector())) < 1e-4: + if t != "inversion": + continue + for sv in (self * v).unique(): + plt_axis.symmetry_marker(sv, folds=f, s=s, color=c, modifier=t) + # return the figure if requested + if return_figure: + return fig + def plot( self, orientation: "Orientation | None" = None, reproject_scatter_kwargs: dict | None = None, **kwargs, - ) -> mfigure.Figure | None: + ) -> plt.Figure | None: """Stereographic projection of symmetry operations. The upper hemisphere of the stereographic projection is shown. @@ -639,13 +771,16 @@ def plot( # Triclinic C1 = Symmetry((1, 0, 0, 0)) C1.name = "1" +C1._s_name = "C1" Ci = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) Ci.improper = [0, 1] Ci.name = "-1" +Ci._s_name = "Ci" # include redundant point group S2 == Ci S2 = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) S2.improper = [0, 1] S2.name = "-1" +S2._s_name = "S2" # Special generators _mirror_xy = Symmetry([(1, 0, 0, 0), (0, 0.75**0.5, -(0.75**0.5), 0)]) @@ -655,40 +790,53 @@ def plot( # 2-fold rotations C2x = Symmetry([(1, 0, 0, 0), (0, 1, 0, 0)]) C2x.name = "211" +C2x._s_name = "C2x" C2y = Symmetry([(1, 0, 0, 0), (0, 0, 1, 0)]) C2y.name = "121" +C2y._s_name = "C2y" C2z = Symmetry([(1, 0, 0, 0), (0, 0, 0, 1)]) C2z.name = "112" +C2z._s_name = "C2z" C2 = Symmetry(C2z) C2.name = "2" +C2._s_name = "C2" # included redundant point group D1 == C2 D1 = Symmetry(C2z) D1.name = "2" +D1._s_name = "D1" # Mirrors Csx = Symmetry([(1, 0, 0, 0), (0, 1, 0, 0)]) Csx.improper = [0, 1] Csx.name = "m11" +Csx._s_name = "Csx" Csy = Symmetry([(1, 0, 0, 0), (0, 0, 1, 0)]) Csy.improper = [0, 1] Csy.name = "1m1" +Csy._s_name = "Csy" Csz = Symmetry([(1, 0, 0, 0), (0, 0, 0, 1)]) Csz.improper = [0, 1] Csz.name = "11m" +Csz._s_name = "Csz" Cs = Symmetry(Csz) Cs.name = "m" +Cs._s_name = "Cs" # Monoclinic C2h = Symmetry.from_generators(C2, Cs) C2h.name = "2/m" +C2h._s_name = "C2h" # Orthorhombic D2 = Symmetry.from_generators(C2z, C2x, C2y) D2.name = "222" +D2._s_name = "D2" C2v = Symmetry.from_generators(C2z, Csx) C2v.name = "mm2" +C2v._s_name = "C2v" D2h = Symmetry.from_generators(Csz, Csx, Csy) D2h.name = "mmm" +D2h._s_name = "D2h" # 4-fold rotations C4x = Symmetry( @@ -717,25 +865,33 @@ def plot( ) C4 = Symmetry(C4z) C4.name = "4" +C4._s_name = "C4" # Tetragonal S4 = Symmetry(C4) S4.improper = [0, 1, 0, 1] S4.name = "-4" +S4._s_name = "S4" # include redundant point group C4i == S4 C4i = Symmetry(C4) C4i.improper = [0, 1, 0, 1] C4i.name = "-4" +C4i._s_name = "C4i" C4h = Symmetry.from_generators(C4, Cs) C4h.name = "4/m" +C4h._s_name = "C4h" D4 = Symmetry.from_generators(C4, C2x, C2y) D4.name = "422" +D4._s_name = "D4" C4v = Symmetry.from_generators(C4, Csx) C4v.name = "4mm" +C4v._s_name = "C4v" D2d = Symmetry.from_generators(D2, _mirror_xy) D2d.name = "-42m" +D2d._s_name = "D2d" D4h = Symmetry.from_generators(C4h, Csx, Csy) D4h.name = "4/mmm" +D4h._s_name = "D4h" # 3-fold rotations C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)]) @@ -743,188 +899,77 @@ def plot( C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))]) C3 = Symmetry(C3z) C3.name = "3" +C3._s_name = "C3" # Trigonal C3i = Symmetry.from_generators(C3, Ci) C3i.name = "-3" +C3i._s_name = "C3i" # include redundant point group S6==C3i S6 = Symmetry.from_generators(C3, Ci) S6.name = "-3" +S6._s_name = "S6" D3x = Symmetry.from_generators(C3, C2x) D3x.name = "321" +D3x._s_name = "D3x" D3y = Symmetry.from_generators(C3, C2y) D3y.name = "312" +D3y._s_name = "D3y" D3 = Symmetry(D3x) D3.name = "32" +D3._s_name = "D3" C3v = Symmetry.from_generators(C3, Csx) C3v.name = "3m" +C3v._s_name = "C3v" D3d = Symmetry.from_generators(S6, Csx) D3d.name = "-3m" +D3d._s_name = "D3d" # Hexagonal C6 = Symmetry.from_generators(C3, C2) C6.name = "6" +C6._s_name = "C6" C3h = Symmetry.from_generators(C3, Cs) C3h.name = "-6" +C3h._s_name = "C3h" C6h = Symmetry.from_generators(C6, Cs) C6h.name = "6/m" +C6h._s_name = "C6h" D6 = Symmetry.from_generators(C6, C2x, C2y) D6.name = "622" +D6._s_name = "D6" C6v = Symmetry.from_generators(C6, Csx) C6v.name = "6mm" +C6v._s_name = "C6v" D3h = Symmetry.from_generators(C3, C2y, Csz) D3h.name = "-6m2" +D3h._s_name = "-D3h" D6h = Symmetry.from_generators(D6, Csz) D6h.name = "6/mmm" +D6h._s_name = "D6h" # Cubic T = Symmetry.from_generators(C2, _cubic) T.name = "23" +T._s_name = "T" Th = Symmetry.from_generators(T, Ci) Th.name = "m-3" +Th._s_name = "Th" O = Symmetry.from_generators(C4, _cubic, C2x) O.name = "432" +O._s_name = "O" Td = Symmetry.from_generators(T, _mirror_xy) Td.name = "-43m" +Td._s_name = "Td" Oh = Symmetry.from_generators(O, Ci) Oh.name = "m-3m" +Oh._s_name = "Oh" -def get_point_groups(subset: str = "unique"): - """ - returns different subsets of the 32 crystallographic point groups. By - default, this returns all 32 in the order they appear in the - International Tables of Crystallography (ITOC). - - Parameters - ---------- - subset : str, optional - the point group list to return. The options are as follows: - "unique" (default): - All 32 point groups in the order they appear in space groups. - Thus, they are grouped by crystal system and laue class - "all": - All 32 points groups, plus common axis-specific permutations - for non-centrosymmetric groups (ie, C2 plus C2x and C2y) for - a total of 37 point group projections. These - are given in the same order as ITOC Table 10.2.2 - "all_repeated": - The 37 point group projections, plus the redundant Schonflies - and Herman-Mauguin group names. For example, both Ci and S2 - are included, as well as D3 =="32" and D3x == "321". NOTE: - this means several of the entries symmetrically identical. - "proper": - The 11 proper point groups given in the same order as ITOC - table 10.2.2. - same order as "unique", which in turn aligns with Table 3.1 - of ITOC - "proper_all": - The 11 proper point groups, plus axis-specific permutations. - "laue": - The point groups corresponding to the 11 Laue groups, using - the same ordering and definitions as Table 3.1 of ITOC. These - are equivalent to adding an inversion symmetry to each op - the 11 proper point groups - "procedural": - The 32 point groups, but presented in the procedural ordering - described in "Structure of Materials" and other books, where - point groups are created from successive applications of - symmetry elements to the Cyclic (C_n) and Dihedral (D_n) - groups. - - Returns - ------- - point groups: [Symmetry,] - a list of point group symmetries - - Notes - ------- - For convenience, the following table shows the 38 point groups contained - in "all", of which the other lists are subsets of. - - - +-------------+--------------+---------------+------------+------------+ - | Schoenflies | System | HM | Laue Class | Proper PG | - +=============+==============+===============+============+============+ - | C1 | Triclinic | 1 | -1 | 1 | - +-------------+--------------+---------------+------------+------------+ - | Ci | Triclinic | -1 | -1 | 1 | - +-------------+--------------+---------------+------------+------------+ - | C2 | Monoclinic | 2 or 112 | 2/m | 112 | - +-------------+--------------+---------------+------------+------------+ - | C2x | Monoclinic | 211 | 2/m | 211 | - +-------------+--------------+---------------+------------+------------+ - | C2y | Monoclinic | 121 | 2/m | 121 | - +-------------+--------------+---------------+------------+------------+ - | Cs | Monoclinic | 11m or m | 2/m | 1 | - +-------------+--------------+---------------+------------+------------+ - | Csx | Monoclinic | m11 | 2/m | 1 | - +-------------+--------------+---------------+------------+------------+ - | Csy | Monoclinic | 1m1 | 2/m | 1 | - +-------------+--------------+---------------+------------+------------+ - | C2h | Monoclinic | 2/m | 2/m | 112 | - +-------------+--------------+---------------+------------+------------+ - | D2 | Orthorhombic | 222 | mmm | 222 | - +-------------+--------------+---------------+------------+------------+ - | C2v | Orthorhombic | mm2 | mmm | 211 | - +-------------+--------------+---------------+------------+------------+ - | D2h | Orthorhombic | mmm | mmm | 222 | - +-------------+--------------+---------------+------------+------------+ - | C4 | Tetragonal | 4 | 4/m | 4 | - +-------------+--------------+---------------+------------+------------+ - | S4 | Tetragonal | -4 | 4/m | 112 | - +-------------+--------------+---------------+------------+------------+ - | C4h | Tetragonal | 4/m | 4/m | 4 | - +-------------+--------------+---------------+------------+------------+ - | D4 | Tetragonal | 422 | 4/mmm | 422 | - +-------------+--------------+---------------+------------+------------+ - | C4v | Tetragonal | 4mm | 4/mmm | 4 | - +-------------+--------------+---------------+------------+------------+ - | D2d | Tetragonal | -42m | 4/mmm | 222 | - +-------------+--------------+---------------+------------+------------+ - | D4h | Tetragonal | 4/mmm | 4/mmm | 422 | - +-------------+--------------+---------------+------------+------------+ - | C3 | Trigonal | 3 | -3 | 3 | - +-------------+--------------+---------------+------------+------------+ - | C3i | Trigonal | -3 | -3 | 3 | - +-------------+--------------+---------------+------------+------------+ - | D3 | Trigonal | 32 or 321 | -3m | 32 | - +-------------+--------------+---------------+------------+------------+ - | D3y | Trigonal | 312 | -3m | 312 | - +-------------+--------------+---------------+------------+------------+ - | C3v | Trigonal | 3m | -3m | 3 | - +-------------+--------------+---------------+------------+------------+ - | D3d | Trigonal | -3m | -3m | 32 | - +-------------+--------------+---------------+------------+------------+ - | C6 | Hexagonal | 6 | 6/m | 6 | - +-------------+--------------+---------------+------------+------------+ - | C3h | Hexagonal | -6 | 6/m | 6 | - +-------------+--------------+---------------+------------+------------+ - | C6h | Hexagonal | 6/m | 6/m | 622 | - +-------------+--------------+---------------+------------+------------+ - | D6 | Hexagonal | 622 | 6/mmm | 622 | - +-------------+--------------+---------------+------------+------------+ - | C6v | Hexagonal | 6mm | 6/mmm | 6 | - +-------------+--------------+---------------+------------+------------+ - | D3h | Hexagonal | -6m2 | 6/mmm | 312 | - +-------------+--------------+---------------+------------+------------+ - | D6h | Hexagonal | 6/mmm | 6/mmm | 622 | - +-------------+--------------+---------------+------------+------------+ - | T | Cubic | 23 | m-3 | 23 | - +-------------+--------------+---------------+------------+------------+ - | Th | Cubic | m-3 | m-3 | 23 | - +-------------+--------------+---------------+------------+------------+ - | O | Cubic | 432 | m-3m | 432 | - +-------------+--------------+---------------+------------+------------+ - | Td | Cubic | -43m | m-3m | 23 | - +-------------+--------------+---------------+------------+------------+ - | Oh | Cubic | m-3m | m-3m | 432 | - +-------------+--------------+---------------+------------+------------+ - - """ - subset = str(subset).lower() - if subset == "all_repeated": - return [ +class PointGroups(list): + # make a lookup table of common subsets of Point Groups + _pg_sets = { + "all_repeated": [ # Triclinic C1, Ci, @@ -976,9 +1021,8 @@ def get_point_groups(subset: str = "unique"): O, Td, Oh, - ] - elif subset == "all": - return [ + ], + "all": [ # Triclinic C1, Ci, @@ -1023,9 +1067,8 @@ def get_point_groups(subset: str = "unique"): O, Td, Oh, - ] - elif subset == "unique": - return [ + ], + "unique": [ # Triclinic C1, Ci, @@ -1065,9 +1108,8 @@ def get_point_groups(subset: str = "unique"): O, Td, Oh, - ] - elif subset == "proper": - return [ + ], + "proper": [ # Triclinic C1, # Monoclinic @@ -1086,9 +1128,8 @@ def get_point_groups(subset: str = "unique"): # cubic T, O, - ] - elif subset == "laue": - return [ + ], + "laue": [ # Triclinic Ci, # Monoclinic @@ -1107,9 +1148,8 @@ def get_point_groups(subset: str = "unique"): # cubic Th, Oh, - ] - elif subset == "proper_all": - return [ + ], + "proper_all": [ # Triclinic C1, # Monoclinic @@ -1131,9 +1171,8 @@ def get_point_groups(subset: str = "unique"): # cubic T, O, - ] - elif subset == "procedural": - return [ + ], + "procedural": [ # Cyclic C1, C2, @@ -1175,9 +1214,260 @@ def get_point_groups(subset: str = "unique"): Th, Td, Oh, - ] - else: - raise ValueError("{} is not a valid subset option".format(subset)) + ], + } + _subset_names = _pg_sets.keys() + _point_group_names = dict([(x.name, x) for x in _pg_sets["all_repeated"]]).keys() + + _spacegroup2pointgroup_dict = { + "PG1": {"proper": C1, "improper": C1}, + "PG1bar": {"proper": C1, "improper": Ci}, + "PG2": {"proper": C2, "improper": C2}, + "PGm": {"proper": C2, "improper": Cs}, + "PG2/m": {"proper": C2, "improper": C2h}, + "PG222": {"proper": D2, "improper": D2}, + "PGmm2": {"proper": C2, "improper": C2v}, + "PGmmm": {"proper": D2, "improper": D2h}, + "PG4": {"proper": C4, "improper": C4}, + "PG4bar": {"proper": C4, "improper": S4}, + "PG4/m": {"proper": C4, "improper": C4h}, + "PG422": {"proper": D4, "improper": D4}, + "PG4mm": {"proper": C4, "improper": C4v}, + "PG4bar2m": {"proper": D4, "improper": D2d}, + "PG4barm2": {"proper": D4, "improper": D2d}, + "PG4/mmm": {"proper": D4, "improper": D4h}, + "PG3": {"proper": C3, "improper": C3}, + "PG3bar": {"proper": C3, "improper": S6}, # Improper also known as C3i + "PG312": {"proper": D3, "improper": D3}, + "PG321": {"proper": D3, "improper": D3}, + "PG3m1": {"proper": C3, "improper": C3v}, + "PG31m": {"proper": C3, "improper": C3v}, + "PG3m": {"proper": C3, "improper": C3v}, + "PG3bar1m": {"proper": D3, "improper": D3d}, + "PG3barm1": {"proper": D3, "improper": D3d}, + "PG3barm": {"proper": D3, "improper": D3d}, + "PG6": {"proper": C6, "improper": C6}, + "PG6bar": {"proper": C6, "improper": C3h}, + "PG6/m": {"proper": C6, "improper": C6h}, + "PG622": {"proper": D6, "improper": D6}, + "PG6mm": {"proper": C6, "improper": C6v}, + "PG6barm2": {"proper": D6, "improper": D3h}, + "PG6bar2m": {"proper": D6, "improper": D3h}, + "PG6/mmm": {"proper": D6, "improper": D6h}, + "PG23": {"proper": T, "improper": T}, + "PGm3bar": {"proper": T, "improper": Th}, + "PG432": {"proper": O, "improper": O}, + "PG4bar3m": {"proper": T, "improper": Td}, + "PGm3barm": {"proper": O, "improper": Oh}, + } + + def __init__(self, symmetry_list: list = [C1]): + """ + A list of symmetry operators with convenence functions for parsing entries + and displaying information. + + This class is primarily intended to be called using PointGroups.subset(), + or to return a single Symmetry object using PointGroups.get(). + + Parameters + ---------- + symmetry_list + A list of orix.quaternion.symmetry.Symmetry objects, each representing + a crystallographic class. + """ + if not isinstance(symmetry_list, list): + ValueError("'symmetry_list' must be a list of Symmetry objects") + if not np.all([isinstance(x, Symmetry) for x in symmetry_list]): + ValueError("'symmetry_list' must be a list of Symmetry objects") + super().__init__() + self.extend(symmetry_list) + + def __repr__(self): + str_data = ( + "| Name | System | HM | Laue | Proper |n" + + "=" * 47 + + "\n" + + "\n".join( + [ + "| " + + "| ".join( + [ + x._s_name.ljust(6), + x.system.ljust(12), + x.name.ljust(6), + x.laue.name.ljust(6), + x.proper_subgroup.name.ljust(7), + ] + ) + + "|" + for x in self + ] + ) + ) + + return str_data + + def get(name: Literal[PointGroups._point_group_names]): + """ + Given a string or integer representation, this function will attempt to + return an associated Symmetry object. + + This is done by first checking the labels defined in orix, which includes + Herman-Mauguin ('m3m' or '2', etc.) and Shoenflies ('C6' or D3h', etc.). + + If it cannot find a match in either list, it will attempt to look up the + space group name using diffpy's GetSpaceGroup, and relate that back + to a point group. this is equivalent to PointGroups.from_space_group(name) + + Parameters + ---------- + name : string in PointGroups._point_group_names + either the Herman-Maugin or Shoenflies name for a crystallographic + point gorup. + + Returns + ------- + point_group + an object of Class `Symmetry` representing the requested + crystallographic point group. + """ + # check the 'unique' list first, then 'all', then 'all_repeated' first. + print(vars().keys()) + for subset in ["unique", "all", "all_repeated"]: + pgs = PointGroups._pg_sets[subset] + pg_dict = dict([(x.name, x) for x in pgs]) + if name.lower() in pg_dict.keys(): + return pg_dict[name.lower()] + # repeat check with Shoenflies notation + pg_dict_s = dict([(x._s_name.lower(), x) for x in pgs]) + if name.lower() in pg_dict_s.keys(): + return pg_dict_s[name.lower()] + # If the name doesn't exist in orix, try diffpy + try: + PointGroups.from_space_group(name) + # If the name still cannot be found, return a ValueError + except ValueError: + raise ValueError( + f"'name' must be one of {", ".join(map(str, pg_dict.keys()))}," + + f" {", ".join(map(str, pg_dict_s.keys()))}, or must be a string or " + + "integer recognized by diffpy.structure.spacegroups.GetSpaceGroup" + + f". name = '{name}' is not a valid value." + ) + + def from_space_group( + space_group_number: Union(int, str), proper: bool = False + ) -> Symmetry: + """ + Maps a space group number or name to a crystallographic point group. + + Parameters + ---------- + space_group_number: int between 1-231, or str + If is an int(n) or str(int(n)) where n is between 1 and 231, it will + return the point group of the nth space group, as defined by the + International Tables of Crystallogrphy. Otherwise, it will be passed + to diffpy's dictionary of space group names for interpretation. + + Thus, 222 and '222' will both return symmetry.Oh (ie "432", the point + group of SG#222==Pn-3n), but 'P222' will return symmetry.D2 + (ie "222", the proper point group of SG#16=='P222'). + + proper: bool + Whether to return the point group with proper rotations only + (``True``), or the full point group (``False``). Default is + ``False``. + + Returns + ------- + point_group + One of the 11 proper or 32 point groups. + + Notes: + ---------- + This function uses diffpy.structure.spacegroups to convert names to + space group IDs, and has some allowances for spelling and spacing + differences. Thus, variations like "Pm-3m", 221, "PM3m", and "Pn3n" all + map to symmetry.Oh == 'm-3m'. To see a full list of all name options + avaiable, use the following snippet: + + >>> import diffpy.structure.spacegroups as sg + >>> sg._buildSGLookupTable() + >>> sg._sg_lookup_table.keys() + + Examples + -------- + >>> from orix.quaternion.symmetry import get_point_group + >>> pgOh = get_point_group(225) + >>> pgOh.name + 'm-3m' + >>> pgO = get_point_group(225, proper=True) + >>> pgO.name + '432' + """ + spg = GetSpaceGroup(space_group_number) + pgn = spg.point_group_name + if proper: + return PointGroups._spacegroup2pointgroup_dict[pgn]["proper"] + else: + return PointGroups._spacegroup2pointgroup_dict[pgn]["improper"] + + @classmethod + def get_set(self, name: Literal[PointGroups._subset_names] = "unique"): + """ + returns different subsets of the 32 crystallographic point groups. By + default, this returns all 32 in the order they appear in the + International Tables of Crystallography (ITOC). + + Parameters + ---------- + subset : str, optional + the point group list to return. The options are as follows: + "unique" (default): + All 32 point groups in the order they appear in ITOC's space groups. + Thus, they are grouped by crystal system and Laue class + "all": + All 32 points groups, plus common axis-specific permutations + for non-centrosymmetric groups (ie, C2 plus C2x and C2y) for + a total of 37 point group projections. These + are given in the same order as ITOC Table 10.2.2 + "all_repeated": + The 37 point group projections, plus the redundant Schonflies + and Herman-Mauguin group names. For example, both Ci and S2 + are included, as well as D3 =="32" and D3x == "321". NOTE: + this means several of the entries symmetrically identical. + "proper": + The 11 proper point groups given in the same order as ITOC + table 10.2.2. + same order as "unique", which in turn aligns with Table 3.1 + of ITOC + "proper_all": + The 11 proper point groups, plus axis-specific permutations. + "laue": + The point groups corresponding to the 11 Laue groups, using + the same ordering and definitions as Table 3.1 of ITOC. These + are equivalent to adding an inversion symmetry to each op + the 11 proper point groups + "procedural": + The 32 point groups, but presented in the procedural ordering + described in "Structure of Materials" and other books, where + point groups are created from successive applications of + symmetry elements to the Cyclic (C_n) and Dihedral (D_n) + groups. + + Returns + ------- + point groups: PointGroups + A PointGroup class containing the requested symmetries + """ + pg_opts = self._pg_sets.keys() + if name in pg_opts: + return PointGroups(self._pg_sets[name]) + elif name.lower() in pg_opts: + return PointGroups(self._pg_sets[name.lower()]) + # if the name doesn't exist, return a ValueError + raise ValueError( + f"'name' must be one of {", ".join(map(str, pg_opts))}, not '{name}'" + ) def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: @@ -1200,118 +1490,16 @@ def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: return distinguished_points[distinguished_points.angle > 0] -spacegroup2pointgroup_dict = { - "PG1": {"proper": C1, "improper": C1}, - "PG1bar": {"proper": C1, "improper": Ci}, - "PG2": {"proper": C2, "improper": C2}, - "PGm": {"proper": C2, "improper": Cs}, - "PG2/m": {"proper": C2, "improper": C2h}, - "PG222": {"proper": D2, "improper": D2}, - "PGmm2": {"proper": C2, "improper": C2v}, - "PGmmm": {"proper": D2, "improper": D2h}, - "PG4": {"proper": C4, "improper": C4}, - "PG4bar": {"proper": C4, "improper": S4}, - "PG4/m": {"proper": C4, "improper": C4h}, - "PG422": {"proper": D4, "improper": D4}, - "PG4mm": {"proper": C4, "improper": C4v}, - "PG4bar2m": {"proper": D4, "improper": D2d}, - "PG4barm2": {"proper": D4, "improper": D2d}, - "PG4/mmm": {"proper": D4, "improper": D4h}, - "PG3": {"proper": C3, "improper": C3}, - "PG3bar": {"proper": C3, "improper": S6}, # Improper also known as C3i - "PG312": {"proper": D3, "improper": D3}, - "PG321": {"proper": D3, "improper": D3}, - "PG3m1": {"proper": C3, "improper": C3v}, - "PG31m": {"proper": C3, "improper": C3v}, - "PG3m": {"proper": C3, "improper": C3v}, - "PG3bar1m": {"proper": D3, "improper": D3d}, - "PG3barm1": {"proper": D3, "improper": D3d}, - "PG3barm": {"proper": D3, "improper": D3d}, - "PG6": {"proper": C6, "improper": C6}, - "PG6bar": {"proper": C6, "improper": C3h}, - "PG6/m": {"proper": C6, "improper": C6h}, - "PG622": {"proper": D6, "improper": D6}, - "PG6mm": {"proper": C6, "improper": C6v}, - "PG6barm2": {"proper": D6, "improper": D3h}, - "PG6bar2m": {"proper": D6, "improper": D3h}, - "PG6/mmm": {"proper": D6, "improper": D6h}, - "PG23": {"proper": T, "improper": T}, - "PGm3bar": {"proper": T, "improper": Th}, - "PG432": {"proper": O, "improper": O}, - "PG4bar3m": {"proper": T, "improper": Td}, - "PGm3barm": {"proper": O, "improper": Oh}, -} - - @deprecated( since="0.14", removal="0.15", - alternative="symmetry.get_point_group_from_space_group", + alternative="PointGroup.get_from_space_group", ) def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: r""" - This function has been renamed to - `orix.quaternion.symmetry.get_point_group_from_space_group`. - for clairity""" - return get_point_group_from_space_group(space_group_number, proper) - - -def get_point_group_from_space_group( - space_group_number: Union(int, str), proper: bool = False -) -> Symmetry: - """ - Maps a space group number or name to a crystallographic point group. - - Parameters - ---------- - space_group_number: int between 1-231, or str - If is an int(n) or str(int(n)) where n is between 1 and 231, it will - return the point group of the nth space group, as defined by the - International Tables of Crystallogrphy. Otherwise, it will be passed - to diffpy's dictionary of space group names for interpretation. - - Thus, 222 and '222' will both return symmetry.Oh (ie "432", the point - group of SG#222==Pn-3n), but 'P222' will return symmetry.D2 - (ie "222", the proper point group of SG#16=='P222'). - - proper: bool - Whether to return the point group with proper rotations only - (``True``), or the full point group (``False``). Default is - ``False``. - - Returns - ------- - point_group - One of the 11 proper or 32 point groups. - - Notes: - ---------- - This function uses diffpy.structure.spacegroups to convert names to - space group IDs, and has some allowances for spelling and spacing - differences. Thus, variations like "Pm-3m", 221, "PM3m", and "Pn3n" all - map to symmetry.Oh == 'm-3m'. To see a full list of all name options - avaiable, use the following snippet: - - >>> import diffpy.structure.spacegroups as sg - >>> sg._buildSGLookupTable() - >>> sg._sg_lookup_table.keys() - - Examples - -------- - >>> from orix.quaternion.symmetry import get_point_group - >>> pgOh = get_point_group(225) - >>> pgOh.name - 'm-3m' - >>> pgO = get_point_group(225, proper=True) - >>> pgO.name - '432' + This function has been moved to the PointGroups class """ - spg = GetSpaceGroup(space_group_number) - pgn = spg.point_group_name - if proper: - return spacegroup2pointgroup_dict[pgn]["proper"] - else: - return spacegroup2pointgroup_dict[pgn]["improper"] + return PointGroups.get_from_space_group(space_group_number, proper) # Point group alias mapping. This is needed because in EDAX TSL OIM @@ -1333,16 +1521,16 @@ def _get_laue_group_name(name: str) -> str | None: # search through all the point groups defined in orix for one with a # matching name. valid_name = False - for g in get_point_groups("all_repeated"): + for g in PointGroups._pg_sets["all_repeated"]: if g.name == name: valid_name = True break if valid_name == False: - raise ValueError("{} is not a valid point group name") + raise ValueError(f"{name} is not a valid point group name") # add an inversion to get the laue group. g_laue = _get_unique_symmetry_elements(g, Ci) # find a laue group with matching operators - for laue in get_point_groups("laue"): + for laue in PointGroups._pg_sets["laue"]: # first check for length if g_laue.shape != laue.shape: continue @@ -1350,6 +1538,7 @@ def _get_laue_group_name(name: str) -> str | None: if np.min(g_laue.outer(laue).angle ** 2, 1).max() < 1e-4: if np.min(g_laue.outer(laue).angle ** 2, 0).max() < 1e-4: return laue.name + return "" def _get_unique_symmetry_elements( From fed56bcd74ad1bfacaa83e92b2230660806ded15 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Tue, 8 Jul 2025 20:32:01 -0600 Subject: [PATCH 12/40] formatting --- .../stereographic_projection/plot_symmetry_operations.py | 8 +++++++- orix/crystal_map/phase_list.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index a27442033..31d9b866d 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -9,13 +9,19 @@ The ordering follows the one given in section 9.2 of "Structures of Materials" (DeGraef et.al, 2nd edition, 2012), starting with the cyclic groups, then the dihedral groups, then those same groups plus inversion centers, then the successive -application of mirror planes and secondary rotational symmetries. +application of mirror planes and secondary rotational symmetries until all 32 +groups are made. The plots themselves as well as their labels follow the standards given in Table 10.2.2 of the "International Tables of Crystallography, Volume A" (ITOC). Both the nomenclature and marker styles thus differ slightly from some textbooks, as there are some arbitrary convention choices in both Schoenflies notation and marker styles. + +Orix uses Schoenflies Notation (left label above each plot) for variable names since +they are short and always begin with a letter, but both Schoenflies and +Hermann-Mauguin (right label above each plot) names can be used to look up symmetry +groups using `PointGroups.get()` """ import matplotlib.pyplot as plt diff --git a/orix/crystal_map/phase_list.py b/orix/crystal_map/phase_list.py index bf0539441..b00476417 100644 --- a/orix/crystal_map/phase_list.py +++ b/orix/crystal_map/phase_list.py @@ -31,7 +31,7 @@ import matplotlib.colors as mcolors import numpy as np -from orix.quaternion.symmetry import _EDAX_POINT_GROUP_ALIASES, Symmetry, PointGroups +from orix.quaternion.symmetry import _EDAX_POINT_GROUP_ALIASES, PointGroups, Symmetry from orix.vector import Miller, Vector3d # All named Matplotlib colors (tableau and xkcd already lower case hex) From da8269d6312b64732e5dbeaefc95a4cac4aba75a Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:46:43 -0600 Subject: [PATCH 13/40] finishing symmetry.plot and adding to PointGroups --- .../plot_symmetry_operations.py | 6 +- orix/crystal_map/phase_list.py | 2 +- orix/quaternion/symmetry.py | 352 ++++++++++-------- orix/tests/plot/test_stereographic_plot.py | 10 +- orix/tests/quaternion/test_orientation.py | 6 +- orix/tests/quaternion/test_symmetry.py | 120 +++--- 6 files changed, 282 insertions(+), 214 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 31d9b866d..22806345e 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -27,15 +27,19 @@ import matplotlib.pyplot as plt import orix.plot from orix.quaternion.symmetry import PointGroups +from orix.vector import Vector3d # create a list of the 32 crystallographic point groups point_groups = PointGroups.get_set("procedural") +# prepare the plots fig, ax = plt.subplots( 4, 8, subplot_kw={"projection": "stereographic"}, figsize=[14, 10] ) ax = ax.flatten() +# create a vector to mirror over axes +v = Vector3d.from_polar(65, 80, degrees=True) # Iterate through the 32 Point groups for i, pg in enumerate(point_groups): - pg.plot_elements(plt_axis=ax[i], itoc_style=True) + pg.plot(asymetric_vector=v, plt_axis=ax[i], itoc_style=True) diff --git a/orix/crystal_map/phase_list.py b/orix/crystal_map/phase_list.py index b00476417..2468ab60d 100644 --- a/orix/crystal_map/phase_list.py +++ b/orix/crystal_map/phase_list.py @@ -234,7 +234,7 @@ def point_group(self) -> Symmetry | None: @point_group.setter def point_group(self, value: int | str | Symmetry | None) -> None: """Set the point group.""" - groups = PointGroups._pg_sets["all_repeated"] + groups = PointGroups._pg_sets["permutations_repeated"] if isinstance(value, int): value = str(value) if isinstance(value, str): diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index b32602006..b6604c566 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -75,7 +75,7 @@ def is_proper(self) -> bool: @property def subgroups(self) -> list[Symmetry]: """Return the list groups that are subgroups of this group.""" - groups = PointGroups._pg_sets["all"] + groups = PointGroups._pg_sets["permutations"] return [g for g in groups if g._tuples <= self._tuples] @property @@ -418,8 +418,7 @@ def from_generators(cls, *generators: Rotation) -> Symmetry: # --------------------- Other public methods --------------------- # def _get_symmetry_elements(self) -> list: - """ - Returns all the crystallographically unique axes and their associated + """Returns all the crystallographically unique axes and their associated symmetry elements (mirrors, rotations, rotoinversions, etc). Returns @@ -519,22 +518,11 @@ def _get_symmetry_elements(self) -> list: elements.append( (copy(axis), m_flag, 1, "none"), ) - # Finally, 3-fold rotations around the 111 create <110> mirrors, and - # inversion combined with mirror planes can create 2-fold symmetries - # not on the primary axis. These we can add by hand if they are missing. + # Finally, 3-fold rotations around the 111 create <110> mirrors + # not on the primary axes. These we can add by hand. if np.any(np.abs(Vector3d([1, 1, 1]).angle_with(self.axis)) < 1e-4): - catch = False v = Vector3d([0, 1, 1]).in_fundamental_sector(self) - for i, e in enumerate(elements): - if np.abs(v.data - e[0].data).sum() < 1e-4: - elements[i][1] = True - catch = True - break - if catch is False: - elements.append( - (v, True, 1, "none"), - ) - + elements.append((v, True, 1, "none")) # split the list of lists into 4 variables. axes = [x[0] for x in elements] is_mirror = [x[1] for x in elements] @@ -543,6 +531,7 @@ def _get_symmetry_elements(self) -> list: return axes, is_mirror, s_type, folds def get_axis_orders(self) -> dict[Vector3d, int]: + """Return a dictionary of every rotation axis and it's order (ie, folds)""" s = self[self.angle > 0] if s.size == 0: return {} @@ -552,6 +541,7 @@ def get_axis_orders(self) -> dict[Vector3d, int]: } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: + """Return the highest order rotational axis and it's order (ie, folds)""" axis_orders = self.get_axis_orders() if len(axis_orders) == 0: return Vector3d.zvector(), np.inf @@ -595,22 +585,97 @@ def fundamental_zone(self) -> Vector3d: sr = SphericalRegion(n.unique()) return sr - def plot_elements( + def plot( self, - color_dict: dict = {}, - symmetry_color: str | None = None, - mirror_color: str | None = None, - s: float = 160, - mirror_lw: float = 1, - plt_axis: plt.Axes | None = None, - return_figure: bool = False, - itoc_style: bool = True, + asymetric_vector: Vector3d | None = None, show_name: bool = True, - ): + plt_axis: plt.Axes | None = None, + return_figure: bool = True, + marker_dict: dict = {}, + marker_size: float = 150.0, + mirror_width: float = 2.0, + asymetric_vector_dict: dict = {}, + asymmetric_vector_size: float = 10.0, + itoc_style=True, + ) -> plt.Figure | None: + """Creates a stereographic projection of symmetry operations in the group. + Can also plot symmetrically equivalent variations of orientations or vectors + to demonstrate the effect of symmetry operations. + + Parameters + ---------- + asymetric_vector + A marker will be added at the stereographic projection of this vector, + along with all it's symmetrically equivalent rotations. By default, no + vector will be plotted, and only rotation and mirror markers will be added + to the plot. + show_name + If True, add both the Schoenflies and Hermann-Mauguin names of the point + group to the title. + plt_axis + The matplotlib.Axis object into which to add the stereographic plot. + If None is passed, a new figure and axis will be generated. + return_figure + If True, return the figure containing the plotting axis. + marker_dict + A dictionary of arguments to modify how the symmetry markers are + generated. The following options are the overwritable defaults: + 1: 'black' <-- 1-fold marker color + 2: 'green' <-- 2-fold marker color + 3: 'red' <-- 3-fold marker color + 4: 'purple' <-- 4-fold marker color + 6: 'magenta' <-- 6-fold marker color + 'm': 'blue' <-- 1-fold marker color + marker_size + The size of the rotational makers to be added to the plot. This is + equivalent to the argument "s" in matplotlib.scatter + mirror_width + The width of the line used to draw the mirror planes. This is + equivalent to the argument "linewidth" in matplotlib.plot + asymetric_vector_dict + A dictionary of arguments to modify the asymetric_vector markers. + The following options are the overwritable defaults: + 'upper_color': 'black' < -- Upper hemisphere marker color + 'lower_color': 'grey' < -- Lower hemisphere marker color + 'upper_marker': '+' < -- Upper hemisphere marker shape + 'lower_marker': 'o' < -- Lower hemisphere marker shape + asymmetric_vector_size + size of the markers used to plot the asymetric vector markers. + itoc_style + If True, the plot will follow the ITOC convention of not placing redundant + inversion symbols on rotation markers perpendicular to the viewing + direction. + + Returns + ------- + fig + The created figure, returned if ``return_figure=True`` is + passed as a keyword argument. + + Notes + ----- + + If users wish to have more control over their plots, this function can be used + to modify an existing plot, like so: + + >>> import matplotlib.pyplot as plt + >>> pg_Oh = PointGroups.get('m-3m') + >>> v = Vector3d.random(10) + >>> v_symm = pg_Oh.outer(v).flatten() + >>> fig, ax = plt.subplots(1, 1, subplot_kw={"projection": "stereographic"}) + >>> pg_Oh.plot(plt_axis=ax, show_name = False) + >>> ax.set_title("my cool custom title") + >>> ax.scatter(v_symm) + + In this way, keword arguments related to the plot, the title, the scattered + vector markers, and/or the symmetry markers can be individually altered as + desired. + + """ # import orix.plot so matplotlib knows what the stereographic projection is. import orix.plot - # dictionary of default colors + # dictionary of default colors for the symmetry markers. colors = { 1: "black", 2: "green", @@ -619,23 +684,17 @@ def plot_elements( 6: "magenta", "m": "blue", } - # if a symmetry or mirror color was passed in, reset default values. - if symmetry_color is not None: - for i in [1, 2, 3, 4, 6]: - colors[i] = symmetry_color - if mirror_color is not None: - colors["m"] = mirror_color # after resetting defaults, update color choices passed in via color_dict - colors.update(color_dict) + colors.update(marker_dict) # if the user did not pass in an axis, generate one if plt_axis is None: fig, plt_axis = plt.subplots(subplot_kw={"projection": "stereographic"}) else: fig = plt_axis.get_figure() - # add titles and labels if requested + # add a default title if requested if show_name: - plt_axis.set_title(self._s_name + " | " + self.name) + plt_axis.set_title(self._s_name + " ( " + self.name + " )") # determine the symnmetry elements and plot them. elements = self._get_symmetry_elements() @@ -644,7 +703,7 @@ def plot_elements( if m: for mv in (self * v).unique(): m_circ = mv.get_circle() - plt_axis.plot(m_circ, color=colors["m"], linewidth=mirror_lw) + plt_axis.plot(m_circ, color=colors["m"], linewidth=mirror_width) # plot each symmetrically equivalent rotation element only once c = colors[f] if f > 1: @@ -656,96 +715,54 @@ def plot_elements( is_perp = np.abs(z_ang - (np.pi / 2)) < 1e-4 if itoc_style and is_perp: plt_axis.symmetry_marker( - sv, folds=f, s=s, color=c, modifier=None + sv, folds=f, s=marker_size, color=c, modifier=None ) else: - plt_axis.symmetry_marker(sv, folds=f, s=s, color=c, modifier=t) + plt_axis.symmetry_marker( + sv, folds=f, s=marker_size, color=c, modifier=t + ) # if this is the primary axis and there is no rotation but an inversion # (ie, this is symmetry.Ci, the `-1` PG), add the appropriate marker. elif f == 1 and np.abs(v.angle_with(Vector3d.zvector())) < 1e-4: if t != "inversion": continue for sv in (self * v).unique(): - plt_axis.symmetry_marker(sv, folds=f, s=s, color=c, modifier=t) - # return the figure if requested - if return_figure: - return fig - - def plot( - self, - orientation: "Orientation | None" = None, - reproject_scatter_kwargs: dict | None = None, - **kwargs, - ) -> plt.Figure | None: - """Stereographic projection of symmetry operations. - - The upper hemisphere of the stereographic projection is shown. - Vectors on the lower hemisphere are shown after reprojection - onto the upper hemisphere. - - Parameters - ---------- - orientation - The symmetry operations are applied to this orientation - before plotting. The default value uses an orientation - optimized to show symmetry elements. - reproject_scatter_kwargs - Dictionary of keyword arguments for the reprojected scatter - points which is passed to - :meth:`~orix.plot.StereographicPlot.scatter`, which passes - these on to :meth:`matplotlib.axes.Axes.scatter`. The - default marker style for reprojected vectors is "+". Values - used for vector(s) on the visible hemisphere are used unless - another value is passed here. - **kwargs - Keyword arguments passed to - :meth:`~orix.plot.StereographicPlot.scatter`, which passes - these on to :meth:`matplotlib.axes.Axes.scatter`. + plt_axis.symmetry_marker( + sv, folds=f, s=marker_size, color=c, modifier=t + ) - Returns - ------- - fig - The created figure, returned if ``return_figure=True`` is - passed as a keyword argument. - """ - if orientation is None: - # orientation chosen to mimic stereographic projections as - # shown: http://xrayweb.chem.ou.edu/notes/symmetry.html - orientation = Rotation.from_axes_angles((-1, 8, 1), np.deg2rad(65)) - if not isinstance(orientation, Rotation): - raise TypeError("Orientation must be a Rotation instance.") - orientation = self.outer(orientation) - - kwargs.setdefault("return_figure", False) - return_figure = kwargs.pop("return_figure") - - if reproject_scatter_kwargs is None: - reproject_scatter_kwargs = {} - reproject_scatter_kwargs.setdefault("marker", "+") - reproject_scatter_kwargs.setdefault("label", "lower") - - v = orientation * Vector3d.zvector() - - figure = v.scatter( - return_figure=True, - axes_labels=[r"$e_1$", r"$e_2$", None], - label="upper", - reproject=True, - reproject_scatter_kwargs=reproject_scatter_kwargs, - **kwargs, - ) - # add symmetry name to figure title - figure.suptitle(f"${self.name}$") + # plot asymmetric markers if requested. + if asymetric_vector is not None: + v_symm = self.outer(asymetric_vector).flatten() + vdict = { + "upper_color": "black", + "lower_color": "grey", + "upper_marker": "+", + "lower_marker": "o", + } + vdict.update(asymetric_vector_dict) + mask = v_symm.z >= 0 + plt_axis.scatter( + -1 * v_symm[~mask], + marker=vdict["lower_marker"], + c=vdict["lower_color"], + ) + plt_axis.scatter( + v_symm[mask], + marker=vdict["upper_marker"], + c=vdict["upper_color"], + ) + # return the figure if requested if return_figure: - return figure + return fig # ---------------- Proceedural definitions of Point Groups ---------------- # # NOTE: ORIX uses Schoenflies symbols to define point groups. This is partly # because the notation is short and always starts with a letter (ie, they # make convenient python variables), and partly because it helps limit -# accidental misinterpretation of Herman-Mauguin symbols as space group +# accidental misinterpretation of Hermann-Mauguin symbols as space group # numbers. For example. "222" could be interpreted as SG#222 == Pn-3n, or # as PG'222'== D3. there are similar examples with 2, 3, 4, 32, etc. @@ -969,7 +986,7 @@ def plot( class PointGroups(list): # make a lookup table of common subsets of Point Groups _pg_sets = { - "all_repeated": [ + "permutations_repeated": [ # Triclinic C1, Ci, @@ -1022,7 +1039,7 @@ class PointGroups(list): Td, Oh, ], - "all": [ + "permutations": [ # Triclinic C1, Ci, @@ -1068,7 +1085,7 @@ class PointGroups(list): Td, Oh, ], - "unique": [ + "groups": [ # Triclinic C1, Ci, @@ -1109,7 +1126,7 @@ class PointGroups(list): Td, Oh, ], - "proper": [ + "proper_groups": [ # Triclinic C1, # Monoclinic @@ -1129,27 +1146,7 @@ class PointGroups(list): T, O, ], - "laue": [ - # Triclinic - Ci, - # Monoclinic - C2h, - # Orthorhombic - D2h, - D4h, - # Tetragonal - C4h, - # Trigonal - C3i, - D3d, - # Hexagonal - C6h, - D6h, - # cubic - Th, - Oh, - ], - "proper_all": [ + "proper_permutations": [ # Triclinic C1, # Monoclinic @@ -1172,6 +1169,26 @@ class PointGroups(list): T, O, ], + "laue": [ + # Triclinic + Ci, + # Monoclinic + C2h, + # Orthorhombic + D2h, + D4h, + # Tetragonal + C4h, + # Trigonal + C3i, + D3d, + # Hexagonal + C6h, + D6h, + # cubic + Th, + Oh, + ], "procedural": [ # Cyclic C1, @@ -1217,7 +1234,9 @@ class PointGroups(list): ], } _subset_names = _pg_sets.keys() - _point_group_names = dict([(x.name, x) for x in _pg_sets["all_repeated"]]).keys() + _point_group_names = dict( + [(x.name, x) for x in _pg_sets["permutations_repeated"]] + ).keys() _spacegroup2pointgroup_dict = { "PG1": {"proper": C1, "improper": C1}, @@ -1276,16 +1295,16 @@ def __init__(self, symmetry_list: list = [C1]): a crystallographic class. """ if not isinstance(symmetry_list, list): - ValueError("'symmetry_list' must be a list of Symmetry objects") - if not np.all([isinstance(x, Symmetry) for x in symmetry_list]): - ValueError("'symmetry_list' must be a list of Symmetry objects") - super().__init__() - self.extend(symmetry_list) + raise ValueError("'symmetry_list' must be a list of Symmetry objects") + elif not np.all([isinstance(x, Symmetry) for x in symmetry_list]): + raise ValueError("'symmetry_list' must be a list of Symmetry objects") + else: + self.extend(symmetry_list) def __repr__(self): str_data = ( - "| Name | System | HM | Laue | Proper |n" - + "=" * 47 + "| Name | System | HM | Laue | Proper |\n" + + "=" * 48 + "\n" + "\n".join( [ @@ -1293,7 +1312,7 @@ def __repr__(self): + "| ".join( [ x._s_name.ljust(6), - x.system.ljust(12), + x.system.ljust(13), x.name.ljust(6), x.laue.name.ljust(6), x.proper_subgroup.name.ljust(7), @@ -1313,7 +1332,7 @@ def get(name: Literal[PointGroups._point_group_names]): return an associated Symmetry object. This is done by first checking the labels defined in orix, which includes - Herman-Mauguin ('m3m' or '2', etc.) and Shoenflies ('C6' or D3h', etc.). + Hermann-Mauguin ('m3m' or '2', etc.) and Shoenflies ('C6' or D3h', etc.). If it cannot find a match in either list, it will attempt to look up the space group name using diffpy's GetSpaceGroup, and relate that back @@ -1322,7 +1341,7 @@ def get(name: Literal[PointGroups._point_group_names]): Parameters ---------- name : string in PointGroups._point_group_names - either the Herman-Maugin or Shoenflies name for a crystallographic + either the Hermann-Maugin or Shoenflies name for a crystallographic point gorup. Returns @@ -1331,20 +1350,21 @@ def get(name: Literal[PointGroups._point_group_names]): an object of Class `Symmetry` representing the requested crystallographic point group. """ - # check the 'unique' list first, then 'all', then 'all_repeated' first. + # check the 'groups' list first, then 'permutations', + # then 'permutations_repeated'. print(vars().keys()) - for subset in ["unique", "all", "all_repeated"]: + for subset in ["groups", "permutations", "permutations_repeated"]: pgs = PointGroups._pg_sets[subset] pg_dict = dict([(x.name, x) for x in pgs]) - if name.lower() in pg_dict.keys(): + if str(name).lower() in pg_dict.keys(): return pg_dict[name.lower()] # repeat check with Shoenflies notation pg_dict_s = dict([(x._s_name.lower(), x) for x in pgs]) - if name.lower() in pg_dict_s.keys(): + if str(name).lower() in pg_dict_s.keys(): return pg_dict_s[name.lower()] # If the name doesn't exist in orix, try diffpy try: - PointGroups.from_space_group(name) + return PointGroups.from_space_group(name) # If the name still cannot be found, return a ValueError except ValueError: raise ValueError( @@ -1412,7 +1432,7 @@ def from_space_group( return PointGroups._spacegroup2pointgroup_dict[pgn]["improper"] @classmethod - def get_set(self, name: Literal[PointGroups._subset_names] = "unique"): + def get_set(self, name: Literal[PointGroups._subset_names] = "groups"): """ returns different subsets of the 32 crystallographic point groups. By default, this returns all 32 in the order they appear in the @@ -1422,17 +1442,17 @@ def get_set(self, name: Literal[PointGroups._subset_names] = "unique"): ---------- subset : str, optional the point group list to return. The options are as follows: - "unique" (default): + "groups" (default): All 32 point groups in the order they appear in ITOC's space groups. Thus, they are grouped by crystal system and Laue class - "all": + "permutations": All 32 points groups, plus common axis-specific permutations for non-centrosymmetric groups (ie, C2 plus C2x and C2y) for a total of 37 point group projections. These are given in the same order as ITOC Table 10.2.2 - "all_repeated": + "permutations_repeated": The 37 point group projections, plus the redundant Schonflies - and Herman-Mauguin group names. For example, both Ci and S2 + and Hermann-Mauguin group names. For example, both Ci and S2 are included, as well as D3 =="32" and D3x == "321". NOTE: this means several of the entries symmetrically identical. "proper": @@ -1493,13 +1513,13 @@ def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: @deprecated( since="0.14", removal="0.15", - alternative="PointGroup.get_from_space_group", + alternative="PointGroups.from_space_group", ) def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: - r""" + """ This function has been moved to the PointGroups class """ - return PointGroups.get_from_space_group(space_group_number, proper) + return PointGroups.from_space_group(space_group_number, proper) # Point group alias mapping. This is needed because in EDAX TSL OIM @@ -1521,12 +1541,19 @@ def _get_laue_group_name(name: str) -> str | None: # search through all the point groups defined in orix for one with a # matching name. valid_name = False - for g in PointGroups._pg_sets["all_repeated"]: + for g in PointGroups._pg_sets["permutations_repeated"]: if g.name == name: valid_name = True break - if valid_name == False: + if valid_name is False: raise ValueError(f"{name} is not a valid point group name") + # if the matching point group as a Schoenflies name that ends in an x,y, or z, + # it's a permutation of a point group. trade it for an unpermutated one. + if np.isin(g._s_name[-1], ["x", "y", "z"]): + s_name = g._s_name[:-1] + for g in PointGroups._pg_sets["permutations_repeated"]: + if g._s_name == s_name: + break # add an inversion to get the laue group. g_laue = _get_unique_symmetry_elements(g, Ci) # find a laue group with matching operators @@ -1538,7 +1565,6 @@ def _get_laue_group_name(name: str) -> str | None: if np.min(g_laue.outer(laue).angle ** 2, 1).max() < 1e-4: if np.min(g_laue.outer(laue).angle ** 2, 0).max() < 1e-4: return laue.name - return "" def _get_unique_symmetry_elements( diff --git a/orix/tests/plot/test_stereographic_plot.py b/orix/tests/plot/test_stereographic_plot.py index cea15cfa3..cfb170331 100644 --- a/orix/tests/plot/test_stereographic_plot.py +++ b/orix/tests/plot/test_stereographic_plot.py @@ -289,7 +289,7 @@ def test_size_parameter(self): class TestSymmetryMarker: @pytest.mark.parametrize("v_data", [[0, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]) @pytest.mark.parametrize("folds", [1, 2, 3, 4, 6]) - @pytest.mark.parametrize("modifier", ["none", "roto", "inv"]) + @pytest.mark.parametrize("modifier", [None, "none", "rotoinversion", "inversion"]) def test_main_properties(self, v_data, folds, modifier): v = Vector3d(v_data) marker = _SymmetryMarker(v, folds=folds, modifier=modifier) @@ -311,10 +311,12 @@ def test_plot_symmetry_marker(self): v = Vector3d([[1, 0, 0], [0, 1, 1]]) for i in [1, 2, 3, 4, 6]: ax.symmetry_marker(v[0], folds=i, s=marker_size, color="k") - ax.symmetry_marker(v[1], folds=i, modifier="inv", s=marker_size) - ax.symmetry_marker(v, folds=1, modifier="inv", color="C1", s=marker_size) + ax.symmetry_marker(v[1], folds=i, modifier="inversion", s=marker_size) + ax.symmetry_marker( + v, folds=1, modifier="inversion", color="C1", s=marker_size + ) for i in [4, 6]: - ax.symmetry_marker(v, folds=i, modifier="roto", s=marker_size) + ax.symmetry_marker(v, folds=i, modifier="rotoinversion", s=marker_size) markers = ax.collections assert len(markers) == 43 diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index a0e4df286..d8b72f124 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -37,14 +37,14 @@ T, O, Oh, - get_point_groups, + PointGroups, ) from orix.vector import Miller, Vector3d # isort: on # fmt: on -groups = get_point_groups("all") -proper_groups = get_point_groups("proper") +groups = PointGroups.get_set("permutations_repeated") +proper_groups = PointGroups.get_set("proper_groups") @pytest.fixture diff --git a/orix/tests/quaternion/test_symmetry.py b/orix/tests/quaternion/test_symmetry.py index 67dff03de..a26c3d63b 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -34,15 +34,16 @@ C3, S6, D3x, D3y, D3, C3v, D3d, # trigonal C6, C3h, C6h, D6, C6v, D3h, D6h, # hexagonal T, Th, O, Td, Oh, # cubic - spacegroup2pointgroup_dict, - get_point_groups, + PointGroups, _get_unique_symmetry_elements ) # isort: on # fmt: on from orix.vector import Vector3d -_groups = get_point_groups("all") +# fmt: onfrom orix.vector import Vector3d + +_groups = PointGroups.get_set("permutations") @pytest.fixture(params=[(1, 2, 3)]) @@ -58,13 +59,13 @@ def all_symmetries(request): @pytest.mark.parametrize( "symmetry, vector, expected", [ - (Ci, (1, 2, 3), [(1, 2, 3), (-1, -2, -3)]), - (Csx, (1, 2, 3), [(1, 2, 3), (-1, 2, 3)]), - (Csy, (1, 2, 3), [(1, 2, 3), (1, -2, 3)]), - (Csz, (1, 2, 3), [(1, 2, 3), (1, 2, -3)]), - (C2, (1, 2, 3), [(1, 2, 3), (-1, -2, 3)]), + (PointGroups.get("Ci"), (1, 2, 3), [(1, 2, 3), (-1, -2, -3)]), + (PointGroups.get("Csx"), (1, 2, 3), [(1, 2, 3), (-1, 2, 3)]), + (PointGroups.get("Csy"), (1, 2, 3), [(1, 2, 3), (1, -2, 3)]), + (PointGroups.get("Csz"), (1, 2, 3), [(1, 2, 3), (1, 2, -3)]), + (PointGroups.get("C2"), (1, 2, 3), [(1, 2, 3), (-1, -2, 3)]), ( - C2v, + PointGroups.get("C2v"), (1, 2, 3), [ (1, 2, 3), @@ -74,7 +75,7 @@ def all_symmetries(request): ], ), ( - C4v, + PointGroups.get("C4v"), (1, 2, 3), [ (1, 2, 3), @@ -88,7 +89,7 @@ def all_symmetries(request): ], ), ( - D4, + PointGroups.get("D4"), (1, 2, 3), [ (1, 2, 3), @@ -102,7 +103,7 @@ def all_symmetries(request): ], ), ( - C6, + PointGroups.get("C6"), (1, 2, 3), [ (1, 2, 3), @@ -114,7 +115,7 @@ def all_symmetries(request): ], ), ( - Td, + PointGroups.get("Td"), (1, 2, 3), [ (1, 2, 3), @@ -144,7 +145,7 @@ def all_symmetries(request): ], ), ( - Oh, + PointGroups.get("Oh"), (1, 2, 3), [ (1, 2, 3), @@ -442,14 +443,15 @@ def test_no_symm_fundamental_zone(): def test_get_point_group(): """Makes sure all the ints from 1 to 230 give answers.""" + sg2pg = PointGroups._spacegroup2pointgroup_dict for sg_number in np.arange(1, 231): proper_pg = get_point_group(sg_number, proper=True) assert proper_pg in [C1, C2, C3, C4, C6, D2, D3, D4, D6, O, T] sg = GetSpaceGroup(sg_number) pg = get_point_group(sg_number, proper=False) - assert proper_pg == spacegroup2pointgroup_dict[sg.point_group_name]["proper"] - assert pg == spacegroup2pointgroup_dict[sg.point_group_name]["improper"] + assert proper_pg == sg2pg[sg.point_group_name]["proper"] + assert pg == sg2pg[sg.point_group_name]["improper"] def test_unique_symmetry_elements_subgroups(all_symmetries): @@ -516,33 +518,67 @@ def test_hash_persistence(): assert all(h1a == h2a for h1a, h2a in zip(h1, h2)) -@pytest.mark.parametrize("pg", [C1, C4, Oh]) -def test_symmetry_plot(pg): +@pytest.mark.parametrize( + "pg, n_elements", + [ + (C1, 2), + (Ci, 4), + (S4, 4), + (S6, 4), + (D3, 16), + (T, 30), + (Th, 20), + (O, 52), + (Td, 20), + (Oh, 36), + ], +) +def test_symmetry_plot(pg, n_elements): + plt.close("all") fig = pg.plot(return_figure=True) assert isinstance(fig, plt.Figure) assert len(fig.axes) == 1 ax = fig.axes[0] + assert len(ax.collections) == n_elements + + fig, plt_axis = plt.subplots(subplot_kw={"projection": "stereographic"}) + v = Vector3d.from_polar(0.5, 0.3) + v_symm = pg.outer(v) + pg.plot(v, plt_axis=plt_axis) + c1 = plt_axis.collections[-1] + c2 = plt_axis.collections[-2] + assert len(c1.get_offsets()) == np.sum(v_symm.z >= 0) + if pg.size > 1: + assert len(c2.get_offsets()) == np.sum(v_symm.z < 0) + plt.close("all") - c0 = ax.collections[0] - assert len(c0.get_offsets()) == np.count_nonzero(~pg.improper) - assert c0.get_label().lower() == "upper" - if not pg.is_proper: - c1 = ax.collections[1] - assert len(c1.get_offsets()) == np.count_nonzero(pg.improper) - assert c1.get_label().lower() == "lower" - - assert len(ax.texts) == 2 - assert ax.texts[0].get_text() == "$e_1$" - assert ax.texts[1].get_text() == "$e_2$" - plt.close("all") +class TestPointGroups: + def test_repr(self): + pg_list = PointGroups.get_set("permutations_repeated") + assert len(pg_list) == 44 + pg_list = PointGroups.get_set("GROUPS") + assert len(pg_list) == 32 + docs = pg_list.__repr__() + lines = docs.split("\n") + assert len(lines) == 34 + for l in lines[2:]: + assert len(l.split()) == 11 + def test_init_checks(self): + with pytest.raises(ValueError): + x = PointGroups("banana") + with pytest.raises(ValueError): + x = PointGroups(["banana"]) -@pytest.mark.parametrize("symmetry", [C1, C4, Oh]) -def test_symmetry_plot_raises(symmetry): - with pytest.raises(TypeError, match="Orientation must be a Rotation instance"): - _ = symmetry.plot(return_figure=True, orientation="test") + def test_get(self): + assert PointGroups.get("c1").laue.name == "-1" + assert PointGroups.get("C1").laue.name == "-1" + assert PointGroups.get("32").laue.name == "-3m" + assert PointGroups.get(230).laue.name == "m-3m" + with pytest.raises(ValueError): + x = PointGroups.get("banana") class TestFundamentalSectorFromSymmetry: @@ -893,24 +929,24 @@ def test_equality(symmetry): @pytest.mark.parametrize( ["subset", "length", "proper_count"], [ - ["unique", 32, 11], - ["all", 37, 14], - ["all_repeated", 44, 17], - ["proper", 11, 11], - ["proper_all", 14, 14], + ["groups", 32, 11], + ["permutations", 37, 14], + ["permutations_repeated", 44, 17], + ["proper_groups", 11, 11], + ["proper_permutations", 14, 14], ["laue", 11, 0], ["procedural", 32, 11], ], ) def test_get_point_groups(subset, length, proper_count): # check that we get the expected number of proper and total point groups. - group = get_point_groups(subset) + group = PointGroups.get_set(subset) assert len(group) == length assert np.sum([x.is_proper for x in group]) == proper_count def test_get_point_groups_unique(): - group = get_point_groups() + group = PointGroups.get_set("groups") # this is just a check to see if each element is unique, and if there are # 32 of them. assert np.all( @@ -929,7 +965,7 @@ def test_get_point_groups_unique(): ) # additional test that nonsense returns nonsense with pytest.raises(ValueError): - get_point_groups("banana") + PointGroups.get_set("banana") class TestLaueGroup: From 46e86638f6df42fbda110029767fe2e381bcdb2d Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:55:52 -0600 Subject: [PATCH 14/40] typos --- examples/stereographic_projection/plot_symmetry_operations.py | 2 +- orix/quaternion/symmetry.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 22806345e..af843573e 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -4,7 +4,7 @@ ======================== This example shows how stereographic projections with symmetry operators can be -automatically generated using orix for the 32 crystallographic point groups. +automatically generated for the 32 crystallographic point groups. The ordering follows the one given in section 9.2 of "Structures of Materials" (DeGraef et.al, 2nd edition, 2012), starting with the cyclic groups, then the diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index b6604c566..203a7c4aa 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1368,8 +1368,8 @@ def get(name: Literal[PointGroups._point_group_names]): # If the name still cannot be found, return a ValueError except ValueError: raise ValueError( - f"'name' must be one of {", ".join(map(str, pg_dict.keys()))}," - + f" {", ".join(map(str, pg_dict_s.keys()))}, or must be a string or " + f"'name' must be one of {', '.join(map(str, pg_dict.keys()))}," + + f" {', '.join(map(str, pg_dict_s.keys()))}, or must be a string or " + "integer recognized by diffpy.structure.spacegroups.GetSpaceGroup" + f". name = '{name}' is not a valid value." ) From 19b6e47fc0b6d95f1be0beb6873a7971a8937e05 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Wed, 9 Jul 2025 18:20:34 -0600 Subject: [PATCH 15/40] formatting --- examples/stereographic_projection/plot_symmetry_operations.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index af843573e..5fe6b6029 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -25,6 +25,7 @@ """ import matplotlib.pyplot as plt + import orix.plot from orix.quaternion.symmetry import PointGroups from orix.vector import Vector3d From 276d45675924d641ae0c14bc74577dc8ebef115a Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Wed, 9 Jul 2025 18:26:30 -0600 Subject: [PATCH 16/40] more formatting --- orix/quaternion/symmetry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 203a7c4aa..b1abb109a 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1486,7 +1486,7 @@ def get_set(self, name: Literal[PointGroups._subset_names] = "groups"): return PointGroups(self._pg_sets[name.lower()]) # if the name doesn't exist, return a ValueError raise ValueError( - f"'name' must be one of {", ".join(map(str, pg_opts))}, not '{name}'" + f"'name' must be one of {', '.join(map(str, pg_opts))}, not '{name}'" ) From 085be4a7e2313131650db696ba5d5997f19f06f4 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:41:21 -0600 Subject: [PATCH 17/40] Correct C2v to match ITOC see chapter 12, as wel as figure 10.3.2 of International Tables of Crystallography, Volume A, for details on this discrepency, but The Cyclic rotation axis should be the z axis, not the x axis, in the standard form. --- orix/quaternion/symmetry.py | 85 +++++++-- orix/tests/quaternion/test_orientation.py | 43 +++-- .../quaternion/test_orientation_region.py | 10 +- orix/tests/quaternion/test_symmetry.py | 149 +++++++++++---- orix/tests/test_crystal_map.py | 143 +++++++++++---- orix/tests/test_miller.py | 171 +++++++++++++----- 6 files changed, 445 insertions(+), 156 deletions(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 417f04add..d99765081 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -19,12 +19,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Union from diffpy.structure.spacegroups import GetSpaceGroup import matplotlib.figure as mfigure import numpy as np +from orix._util import deprecated from orix.quaternion.rotation import Rotation from orix.vector.vector3d import Vector3d @@ -170,7 +171,17 @@ def system(self) -> str | None: name = self.name if name in ["1", "-1"]: return "triclinic" - elif name in ["211", "121", "112", "2", "m11", "1m1", "11m", "m", "2/m"]: + elif name in [ + "211", + "121", + "112", + "2", + "m11", + "1m1", + "11m", + "m", + "2/m", + ]: return "monoclinic" elif name in ["222", "mm2", "mmm"]: return "orthorhombic" @@ -220,7 +231,8 @@ def fundamental_sector(self) -> "FundamentalSector": if self.size > 1 + n.size: angle = 2 * np.pi * (1 + n.size) / self.size new_v = Vector3d.from_polar( - azimuth=[np.pi / 2, angle - np.pi / 2], polar=[np.pi / 2, np.pi / 2] + azimuth=[np.pi / 2, angle - np.pi / 2], + polar=[np.pi / 2, np.pi / 2], ) n = Vector3d(np.vstack([n.data, new_v.data])) @@ -250,7 +262,9 @@ def fundamental_sector(self) -> "FundamentalSector": # Taken from MTEX center = Vector3d([0.707558, -0.000403, 0.706655]) elif name in ["m-3", "432"]: - n = Vector3d(np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data])) + n = Vector3d( + np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data]) + ) # Taken from MTEX center = Vector3d([0.349928, 0.348069, 0.869711]) elif name == "-43m": @@ -358,7 +372,9 @@ def __and__(self, other: Symmetry) -> Symmetry: return Symmetry.from_generators(*generators) def __hash__(self) -> int: - return hash(self.name.encode() + self.data.tobytes() + self.improper.tobytes()) + return hash( + self.name.encode() + self.data.tobytes() + self.improper.tobytes() + ) # ------------------------ Class methods ------------------------- # @@ -416,7 +432,9 @@ def get_axis_orders(self) -> dict[Vector3d, int]: return {} return { Vector3d(a): b + 1 - for a, b in zip(*np.unique(s.axis.data, axis=0, return_counts=True)) + for a, b in zip( + *np.unique(s.axis.data, axis=0, return_counts=True) + ) } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: @@ -536,9 +554,13 @@ def plot( # Triclinic C1 = Symmetry((1, 0, 0, 0)) C1.name = "1" -Ci = Symmetry([(1, 0, 0, 0), (1, 0, 0, 0)]) +Ci = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) Ci.improper = [0, 1] Ci.name = "-1" +# include redundant point group S2 == Ci +S2 = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) +S2.improper = [0, 1] +S2.name = "-1" # Special generators _mirror_xy = Symmetry([(1, 0, 0, 0), (0, 0.75**0.5, -(0.75**0.5), 0)]) @@ -554,6 +576,9 @@ def plot( C2z.name = "112" C2 = Symmetry(C2z) C2.name = "2" +# included redundant point group D1 == C2 +D1 = Symmetry(C2z) +D1.name = "2" # Mirrors Csx = Symmetry([(1, 0, 0, 0), (0, 1, 0, 0)]) @@ -575,7 +600,7 @@ def plot( # Orthorhombic D2 = Symmetry.from_generators(C2z, C2x, C2y) D2.name = "222" -C2v = Symmetry.from_generators(C2x, Csz) +C2v = Symmetry.from_generators(C2z, Csx) C2v.name = "mm2" D2h = Symmetry.from_generators(Csz, Csx, Csy) D2h.name = "mmm" @@ -586,7 +611,7 @@ def plot( (1, 0, 0, 0), (0.5**0.5, 0.5**0.5, 0, 0), (0, 1, 0, 0), - (-(0.5**0.5), 0.5**0.5, 0, 0), + ((0.5**0.5), -(0.5**0.5), 0, 0), ] ) C4y = Symmetry( @@ -594,7 +619,7 @@ def plot( (1, 0, 0, 0), (0.5**0.5, 0, 0.5**0.5, 0), (0, 0, 1, 0), - (-(0.5**0.5), 0, 0.5**0.5, 0), + ((0.5**0.5), -0, 0.5**0.5, 0), ] ) C4z = Symmetry( @@ -602,7 +627,7 @@ def plot( (1, 0, 0, 0), (0.5**0.5, 0, 0, 0.5**0.5), (0, 0, 0, 1), - (-(0.5**0.5), 0, 0, 0.5**0.5), + ((0.5**0.5), 0, 0, -(0.5**0.5)), ] ) C4 = Symmetry(C4z) @@ -612,6 +637,10 @@ def plot( S4 = Symmetry(C4) S4.improper = [0, 1, 0, 1] S4.name = "-4" +# include redundant point group C4i == S4 +C4i = Symmetry(C4) +C4i.improper = [0, 1, 0, 1] +C4i.name = "-4" C4h = Symmetry.from_generators(C4, Cs) C4h.name = "4/m" D4 = Symmetry.from_generators(C4, C2x, C2y) @@ -624,13 +653,22 @@ def plot( D4h.name = "4/mmm" # 3-fold rotations -C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (-0.5, 0.75**0.5, 0, 0)]) -C3y = Symmetry([(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (-0.5, 0, 0.75**0.5, 0)]) -C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (-0.5, 0, 0, 0.75**0.5)]) +C3x = Symmetry( + [(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)] +) +C3y = Symmetry( + [(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)] +) +C3z = Symmetry( + [(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))] +) C3 = Symmetry(C3z) C3.name = "3" # Trigonal +C3i = Symmetry.from_generators(C3, Ci) +C3i.name = "-3" +# include redundant point group S6==C3i S6 = Symmetry.from_generators(C3, Ci) S6.name = "-3" D3x = Symmetry.from_generators(C3, C2x) @@ -716,7 +754,24 @@ def plot( Oh, # Cubic m-3m m-3m 432 ] # fmt: on -_proper_groups = [C1, C2, C2x, C2y, C2z, D2, C4, D4, C3, D3x, D3y, D3, C6, D6, T, O] +_proper_groups = [ + C1, + C2, + C2x, + C2y, + C2z, + D2, + C4, + D4, + C3, + D3x, + D3y, + D3, + C6, + D6, + T, + O, +] def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index f02424500..a8e87ee9b 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -89,11 +89,11 @@ def test_quaternion_subclasses_copy_constructor_casting(): # 7pi/12 -C2-> # 7pi/12 ([(0.6088, 0, 0, 0.7934)], C2, [(-0.7934, 0, 0, 0.6088)]), # 7pi/12 -C3-> # 7pi/12 - ([(0.6088, 0, 0, 0.7934)], C3, [(-0.9914, 0, 0, 0.1305)]), + ([(0.6088, 0, 0, 0.7934)], C3, [(0.9914, 0, 0, -0.1305)]), # 7pi/12 -C4-> # pi/12 - ([(0.6088, 0, 0, 0.7934)], C4, [(-0.9914, 0, 0, -0.1305)]), + ([(0.6088, 0, 0, 0.7934)], C4, [(0.9914, 0, 0, 0.1305)]), # 7pi/12 -O-> # pi/12 - ([(0.6088, 0, 0, 0.7934)], O, [(-0.9914, 0, 0, -0.1305)]), + ([(0.6088, 0, 0, 0.7934)], O, [(0.9914, 0, 0, 0.1305)]), ], indirect=["orientation"], ) @@ -297,7 +297,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)) @@ -309,7 +310,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 @@ -502,13 +505,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.map_into_symmetry_reduced_zone() 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) @@ -595,7 +600,9 @@ def test_from_scipy_rotation(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) @@ -622,11 +629,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) @@ -673,7 +684,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) @@ -708,7 +721,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 @@ -801,7 +816,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_orientation_region.py b/orix/tests/quaternion/test_orientation_region.py index e377cd40c..8a3c0bbf9 100644 --- a/orix/tests/quaternion/test_orientation_region.py +++ b/orix/tests/quaternion/test_orientation_region.py @@ -35,8 +35,8 @@ [ [0.5, 0, 0, 0.866], [-0.5, 0, 0, -0.866], - [-0.5, 0, 0, 0.866], [0.5, 0, 0, -0.866], + [-0.5, 0, 0, 0.866], ], ), ( @@ -45,14 +45,14 @@ [ [0.5, 0.0, 0.0, 0.866], [-0.5, 0.0, 0.0, -0.866], - [-0.5, 0.0, 0.0, 0.866], - [0.5, -0.0, -0.0, -0.866], + [0.5, 0.0, 0.0, -0.866], + [-0.5, -0.0, -0.0, 0.866], [0.0, 1.0, 0.0, 0.0], [-0.0, -1.0, -0.0, -0.0], [0.0, 0.5, 0.866, 0.0], - [-0.0, -0.5, -0.866, 0.0], - [0.0, -0.5, 0.866, 0.0], + [-0.0, -0.5, -0.866, -0.0], [0.0, 0.5, -0.866, 0.0], + [-0.0, -0.5, 0.866, -0.0], ], ), ], diff --git a/orix/tests/quaternion/test_symmetry.py b/orix/tests/quaternion/test_symmetry.py index bd7ac463a..a92ccdc4e 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -64,9 +64,9 @@ def all_symmetries(request): (1, 2, 3), [ (1, 2, 3), + (-1, -2, 3), + (-1, 2, 3), (1, -2, 3), - (1, -2, -3), - (1, 2, -3), ], ), ( @@ -277,7 +277,7 @@ def test_is_proper(symmetry, expected): [ (C1, [C1]), (D2, [C1, C2x, C2y, C2z, D2]), - (C6v, [C1, Csx, Csy, C2z, C3, C3v, C6, C6v]), + (C6v, [C1, C2z, Csx, Csy, C2v, C3, C3v, C6, C6v]), ], ) def test_subgroups(symmetry, expected): @@ -305,7 +305,7 @@ def test_proper_subgroups(symmetry, expected): (Cs, C1), (C2h, C2), (D2, D2), - (C2v, C2x), + (C2v, C2z), (C4, C4), (C4h, C4), (C3h, C3), @@ -444,8 +444,13 @@ def test_get_point_group(): sg = GetSpaceGroup(sg_number) pg = get_point_group(sg_number, proper=False) - assert proper_pg == spacegroup2pointgroup_dict[sg.point_group_name]["proper"] - assert pg == spacegroup2pointgroup_dict[sg.point_group_name]["improper"] + assert ( + proper_pg + == spacegroup2pointgroup_dict[sg.point_group_name]["proper"] + ) + assert ( + pg == spacegroup2pointgroup_dict[sg.point_group_name]["improper"] + ) def test_unique_symmetry_elements_subgroups(all_symmetries): @@ -537,7 +542,9 @@ def test_symmetry_plot(pg): @pytest.mark.parametrize("symmetry", [C1, C4, Oh]) def test_symmetry_plot_raises(symmetry): - with pytest.raises(TypeError, match="Orientation must be a Rotation instance"): + with pytest.raises( + TypeError, match="Orientation must be a Rotation instance" + ): _ = symmetry.plot(return_figure=True, orientation="test") @@ -595,9 +602,9 @@ def test_fundamental_sector_d2(self): def test_fundamental_sector_c2v(self): pg = C2v # mm2 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0]]) - assert np.allclose(fs.vertices.data, [[1, 0, 0], [-1, 0, 0]]) - assert np.allclose(fs.center.data, [[0, 0.5, 0.5]]) + assert np.allclose(fs.data, [[1, 0, 0], [0, 1, 0]]) + assert np.allclose(fs.vertices.data, [[0, 0, -1], [0, 0, 1]]) + assert np.allclose(fs.center.data, [[0.5, 0.5, 0]]) def test_fundamental_sector_d2h(self): pg = D2h # mmm @@ -637,7 +644,9 @@ def test_fundamental_sector_d4(self): def test_fundamental_sector_c4v(self): pg = C4v # 4mm fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 1, 0], [0.7071, -0.7071, 0]], atol=1e-4) + assert np.allclose( + fs.data, [[0, 1, 0], [0.7071, -0.7071, 0]], atol=1e-4 + ) assert np.allclose(fs.vertices.data, [[0, 0, 1], [0, 0, -1]]) assert np.allclose(fs.center.data, [[0.3536, 0.1464, 0]], atol=1e-4) @@ -645,10 +654,13 @@ def test_fundamental_sector_d2d(self): pg = D2d # -42m fs = pg.fundamental_sector assert np.allclose( - fs.data, [[0, 0, 1], [0.7071, 0.7071, 0], [0.7071, -0.7071, 0]], atol=1e-4 + fs.data, + [[0, 0, 1], [0.7071, 0.7071, 0], [0.7071, -0.7071, 0]], + atol=1e-4, ) assert np.allclose( - fs.vertices.data, [[0.7071, -0.7071, 0], [0, 0, 1], [0.7071, 0.7071, 0]] + fs.vertices.data, + [[0.7071, -0.7071, 0], [0, 0, 1], [0.7071, 0.7071, 0]], ) assert np.allclose(fs.center.data, [[0.4714, 0, 1 / 3]], atol=1e-4) @@ -659,7 +671,9 @@ def test_fundamental_sector_d4h(self): fs.data, [[0, 0, 1], [0, 1, 0], [0.7071, -0.7071, 0]], atol=1e-4 ) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.7071, 0.7071, 0]], atol=1e-4 + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [0.7071, 0.7071, 0]], + atol=1e-4, ) assert np.allclose(fs.center.data, [[0.569, 0.2357, 1 / 3]], atol=1e-3) @@ -673,25 +687,35 @@ def test_fundamental_sector_c3(self): def test_fundamental_sector_s6(self): pg = S6 # -3 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], atol=1e-4 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], + atol=1e-4, ) assert np.allclose(fs.center.data, [[1 / 6, 0.2887, 1 / 3]], atol=1e-4) def test_fundamental_sector_d3(self): pg = D3 # 32 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], atol=1e-4 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], + atol=1e-4, ) assert np.allclose(fs.center.data, [[1 / 6, 0.2887, 1 / 3]], atol=1e-4) def test_fundamental_sector_c3v(self): pg = C3v # 3m fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0.5, 0.866, 0], [0.5, -0.866, 0]], atol=1e-3) + assert np.allclose( + fs.data, [[0.5, 0.866, 0], [0.5, -0.866, 0]], atol=1e-3 + ) assert np.allclose(fs.vertices.data, [[0, 0, 1], [0, 0, -1]]) assert np.allclose(fs.center.data, [[0.5, 0, 0]]) @@ -702,7 +726,9 @@ def test_fundamental_sector_d3d(self): fs.data, [[0, 0, 1], [0.5, 0.866, 0], [0.5, -0.866, 0]], atol=1e-3 ) assert np.allclose( - fs.vertices.data, [[0.866, -0.5, 0], [0, 0, 1], [0.866, 0.5, 0]], atol=1e-3 + fs.vertices.data, + [[0.866, -0.5, 0], [0, 0, 1], [0.866, 0.5, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[0.577, 0, 1 / 3]], atol=1e-3) @@ -716,27 +742,39 @@ def test_fundamental_sector_c6(self): def test_fundamental_sector_c3h(self): pg = C3h # -6 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], atol=1e-3 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[1 / 6, 0.2887, 1 / 3]], atol=1e-4) def test_fundamental_sector_c6h(self): pg = C6h # 6/m fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], atol=1e-3 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[0.5, 0.2887, 1 / 3]], atol=1e-4) def test_fundamental_sector_d6(self): pg = D6 # 622 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], atol=1e-3 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[0.5, 0.2887, 1 / 3]], atol=1e-4) @@ -750,31 +788,48 @@ def test_fundamental_sector_c6v(self): def test_fundamental_sector_d3h(self): pg = D3h # -6m2 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], atol=1e-3 + fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[0.5, 0.2887, 1 / 3]], atol=1e-4) def test_fundamental_sector_d6h(self): pg = D6h # 6/mmm fs = pg.fundamental_sector - assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.5, -0.866, 0]], atol=1e-3) assert np.allclose( - fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.866, 0.5, 0]], atol=1e-3 + fs.data, [[0, 0, 1], [0, 1, 0], [0.5, -0.866, 0]], atol=1e-3 + ) + assert np.allclose( + fs.vertices.data, + [[1, 0, 0], [0, 0, 1], [0.866, 0.5, 0]], + atol=1e-3, ) assert np.allclose(fs.center.data, [[0.622, 0.1667, 1 / 3]], atol=1e-4) def test_fundamental_sector_t(self): pg = T # 23 fs = pg.fundamental_sector - assert np.allclose(fs.data, [[1, 1, 0], [1, -1, 0], [0, -1, 1], [0, 1, 1]]) + assert np.allclose( + fs.data, [[1, 1, 0], [1, -1, 0], [0, -1, 1], [0, 1, 1]] + ) assert np.allclose( fs.vertices.data, - [[0, 0, 1], [0.5774, 0.5774, 0.5774], [1, 0, 0], [0.5774, -0.5774, 0.5774]], + [ + [0, 0, 1], + [0.5774, 0.5774, 0.5774], + [1, 0, 0], + [0.5774, -0.5774, 0.5774], + ], atol=1e-4, ) - assert np.allclose(fs.center.data, [[0.7076, -0.0004, 0.7067]], atol=1e-4) + assert np.allclose( + fs.center.data, [[0.7076, -0.0004, 0.7067]], atol=1e-4 + ) def test_fundamental_sector_th(self): pg = Th # m-3 @@ -793,7 +848,9 @@ def test_fundamental_sector_th(self): ], atol=1e-3, ) - assert np.allclose(fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4) + assert np.allclose( + fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4 + ) def test_fundamental_sector_o(self): pg = O # 432 @@ -811,7 +868,9 @@ def test_fundamental_sector_o(self): ], atol=1e-3, ) - assert np.allclose(fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4) + assert np.allclose( + fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4 + ) def test_fundamental_sector_td(self): pg = Td # -43m @@ -833,7 +892,9 @@ def test_fundamental_sector_oh(self): [[0.5774, 0.5774, 0.5774], [0.7071, 0, 0.7071], [0, 0, 1]], atol=1e-4, ) - assert np.allclose(fs.center.data, [[0.4282, 0.1925, 0.7615]], atol=1e-4) + assert np.allclose( + fs.center.data, [[0.4282, 0.1925, 0.7615]], atol=1e-4 + ) # ---------- End of the 32 crystallographic point groups --------- # @@ -946,10 +1007,16 @@ def test_primary_axis_order(self): def test_special_rotation(self): for pg in [C1, C2z, C3, C4, C6]: assert np.allclose(pg._special_rotation.data, (1, 0, 0, 0)) - assert np.allclose(C2x._special_rotation.data, ((1, 0, 0, 0), (0, 1, 0, 0))) - assert np.allclose(C2y._special_rotation.data, ((1, 0, 0, 0), (0, 0, 1, 0))) + assert np.allclose( + C2x._special_rotation.data, ((1, 0, 0, 0), (0, 1, 0, 0)) + ) + assert np.allclose( + C2y._special_rotation.data, ((1, 0, 0, 0), (0, 0, 1, 0)) + ) for pg in [D2, D4, D6, D3]: - assert np.allclose(pg._special_rotation.data, ((1, 0, 0, 0), (0, -1, 0, 0))) + assert np.allclose( + pg._special_rotation.data, ((1, 0, 0, 0), (0, -1, 0, 0)) + ) assert np.allclose( D3y._special_rotation.data, ((1, 0, 0, 0), (0, -1 / np.sqrt(2), 1 / np.sqrt(2), 0)), @@ -978,7 +1045,9 @@ def test_special_rotation(self): ) unrecognized_symmetry = Symmetry.random(4) - assert np.allclose(unrecognized_symmetry._special_rotation.data, (1, 0, 0, 0)) + assert np.allclose( + unrecognized_symmetry._special_rotation.data, (1, 0, 0, 0) + ) # All point groups provide at least one rotation for pg in _groups: diff --git a/orix/tests/test_crystal_map.py b/orix/tests/test_crystal_map.py index b8b4a38d6..c7d773048 100644 --- a/orix/tests/test_crystal_map.py +++ b/orix/tests/test_crystal_map.py @@ -20,7 +20,12 @@ import numpy as np import pytest -from orix.crystal_map import CrystalMap, Phase, PhaseList, create_coordinate_arrays +from orix.crystal_map import ( + CrystalMap, + Phase, + PhaseList, + create_coordinate_arrays, +) from orix.crystal_map.crystal_map import _data_slices_from_coordinates from orix.plot import CrystalMapPlot from orix.quaternion import Orientation, Rotation @@ -169,7 +174,9 @@ def test_init_with_phase_list(self, crystal_map_input, expected_presence): ): assert (p1 == p2) == expected - unique_phase_ids = list(np.unique(crystal_map_input["phase_id"]).astype(int)) + unique_phase_ids = list( + np.unique(crystal_map_input["phase_id"]).astype(int) + ) assert xmap.phases.ids == unique_phase_ids def test_init_with_sparse_phase_list(self): @@ -207,8 +214,18 @@ def test_init_with_single_point_group(self, crystal_map_input): "crystal_map_input, phase_names, phase_ids, desired_phase_names", [ (((7, 4), (1, 1), 1, [0]), ["a", "b", "c"], [0, 1, 2], ["a"]), - (((7, 4), (1, 1), 1, [0, 1]), ["a", "b", "c"], [0, 2, 1], ["a", "c"]), - (((7, 4), (1, 1), 1, [0, 2]), ["a", "b", "c"], [0, 2, 1], ["a", "b"]), + ( + ((7, 4), (1, 1), 1, [0, 1]), + ["a", "b", "c"], + [0, 2, 1], + ["a", "c"], + ), + ( + ((7, 4), (1, 1), 1, [0, 2]), + ["a", "b", "c"], + [0, 2, 1], + ["a", "b"], + ), (((7, 4), (1, 1), 1, [3]), ["a", "b", "c"], [0, 2, 1], ["a"]), ], indirect=["crystal_map_input"], @@ -293,21 +310,29 @@ def test_is_in_data(self, crystal_map_input): # All points in data xmap = CrystalMap(is_in_data=is_in_data.flatten(), **crystal_map_input) assert xmap.shape == xmap._original_shape == map_shape - assert np.allclose(xmap.get_map_data("phase_id"), np.zeros(is_in_data.shape)) + assert np.allclose( + xmap.get_map_data("phase_id"), np.zeros(is_in_data.shape) + ) # First row not in data is_in_data1 = is_in_data.copy() is_in_data1[0, :] = False - xmap1 = CrystalMap(is_in_data=is_in_data1.flatten(), **crystal_map_input) + xmap1 = CrystalMap( + is_in_data=is_in_data1.flatten(), **crystal_map_input + ) new_shape1 = (map_shape[0] - 1, map_shape[1]) assert xmap1.shape == new_shape1 assert xmap1._original_shape == map_shape - assert np.allclose(xmap1.get_map_data("phase_id"), np.zeros(new_shape1)) + assert np.allclose( + xmap1.get_map_data("phase_id"), np.zeros(new_shape1) + ) # Second row not in data is_in_data2 = is_in_data.copy() is_in_data2[1, :] = False - xmap2 = CrystalMap(is_in_data=is_in_data2.flatten(), **crystal_map_input) + xmap2 = CrystalMap( + is_in_data=is_in_data2.flatten(), **crystal_map_input + ) assert xmap2.shape == xmap2._original_shape == map_shape desired_phase_id = np.zeros(is_in_data.shape) desired_phase_id[1, :] = np.nan @@ -318,11 +343,15 @@ def test_is_in_data(self, crystal_map_input): # Last column not in data is_in_data3 = is_in_data.copy() is_in_data3[:, -1] = False - xmap3 = CrystalMap(is_in_data=is_in_data3.flatten(), **crystal_map_input) + xmap3 = CrystalMap( + is_in_data=is_in_data3.flatten(), **crystal_map_input + ) new_shape3 = (map_shape[0], map_shape[1] - 1) assert xmap3.shape == new_shape3 assert xmap3._original_shape == map_shape - assert np.allclose(xmap3.get_map_data("phase_id"), np.zeros(new_shape3)) + assert np.allclose( + xmap3.get_map_data("phase_id"), np.zeros(new_shape3) + ) def test_set_phases(self, phase_list): assert phase_list.size == 3 @@ -334,7 +363,9 @@ def test_set_phases(self, phase_list): # Not OK xmap._phase_id = np.array([0, 1, 2, 1, 2, 3]) - with pytest.raises(ValueError, match="There must be at least as many phases "): + with pytest.raises( + ValueError, match="There must be at least as many phases " + ): xmap.phases = phase_list @@ -366,7 +397,9 @@ class TestCrystalMapGetItem: ], indirect=["crystal_map_input"], ) - def test_get_by_slice(self, crystal_map_input, slice_tuple, expected_shape): + def test_get_by_slice( + self, crystal_map_input, slice_tuple, expected_shape + ): xmap = CrystalMap(**crystal_map_input) xmap2 = xmap[slice_tuple] @@ -383,7 +416,9 @@ def test_get_by_phase_name(self, crystal_map_input, phase_list): # Get number of points with each phase ID n_points_per_phase = {} for phase_i, phase in phase_list: - n_points_per_phase[phase.name] = len(np.where(phase_ids == phase_i)[0]) + n_points_per_phase[phase.name] = len( + np.where(phase_ids == phase_i)[0] + ) crystal_map_input.pop("phase_id") xmap = CrystalMap(phase_id=phase_ids, **crystal_map_input) @@ -467,7 +502,9 @@ def test_get_by_integer( point = xmap[integer_slices] expected_point = xmap[xmap.id == expected_id] - assert np.allclose(point.rotations.data, expected_point.rotations.data) + assert np.allclose( + point.rotations.data, expected_point.rotations.data + ) assert point._coordinates == expected_point._coordinates def test_get_twice(self): @@ -587,11 +624,17 @@ def test_set_phase_ids(self, crystal_map, set_phase_id): assert "not_indexed" in xmap.phases.names def test_set_phase_ids_raises(self, crystal_map): - with pytest.raises(ValueError, match="NumPy boolean array indexing assignment"): + with pytest.raises( + ValueError, match="NumPy boolean array indexing assignment" + ): crystal_map[1, 1].phase_id = -1 * np.ones(10) - @pytest.mark.parametrize("set_phase_id, index_error", [(-1, False), (1, True)]) - def test_set_phase_id_with_unknown_id(self, crystal_map, set_phase_id, index_error): + @pytest.mark.parametrize( + "set_phase_id, index_error", [(-1, False), (1, True)] + ) + def test_set_phase_id_with_unknown_id( + self, crystal_map, set_phase_id, index_error + ): xmap = crystal_map condition = xmap.x > 1.5 @@ -625,7 +668,9 @@ def test_phases_in_data(self, crystal_map, phase_list): ) condition1 = xmap.x > 1.5 condition2 = xmap.y > 1.5 - for new_id, condition in zip(ids_not_in_data, [condition1, condition2]): + for new_id, condition in zip( + ids_not_in_data, [condition1, condition2] + ): xmap[condition].phase_id = new_id assert xmap.phases_in_data.names == xmap.phases.names @@ -660,12 +705,14 @@ def test_orientations(self, crystal_map_input, phase_list): "point_group, rotation, expected_orientation", [ (C2, [(0.6088, 0, 0, 0.7934)], [(-0.7934, 0, 0, 0.6088)]), - (C3, [(0.6088, 0, 0, 0.7934)], [(-0.9914, 0, 0, 0.1305)]), - (C4, [(0.6088, 0, 0, 0.7934)], [(-0.9914, 0, 0, -0.1305)]), - (O, [(0.6088, 0, 0, 0.7934)], [(-0.9914, 0, 0, -0.1305)]), + (C3, [(0.6088, 0, 0, 0.7934)], [(0.9914, 0, 0, -0.1305)]), + (C4, [(0.6088, 0, 0, 0.7934)], [(0.9914, 0, 0, 0.1305)]), + (O, [(0.6088, 0, 0, 0.7934)], [(0.9914, 0, 0, 0.1305)]), ], ) - def test_orientations_symmetry(self, point_group, rotation, expected_orientation): + def test_orientations_symmetry( + self, point_group, rotation, expected_orientation + ): r = Rotation(rotation) xmap = CrystalMap(rotations=r, phase_id=np.array([0])) xmap.phases = PhaseList(Phase("a", point_group=point_group)) @@ -686,7 +733,9 @@ def test_orientations_none_symmetry_raises(self, crystal_map_input): with pytest.raises(TypeError, match="Value must be an instance of"): _ = xmap.orientations - def test_orientations_multiple_phases_raises(self, crystal_map, phase_list): + def test_orientations_multiple_phases_raises( + self, crystal_map, phase_list + ): xmap = crystal_map xmap.phases = phase_list @@ -735,7 +784,9 @@ def test_overwrite_crystal_map_property_values(self, crystal_map): new_prop_value = -1 xmap.__setattr__(prop_name, new_prop_value) - assert np.allclose(xmap.prop[prop_name], np.ones(xmap.size) * new_prop_value) + assert np.allclose( + xmap.prop[prop_name], np.ones(xmap.size) * new_prop_value + ) class TestCrystalMapMasking: @@ -762,12 +813,16 @@ class TestCrystalMapGetMapData: ( ((4, 4), (0.5, 1), 2, [0]), "y", - np.array([[i * 0.5] * 4 for i in range(4)]), # [0, 0, 0, 0, 0.5, ...] + np.array( + [[i * 0.5] * 4 for i in range(4)] + ), # [0, 0, 0, 0, 0.5, ...] ), ], indirect=["crystal_map_input"], ) - def test_get_coordinate_array(self, crystal_map_input, to_get, expected_array): + def test_get_coordinate_array( + self, crystal_map_input, to_get, expected_array + ): xmap = CrystalMap(**crystal_map_input) # Get via string @@ -900,7 +955,9 @@ def test_get_boolean_array(self, crystal_map): assert np.issubdtype(xmap.get_map_data("is_indexed").dtype, bool) assert np.issubdtype(xmap.get_map_data(xmap.is_in_data).dtype, bool) - @pytest.mark.parametrize("dtype_in", [np.uint8, int, np.float32, float, bool]) + @pytest.mark.parametrize( + "dtype_in", [np.uint8, int, np.float32, float, bool] + ) def test_preserve_dtype(self, crystal_map, dtype_in): xmap = crystal_map prop_name = "new_prop" @@ -966,11 +1023,15 @@ def test_representation(self, crystal_map, phase_list): class TestCrystalMapCopying: def test_shallowcopy_crystal_map(self, crystal_map): - xmap2 = crystal_map[:] # Everything except `is_in_data` is shallow copied + xmap2 = crystal_map[ + : + ] # Everything except `is_in_data` is shallow copied xmap3 = crystal_map # These are the same objects (of course) assert np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) - assert np.may_share_memory(xmap2._rotations.data, crystal_map._rotations.data) + assert np.may_share_memory( + xmap2._rotations.data, crystal_map._rotations.data + ) crystal_map[3, 0].phase_id = -2 assert np.allclose(xmap2.phase_id, crystal_map.phase_id) @@ -988,7 +1049,10 @@ def test_deepcopy_crystal_map(self, crystal_map): crystal_map[3, 0].phase_id = -2 assert np.allclose(xmap2.phase_id, crystal_map.phase_id) is False - assert np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) is False + assert ( + np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) + is False + ) class TestCrystalMapShape: @@ -1015,7 +1079,9 @@ class TestCrystalMapShape: ], indirect=["crystal_map_input"], ) - def test_data_slices_from_coordinates(self, crystal_map_input, expected_slices): + def test_data_slices_from_coordinates( + self, crystal_map_input, expected_slices + ): xmap = CrystalMap(**crystal_map_input) assert xmap._data_slices_from_coordinates() == expected_slices @@ -1042,7 +1108,12 @@ def test_data_slices_from_coordinates(self, crystal_map_input, expected_slices): indirect=["crystal_map_input"], ) def test_data_slice_from_coordinates_masked( - self, crystal_map_input, slices, expected_size, expected_shape, expected_slices + self, + crystal_map_input, + slices, + expected_size, + expected_shape, + expected_slices, ): xmap = CrystalMap(**crystal_map_input) @@ -1092,7 +1163,9 @@ def test_rotation_shape(self, crystal_map_input, expected_shape): ], indirect=["crystal_map_input"], ) - def test_coordinate_axes(self, crystal_map_input, expected_coordinate_axes): + def test_coordinate_axes( + self, crystal_map_input, expected_coordinate_axes + ): xmap = CrystalMap(**crystal_map_input) assert xmap._coordinate_axes == expected_coordinate_axes @@ -1135,5 +1208,7 @@ def test_plot(self, crystal_map): class TestCreateCoordinateArrays: def test_create_coordinate_arrays_raises(self): - with pytest.raises(ValueError, match="Can only create coordinate arrays for "): + with pytest.raises( + ValueError, match="Can only create coordinate arrays for " + ): _ = create_coordinate_arrays((1, 2, 3)) diff --git a/orix/tests/test_miller.py b/orix/tests/test_miller.py index d16969666..6bc7a5b91 100644 --- a/orix/tests/test_miller.py +++ b/orix/tests/test_miller.py @@ -22,10 +22,16 @@ from orix.crystal_map import Phase from orix.quaternion import Orientation, symmetry from orix.vector import Miller -from orix.vector.miller import _round_indices, _transform_space, _UVTW2uvw, _uvw2UVTW +from orix.vector.miller import ( + _round_indices, + _transform_space, + _UVTW2uvw, + _uvw2UVTW, +) TRIGONAL_PHASE = Phase( - point_group="321", structure=Structure(lattice=Lattice(4.9, 4.9, 5.4, 90, 90, 120)) + point_group="321", + structure=Structure(lattice=Lattice(4.9, 4.9, 5.4, 90, 90, 120)), ) TETRAGONAL_LATTICE = Lattice(0.5, 0.5, 1, 90, 90, 90) TETRAGONAL_PHASE = Phase( @@ -43,17 +49,26 @@ def test_init_raises(self): with pytest.raises(ValueError, match="Exactly *"): _ = Miller(phase=Phase(point_group="m-3m")) with pytest.raises(ValueError, match="Exactly *"): - _ = Miller(xyz=[0, 1, 2], hkl=[3, 4, 5], phase=Phase(point_group="m-3m")) - with pytest.raises(ValueError, match="A phase with a crystal lattice and "): + _ = Miller( + xyz=[0, 1, 2], hkl=[3, 4, 5], phase=Phase(point_group="m-3m") + ) + with pytest.raises( + ValueError, match="A phase with a crystal lattice and " + ): _ = Miller(hkl=[1, 1, 1]) def test_repr(self): m = Miller(hkil=[1, 1, -2, 0], phase=TRIGONAL_PHASE) - assert repr(m) == "Miller (1,), point group 321, hkil\n" "[[ 1. 1. -2. 0.]]" + assert ( + repr(m) == "Miller (1,), point group 321, hkil\n" + "[[ 1. 1. -2. 0.]]" + ) def test_coordinate_format_raises(self): m = Miller(hkl=[1, 1, 1], phase=TETRAGONAL_PHASE) - with pytest.raises(ValueError, match="Available coordinate formats are "): + with pytest.raises( + ValueError, match="Available coordinate formats are " + ): m.coordinate_format = "abc" def test_set_coordinates(self): @@ -78,11 +93,19 @@ def test_set_hkl_hkil(self): assert np.allclose([m1.h, m1.k, m1.l], [[1, 1], [2, 1], [0, 0]]) m2 = Miller(hkil=[[1, 1, -2, 1], [2, 0, -2, 0]], phase=TRIGONAL_PHASE) - assert np.allclose(m2.data, [[0.20, 0.35, 0.19], [0.41, 0.24, 0]], atol=0.01) - assert np.allclose([m2.h, m2.k, m2.i, m2.l], [[1, 2], [1, 0], [-2, -2], [1, 0]]) + assert np.allclose( + m2.data, [[0.20, 0.35, 0.19], [0.41, 0.24, 0]], atol=0.01 + ) + assert np.allclose( + [m2.h, m2.k, m2.i, m2.l], [[1, 2], [1, 0], [-2, -2], [1, 0]] + ) m2.hkil = [[1, 2, -3, 1], [2, 1, -3, 1]] - assert np.allclose(m2.data, [[0.20, 0.59, 0.19], [0.41, 0.47, 0.19]], atol=0.01) - assert np.allclose([m2.h, m2.k, m2.i, m2.l], [[1, 2], [2, 1], [-3, -3], [1, 1]]) + assert np.allclose( + m2.data, [[0.20, 0.59, 0.19], [0.41, 0.47, 0.19]], atol=0.01 + ) + assert np.allclose( + [m2.h, m2.k, m2.i, m2.l], [[1, 2], [2, 1], [-3, -3], [1, 1]] + ) def test_set_uvw_UVTW(self): m1 = Miller(uvw=[[1, 1, 1], [2, 0, 0]], phase=TETRAGONAL_PHASE) @@ -93,11 +116,19 @@ def test_set_uvw_UVTW(self): assert np.allclose([m1.u, m1.v, m1.w], [[1, 1], [2, 1], [0, 0]]) m2 = Miller(UVTW=[[1, 1, -2, 1], [2, 0, -2, 0]], phase=TRIGONAL_PHASE) - assert np.allclose(m2.data, [[7.35, 12.73, 5.4], [14.7, 8.49, 0]], atol=0.01) - assert np.allclose([m2.U, m2.V, m2.T, m2.W], [[1, 2], [1, 0], [-2, -2], [1, 0]]) + assert np.allclose( + m2.data, [[7.35, 12.73, 5.4], [14.7, 8.49, 0]], atol=0.01 + ) + assert np.allclose( + [m2.U, m2.V, m2.T, m2.W], [[1, 2], [1, 0], [-2, -2], [1, 0]] + ) m2.UVTW = [[1, 2, -3, 1], [2, 1, -3, 1]] - assert np.allclose(m2.data, [[7.35, 21.22, 5.4], [14.7, 16.97, 5.4]], atol=0.01) - assert np.allclose([m2.U, m2.V, m2.T, m2.W], [[1, 2], [2, 1], [-3, -3], [1, 1]]) + assert np.allclose( + m2.data, [[7.35, 21.22, 5.4], [14.7, 16.97, 5.4]], atol=0.01 + ) + assert np.allclose( + [m2.U, m2.V, m2.T, m2.W], [[1, 2], [2, 1], [-3, -3], [1, 1]] + ) def test_length(self): # Direct lattice vectors @@ -110,7 +141,9 @@ def test_length(self): assert np.allclose(1 / m2.length, [0.224, 0.156], atol=1e-3) # Vectors - m3 = Miller(xyz=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], phase=TETRAGONAL_PHASE) + m3 = Miller( + xyz=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], phase=TETRAGONAL_PHASE + ) assert np.allclose(m3.length, [1, 1, 1]) def test_init_from_highest_indices(self): @@ -119,15 +152,21 @@ def test_init_from_highest_indices(self): m2 = Miller.from_highest_indices(phase=TETRAGONAL_PHASE, uvw=[1, 2, 3]) assert np.allclose(np.max(m2.uvw, axis=0), [1, 2, 3]) - with pytest.raises(ValueError, match="Either highest `hkl` or `uvw` indices "): + with pytest.raises( + ValueError, match="Either highest `hkl` or `uvw` indices " + ): _ = Miller.from_highest_indices(phase=TETRAGONAL_PHASE) with pytest.raises(ValueError, match="All indices*"): - _ = Miller.from_highest_indices(phase=TETRAGONAL_PHASE, hkl=[3, 2, -1]) + _ = Miller.from_highest_indices( + phase=TETRAGONAL_PHASE, hkl=[3, 2, -1] + ) def test_init_from_min_dspacing(self): # Tested against EMsoft v5.0 - m1 = Miller.from_min_dspacing(phase=TETRAGONAL_PHASE, min_dspacing=0.05) + m1 = Miller.from_min_dspacing( + phase=TETRAGONAL_PHASE, min_dspacing=0.05 + ) assert m1.coordinate_format == "hkl" assert m1.size == 14078 assert np.allclose(np.max(m1.hkl, axis=0), [9, 9, 19]) @@ -237,7 +276,9 @@ def test_unique(self): m3, idx = m2.unit.unique(return_index=True) assert m3.size == 205 assert isinstance(m3, Miller) - assert np.allclose(idx[:10], [65, 283, 278, 269, 255, 235, 208, 282, 272, 276]) + assert np.allclose( + idx[:10], [65, 283, 278, 269, 255, 235, 208, 282, 272, 276] + ) def test_multiply_orientation(self): o = Orientation.from_euler(np.deg2rad([45, 0, 0])) @@ -246,26 +287,38 @@ def test_multiply_orientation(self): m2 = o * m assert isinstance(m2, Miller) assert m2.coordinate_format == "hkl" - assert np.allclose(m2.data, [[np.sqrt(2), 0, 1], [np.sqrt(2), -np.sqrt(2), 0]]) + assert np.allclose( + m2.data, [[np.sqrt(2), 0, 1], [np.sqrt(2), -np.sqrt(2), 0]] + ) def test_overwritten_vector3d_methods(self): lattice = Lattice(1, 1, 1, 90, 90, 90) - phase1 = Phase(point_group="m-3m", structure=Structure(lattice=lattice)) + phase1 = Phase( + point_group="m-3m", structure=Structure(lattice=lattice) + ) phase2 = Phase(point_group="432", structure=Structure(lattice=lattice)) m1 = Miller(hkl=[[1, 1, 1], [2, 0, 0]], phase=phase1) m2 = Miller(hkil=[[1, 1, -2, 0], [2, 1, -3, 1]], phase=phase2) assert not m1._compatible_with(m2) - with pytest.raises(ValueError, match="The crystal lattices and symmetries"): + with pytest.raises( + ValueError, match="The crystal lattices and symmetries" + ): _ = m1.angle_with(m2) - with pytest.raises(ValueError, match="The crystal lattices and symmetries"): + with pytest.raises( + ValueError, match="The crystal lattices and symmetries" + ): _ = m1.cross(m2) - with pytest.raises(ValueError, match="The crystal lattices and symmetries"): + with pytest.raises( + ValueError, match="The crystal lattices and symmetries" + ): _ = m1.dot(m2) - with pytest.raises(ValueError, match="The crystal lattices and symmetries"): + with pytest.raises( + ValueError, match="The crystal lattices and symmetries" + ): _ = m1.dot_outer(m2) m3 = Miller(hkl=[[2, 0, 0], [1, 1, 1]], phase=phase1) @@ -273,10 +326,14 @@ def test_overwritten_vector3d_methods(self): def test_is_hexagonal(self): assert Miller(hkil=[1, 1, -2, 1], phase=TRIGONAL_PHASE).is_hexagonal - assert not Miller(hkil=[1, 1, -2, 1], phase=TETRAGONAL_PHASE).is_hexagonal + assert not Miller( + hkil=[1, 1, -2, 1], phase=TETRAGONAL_PHASE + ).is_hexagonal def test_various_shapes(self): - v = np.array([[1, 2, 0], [3, 1, 1], [1, 1, 1], [2, 0, 0], [1, 2, 3], [2, 2, 2]]) + v = np.array( + [[1, 2, 0], [3, 1, 1], [1, 1, 1], [2, 0, 0], [1, 2, 3], [2, 2, 2]] + ) # Initialization of vectors work as expected shape1 = (2, 3) @@ -386,7 +443,9 @@ def test_transform_space(self): assert not np.may_share_memory(v1, v2) # Incorrect space - with pytest.raises(ValueError, match="`space_in` and `space_out` must be one "): + with pytest.raises( + ValueError, match="`space_in` and `space_out` must be one " + ): _transform_space(v1, "direct", "cartesian", lattice) # uvw -> hkl -> uvw @@ -456,8 +515,12 @@ def test_uvw2UVTW(self): assert np.allclose(m1.unit.data, m2.unit.data) # MTEX convention - assert np.allclose(_uvw2UVTW(uvw, convention="mtex") / 3, _uvw2UVTW(uvw)) - assert np.allclose(_UVTW2uvw(UVTW, convention="mtex") * 3, _UVTW2uvw(UVTW)) + assert np.allclose( + _uvw2UVTW(uvw, convention="mtex") / 3, _uvw2UVTW(uvw) + ) + assert np.allclose( + _UVTW2uvw(UVTW, convention="mtex") * 3, _UVTW2uvw(UVTW) + ) def test_mtex_convention(self): # Same result without convention="mtex" because of rounding... @@ -482,7 +545,8 @@ def test_trigonal_crystal(self): nround = n.round() assert np.allclose(nround.UVTW, [3, 3, -6, 11]) assert np.allclose( - [nround.U[0], nround.V[0], nround.T[0], nround.W[0]], [3, 3, -6, 11] + [nround.U[0], nround.V[0], nround.T[0], nround.W[0]], + [3, 3, -6, 11], ) # Examples from MTEX' documentation: @@ -515,12 +579,18 @@ def test_trigonal_crystal(self): m7 = Miller(hkil=[-1, -1, 2, 0], phase=TRIGONAL_PHASE) assert np.allclose(m6.angle_with(m7, degrees=True)[0], 180) - assert np.allclose(m6.angle_with(m7, use_symmetry=True, degrees=True)[0], 60) + assert np.allclose( + m6.angle_with(m7, use_symmetry=True, degrees=True)[0], 60 + ) def test_convention_not_met(self): - with pytest.raises(ValueError, match="The Miller-Bravais indices convention"): + with pytest.raises( + ValueError, match="The Miller-Bravais indices convention" + ): _ = Miller(hkil=[1, 1, -1, 0], phase=TETRAGONAL_PHASE) - with pytest.raises(ValueError, match="The Miller-Bravais indices convention"): + with pytest.raises( + ValueError, match="The Miller-Bravais indices convention" + ): _ = Miller(UVTW=[1, 1, -1, 0], phase=TETRAGONAL_PHASE) @@ -532,7 +602,9 @@ def test_tetragonal_crystal(self): lattice = TETRAGONAL_LATTICE # Example 1.1: Direct metric tensor - assert np.allclose(lattice.metrics, [[0.25, 0, 0], [0, 0.25, 0], [0, 0, 1]]) + assert np.allclose( + lattice.metrics, [[0.25, 0, 0], [0, 0.25, 0], [0, 0, 1]] + ) # Example 1.2: Distance between two points (length of a vector) answer = np.sqrt(5) / 4 # nm @@ -546,11 +618,15 @@ def test_tetragonal_crystal(self): m2 = Miller(uvw=[1, 2, 0], phase=TETRAGONAL_PHASE) m3 = Miller(uvw=[3, 1, 1], phase=TETRAGONAL_PHASE) assert np.allclose(m2.dot(m3), 5 / 4) # nm^2 - assert np.allclose(m2.angle_with(m3, degrees=True)[0], 53.30, atol=0.01) + assert np.allclose( + m2.angle_with(m3, degrees=True)[0], 53.30, atol=0.01 + ) # Example 1.5: Reciprocal metric tensor lattice_recip = lattice.reciprocal() - assert np.allclose(lattice_recip.metrics, [[4, 0, 0], [0, 4, 0], [0, 0, 1]]) + assert np.allclose( + lattice_recip.metrics, [[4, 0, 0], [0, 4, 0], [0, 0, 1]] + ) # Example 1.6, 1.7: Angle between two plane normals m4 = Miller(hkl=[1, 2, 0], phase=TETRAGONAL_PHASE) @@ -833,18 +909,18 @@ def test_group_mm2(self): m.symmetrise(unique=False).hkl, [ [ 0, 0, 1], - [ 0, 0, -1], - [ 0, 0, -1], + [ 0, 0, 1], + [ 0, 0, 1], [ 0, 0, 1], [ 0, 1, 1], - [ 0, -1, -1], - [ 0, 1, -1], + [ 0, -1, 1], + [ 0, 1, 1], [ 0, -1, 1], [ 1, 1, 1], - [ 1, -1, -1], - [ 1, 1, -1], + [-1, -1, 1], + [-1, 1, 1], [ 1, -1, 1], ], ) @@ -853,23 +929,20 @@ def test_group_mm2(self): m_unique.hkl, [ [ 0, 0, 1], - [ 0, 0, -1], [ 0, 1, 1], - [ 0, -1, -1], - [ 0, 1, -1], [ 0, -1, 1], [ 1, 1, 1], - [ 1, -1, -1], - [ 1, 1, -1], + [-1, -1, 1], + [-1, 1, 1], [ 1, -1, 1], ], ) # fmt: on mult = m.multiplicity - assert np.allclose(mult, [2, 4, 4]) + assert np.allclose(mult, [1, 2, 4]) assert np.sum(mult) == m_unique.size def test_group_2overm_2overm_2overm(self): From 5c6a8f7f927dcfa48e71723c7cec53410b2e1dc3 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:52:26 -0600 Subject: [PATCH 18/40] matching symmetry lists and names to ITOC --- orix/crystal_map/phase_list.py | 5 +- orix/quaternion/symmetry.py | 556 ++++++++++++++++++---- orix/tests/quaternion/test_orientation.py | 40 +- orix/tests/quaternion/test_symmetry.py | 101 ++-- orix/tests/test_crystal_map.py | 109 ++--- orix/tests/test_miller.py | 137 ++---- 6 files changed, 563 insertions(+), 385 deletions(-) diff --git a/orix/crystal_map/phase_list.py b/orix/crystal_map/phase_list.py index ce7e3b91e..71f13d724 100644 --- a/orix/crystal_map/phase_list.py +++ b/orix/crystal_map/phase_list.py @@ -36,8 +36,8 @@ from orix.quaternion.symmetry import ( _EDAX_POINT_GROUP_ALIASES, Symmetry, - _groups, get_point_group, + get_point_groups, ) from orix.vector.miller import Miller from orix.vector.vector3d import Vector3d @@ -242,6 +242,7 @@ def point_group(self) -> Symmetry | None: @point_group.setter def point_group(self, value: int | str | Symmetry | None) -> None: """Set the point group.""" + groups = get_point_groups("all_repeated") if isinstance(value, int): value = str(value) if isinstance(value, str): @@ -249,7 +250,7 @@ def point_group(self, value: int | str | Symmetry | None) -> None: if value in aliases: value = key break - for point_group in _groups: + for point_group in groups: if value == point_group.name: value = point_group break diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index d99765081..8448c145a 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -75,7 +75,8 @@ def is_proper(self) -> bool: @property def subgroups(self) -> list[Symmetry]: """Return the list groups that are subgroups of this group.""" - return [g for g in _groups if g._tuples <= self._tuples] + groups = get_point_groups("all") + return [g for g in groups if g._tuples <= self._tuples] @property def proper_subgroups(self) -> list[Symmetry]: @@ -139,10 +140,12 @@ def euler_fundamental_region(self) -> tuple: "211": (360, 90, 360), # Monoclinic "121": (360, 90, 360), "112": (360, 180, 180), + "2": (360, 180, 180), "222": (360, 90, 180), # Orthorhombic "4": (360, 180, 90), # Tetragonal "422": (360, 90, 90), "3": (360, 180, 120), # Trigonal + "321": (360, 90, 120), "312": (360, 90, 120), "32": (360, 90, 120), "6": (360, 180, 60), # Hexagonal @@ -168,20 +171,11 @@ def system(self) -> str | None: system ``None`` is returned if the symmetry name is not recognized. """ + # fmt: off name = self.name if name in ["1", "-1"]: return "triclinic" - elif name in [ - "211", - "121", - "112", - "2", - "m11", - "1m1", - "11m", - "m", - "2/m", - ]: + elif name in ["211", "121", "112", "2", "m11", "1m1", "11m", "m", "2/m"]: return "monoclinic" elif name in ["222", "mm2", "mmm"]: return "orthorhombic" @@ -195,6 +189,7 @@ def system(self) -> str | None: return "cubic" else: return None + # fmt: on @property def _tuples(self) -> set: @@ -262,9 +257,7 @@ def fundamental_sector(self) -> "FundamentalSector": # Taken from MTEX center = Vector3d([0.707558, -0.000403, 0.706655]) elif name in ["m-3", "432"]: - n = Vector3d( - np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data]) - ) + n = Vector3d(np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data])) # Taken from MTEX center = Vector3d([0.349928, 0.348069, 0.869711]) elif name == "-43m": @@ -295,9 +288,9 @@ def _primary_axis_order(self) -> int | None: name = self.proper_subgroup.name if name in ["1", "211", "121"]: return 1 - elif name in ["112", "222", "23"]: + elif name in ["112", "222", "23", "2"]: return 2 - elif name in ["3", "312", "32"]: + elif name in ["3", "312", "321", "32"]: return 3 elif name in ["4", "422", "432"]: return 4 @@ -338,14 +331,14 @@ def symmetry_axis(v: Vector3d, n: int) -> Rotation: if name in ["1", "211", "121"]: # All proper operations rot = self[~self.improper] - elif name in ["112", "3", "4", "6"]: + elif name in ["2", "112", "3", "4", "6"]: # Identity rot = self[0] - elif name in ["222", "422", "622", "32"]: + elif name in ["222", "422", "622", "32", "321"]: # Two-fold rotation about a-axis perpendicular to c-axis rot = symmetry_axis(-vx, 2) elif name == "312": - # Mirror plane perpendicular to c-axis? + # Mirror plane perpendicular to c-axis rot = symmetry_axis(-mirror, 2) elif name in ["23", "432"]: # Three-fold rotation about [111] @@ -372,9 +365,7 @@ def __and__(self, other: Symmetry) -> Symmetry: return Symmetry.from_generators(*generators) def __hash__(self) -> int: - return hash( - self.name.encode() + self.data.tobytes() + self.improper.tobytes() - ) + return hash(self.name.encode() + self.data.tobytes() + self.improper.tobytes()) # ------------------------ Class methods ------------------------- # @@ -432,9 +423,7 @@ def get_axis_orders(self) -> dict[Vector3d, int]: return {} return { Vector3d(a): b + 1 - for a, b in zip( - *np.unique(s.axis.data, axis=0, return_counts=True) - ) + for a, b in zip(*np.unique(s.axis.data, axis=0, return_counts=True)) } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: @@ -551,6 +540,33 @@ def plot( return figure +# ---------------- Proceedural definitions of Point Groups ---------------- # +# NOTE: ORIX uses Schoenflies symbols to define point groups. This is partly +# because the notation is short and always starts with a letter (ie, they +# make convenient python variables), and partly because it helps limit +# accidental misinterpretation of of Herman-Mauguin symbols as space group +# numbers. For example. "222" could be interpreted as SG#222 == Pn-3n, or +# as PG'222'== D3. there are similar examples with 2, 3, 4, 32, etc. + +# Additionally, there are 43 crystallographically valid Schonflies group +# notations, but only 32 unique ones, meaning certain point groups have +# redundant representations in Schonflies notation(S4==C4i, Ci==S2, S6==C3i, +# and C2==D1, for example). The International Tables of Crystallography, +# Volume A, Section 12.1 defines the 32 standard representations, but a few of +# the commonly used redundant ones are given below for convenience. + +# Finally, while there are 32 Point groups, ITOC names several additional +# projections for the non-centrosymmetric groups (ie, using x and/or y as the +# rotation axis instead of z). These are included below as well, following +# the ITOC naming convention (for example, a 2-fold cyclic rotation around +# the x axis instead of the z axis is called C2x.) + +# For more details on how point groups can be generated, the following three +# resources lay out three different but equally valid approaches: +# 1)"Structure of Materials", De Graef et al, Section 9.2 +# 2)"International Tables of Crystallography: Volume A" Section 12.1 +# 3)"Crystallogrpahic Texture and Group Representations", Chi-Sing Man,Ch2 + # Triclinic C1 = Symmetry((1, 0, 0, 0)) C1.name = "1" @@ -653,15 +669,9 @@ def plot( D4h.name = "4/mmm" # 3-fold rotations -C3x = Symmetry( - [(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)] -) -C3y = Symmetry( - [(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)] -) -C3z = Symmetry( - [(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))] -) +C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)]) +C3y = Symmetry([(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)]) +C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))]) C3 = Symmetry(C3z) C3.name = "3" @@ -710,68 +720,374 @@ def plot( Oh = Symmetry.from_generators(O, Ci) Oh.name = "m-3m" -# Collections of groups for convenience -# fmt: off -_groups = [ - # Schoenflies Crystal system International Laue class Proper point group - C1, # Triclinic 1 -1 1 - Ci, # Triclinic -1 -1 1 - C2x, # Monoclinic 211 2/m 211 - C2y, # Monoclinic 121 2/m 121 - C2z, # Monoclinic 112 2/m 112 - Csx, # Monoclinic m11 2/m 1 - Csy, # Monoclinic 1m1 2/m 1 - Csz, # Monoclinic 11m 2/m 1 - C2h, # Monoclinic 2/m 2/m 112 - D2, # Orthorhombic 222 mmm 222 - C2v, # Orthorhombic mm2 mmm 211 - D2h, # Orthorhombic mmm mmm 222 - C4, # Tetragonal 4 4/m 4 - S4, # Tetragonal -4 4/m 112 - C4h, # Tetragonal 4/m 4/m 4 - D4, # Tetragonal 422 4/mmm 422 - C4v, # Tetragonal 4mm 4/mmm 4 - D2d, # Tetragonal -42m 4/mmm 222 - D4h, # Tetragonal 4/mmm 4/mmm 422 - C3, # Trigonal 3 -3 3 - S6, # Trigonal -3 -3 3 - D3x, # Trigonal 321 -3m 32 - D3y, # Trigonal 312 -3m 312 - D3, # Trigonal 32 -3m 32 - C3v, # Trigonal 3m -3m 3 - D3d, # Trigonal -3m -3m 32 - C6, # Hexagonal 6 6/m 6 - C3h, # Hexagonal -6 6/m 6 - C6h, # Hexagonal 6/m 6/m 622 - D6, # Hexagonal 622 6/mmm 622 - C6v, # Hexagonal 6mm 6/mmm 6 - D3h, # Hexagonal -6m2 6/mmm 312 - D6h, # Hexagonal 6/mmm 6/mmm 622 - T, # Cubic 23 m-3 23 - Th, # Cubic m-3 m-3 23 - O, # Cubic 432 m-3m 432 - Td, # Cubic -43m m-3m 23 - Oh, # Cubic m-3m m-3m 432 -] -# fmt: on -_proper_groups = [ - C1, - C2, - C2x, - C2y, - C2z, - D2, - C4, - D4, - C3, - D3x, - D3y, - D3, - C6, - D6, - T, - O, -] + +def get_point_groups(subset: str = "unique"): + """ + returns different subsets of the 32 crystallographic point groups. By + default, this returns all 32 in the order they appear in the + International Tables of Crystallography (ITOC). + + Parameters + ---------- + subset : str, optional + the point group list to return. The options are as follows: + "unique" (default): + All 32 point groups in the order they appear in space groups. + Thus, they are grouped by crystal system and laue class + "all": + All 32 points groups, plus common axis-specific permutations + for non-centrosymmetric groups (ie, C2 plus C2x and C2y) for + a total of 37 point group projections. These + are given in the same order as ITOC Table 10.2.2 + "all_repeated": + The 37 point group projections, plus the redundant Schonflies + and Herman-Mauguin group names. For example, both Ci and S2 + are included, as well as D3 =="32" and D3x == "321". NOTE: + this means several of the entries symmetrically identical. + "proper": + The 11 proper point groups given in the same order as ITOC + table 10.2.2. + same order as "unique", which in turn aligns with Table 3.1 + of ITOC + "proper_all": + The 11 proper point groups, plus axis-specific permutations. + "laue": + The point groups corresponding to the 11 Laue groups, using + the same ordering and definitions as Table 3.1 of ITOC. These + are equivalent to adding an inversion symmetry to each op + the 11 proper point groups + "procedural": + The 32 point groups, but presented in the procedural ordering + described in "Structure of Materials" and other books, where + point groups are created from successive applications of + symmetry elements to the Cyclic (C_n) and Dihedral (D_n) + groups. + + Returns + ------- + point groups: [Symmetry,] + a list of point group symmetries + + Notes + ------- + For convenience, the following table shows the 38 point groups contained + in "all", of which the other lists are subsets of. + + +-------------+--------+---------------------+------------+------------+ + | Schoenflies | System | ITOC/Herman_Mauguin | Laue class | Proper PG | + +-------------+--------+---------------------+------------+------------+ + | C1 | Triclinic | 1 | -1 | 1 | + +-------------+--------+---------------------+------------+------------+ + | Ci | Triclinic | -1 | -1 | 1 | + +-------------+--------+---------------------+------------+------------+ + | C2 | Monoclinic | 2 or 112 | 2/m | 112 | + +-------------+--------+---------------------+------------+------------+ + | C2x | Monoclinic | 211 | 2/m | 211 | + +-------------+--------+---------------------+------------+------------+ + | C2y | Monoclinic | 121 | 2/m | 121 | + +-------------+--------+---------------------+------------+------------+ + | Cs | Monoclinic | 11m or m | 2/m | 1 | + +-------------+--------+---------------------+------------+------------+ + | Csx | Monoclinic | m11 | 2/m | 1 | + +-------------+--------+---------------------+------------+------------+ + | Csy | Monoclinic | 1m1 | 2/m | 1 | + +-------------+--------+---------------------+------------+------------+ + | C2h | Monoclinic | 2/m | 2/m | 112 | + +-------------+--------+---------------------+------------+------------+ + | D2 | Orthorhombic | 222 | mmm | 222 | + +-------------+--------+---------------------+------------+------------+ + | C2v | Orthorhombic | mm2 | mmm | 211 | + +-------------+--------+---------------------+------------+------------+ + | D2h | Orthorhombic | mmm | mmm | 222 | + +-------------+--------+---------------------+------------+------------+ + | C4 | Tetragonal | 4 | 4/m | 4 | + +-------------+--------+---------------------+------------+------------+ + | S4 | Tetragonal | -4 | 4/m | 112 | + +-------------+--------+---------------------+------------+------------+ + | C4h | Tetragonal | 4/m | 4/m | 4 | + +-------------+--------+---------------------+------------+------------+ + | D4 | Tetragonal | 422 | 4/mmm | 422 | + +-------------+--------+---------------------+------------+------------+ + | C4v | Tetragonal | 4mm | 4/mmm | 4 | + +-------------+--------+---------------------+------------+------------+ + | D2d | Tetragonal | -42m | 4/mmm | 222 | + +-------------+--------+---------------------+------------+------------+ + | D4h | Tetragonal | 4/mmm | 4/mmm | 422 | + +-------------+--------+---------------------+------------+------------+ + | C3 | Trigonal | 3 | -3 | 3 | + +-------------+--------+---------------------+------------+------------+ + | C3i | Trigonal | -3 | -3 | 3 | + +-------------+--------+---------------------+------------+------------+ + | D3 | Trigonal | 32 or 321 | -3m | 32 | + +-------------+--------+---------------------+------------+------------+ + | D3y | Trigonal | 312 | -3m | 312 | + +-------------+--------+---------------------+------------+------------+ + | C3v | Trigonal | 3m | -3m | 3 | + +-------------+--------+---------------------+------------+------------+ + | D3d | Trigonal | -3m | -3m | 32 | + +-------------+--------+---------------------+------------+------------+ + | C6 | Hexagonal | 6 | 6/m | 6 | + +-------------+--------+---------------------+------------+------------+ + | C3h | Hexagonal | -6 | 6/m | 6 | + +-------------+--------+---------------------+------------+------------+ + | C6h | Hexagonal | 6/m | 6/m | 622 | + +-------------+--------+---------------------+------------+------------+ + | D6 | Hexagonal | 622 | 6/mmm | 622 | + +-------------+--------+---------------------+------------+------------+ + | C6v | Hexagonal | 6mm | 6/mmm | 6 | + +-------------+--------+---------------------+------------+------------+ + | D3h | Hexagonal | -6m2 | 6/mmm | 312 | + +-------------+--------+---------------------+------------+------------+ + | D6h | Hexagonal | 6/mmm | 6/mmm | 622 | + +-------------+--------+---------------------+------------+------------+ + | T | Cubic | 23 | m-3 | 23 | + +-------------+--------+---------------------+------------+------------+ + | Th | Cubic | m-3 | m-3 | 23 | + +-------------+--------+---------------------+------------+------------+ + | O | Cubic | 432 | m-3m | 432 | + +-------------+--------+---------------------+------------+------------+ + | Td | Cubic | -43m | m-3m | 23 | + +-------------+--------+---------------------+------------+------------+ + | Oh | Cubic | m-3m | m-3m | 432 | + +-------------+--------+---------------------+------------+------------+ + + """ + subset = str(subset).lower() + if subset == "all_repeated": + return [ + # Triclinic + C1, + Ci, + S2, # redundant + # Monoclinic + C2, + D1, # redundant + C2x, + C2y, + C2z, # redundant + Cs, + Csx, + Csy, + Csz, # redundant + C2h, + # Orthorhombic + D2, + C2v, + D2h, + # Tetragonal + C4, + S4, + C4i, # redundant + C4h, + D4, + C4v, + D2d, + D4h, + # Trigonal + C3, + C3i, + S6, # redundant + D3, + D3x, + D3y, + C3v, + D3d, + # Hexagonal + C6, + C3h, + C6h, + D6, + C6v, + D3h, + D6h, + # cubic + T, + Th, + O, + Td, + Oh, + ] + elif subset == "all": + return [ + # Triclinic + C1, + Ci, + # Monoclinic + C2, + C2x, + C2y, + Cs, + Csx, + Csy, + C2h, + # Orthorhombic + D2, + C2v, + D2h, + # Tetragonal + C4, + S4, + C4h, + D4, + C4v, + D2d, + D4h, + # Trigonal + C3, + C3i, + D3, + D3y, + C3v, + D3d, + # Hexagonal + C6, + C3h, + C6h, + D6, + C6v, + D3h, + D6h, + # cubic + T, + Th, + O, + Td, + Oh, + ] + elif subset == "unique": + return [ + # Triclinic + C1, + Ci, + # Monoclinic + C2, + Cs, + C2h, + # Orthorhombic + D2, + C2v, + D2h, + # Tetragonal + C4, + S4, + C4h, + D4, + C4v, + D2d, + D4h, + # Trigonal + C3, + C3i, + D3, + C3v, + D3d, + # Hexagonal + C6, + C3h, + C6h, + D6, + C6v, + D3h, + D6h, + # cubic + T, + Th, + O, + Td, + Oh, + ] + elif subset == "proper": + return [ + # Triclinic + C1, + # Monoclinic + C2, + # Orthorhombic + D2, + # Tetragonal + C4, + # Trigonal + C3, + D3, + # Hexagonal + C6, + D6, + # cubic + T, + O, + ] + elif subset == "laue": + return [x * Ci for x in get_point_groups("proper")] + elif subset == "proper_all": + return [ + # Triclinic + C1, + # Monoclinic + C2, + C2x, + C2y, + # Orthorhombic + D2, + # Tetragonal + C4, + # Trigonal + C3, + D3, + D3x, + D3y, + # Hexagonal + C6, + D6, + # cubic + T, + O, + ] + elif subset == "SoM": + return [ + # Cyclic + C1, + C2, + C3, + C4, + C6, + # Dihedral + D2, + D3, + D4, + D6, + # Cyclic plus inversion (\ba{n}) + Ci, + Cs, + C3i, + S4, + C3h, + # Cyclic plus perpendicular mirrors (n/m) + C2h, + C4h, + C6h, + # Cyclic plus vertical mirrors (nm) + C2v, + C3v, + C4v, + C6v, + # Dihedral plus diagonal mirrors (\bar{n} m) + D3d, + D2d, + D3h, + # Dihedral with vertical and perpendicular mirros (n/m m) + D2h, + D4h, + D6h, + # Combining cyclic (n1 n2) + T, + O, + # combining cyclic and mirrors + Th, + Td, + Oh, + ] + else: + ValueError("{} is not a valid subset option".format(subset)) def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: @@ -837,16 +1153,40 @@ def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: } +@deprecated( + since="0.14", + removal="0.15", + alternative="symmetry.get_point_group_from_space_group", +) def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: - """Map a space group number to its (proper) point group. + r""" + This function has been renamed to + `orix.quaternion.symmetry.get_point_group_from_space_group`. + for clairity""" + return get_point_group_from_space_group(space_group_number, proper) + + +def get_point_group_from_space_group( + space_group_number: Union(int, str), proper: bool = False +) -> Symmetry: + """ + Maps a space group number or name to a crystallographic point group. Parameters ---------- - space_group_number - Between 1 and 231. - proper + space_group_number: int between 1-231, or str + If is an int(n) or str(int(n)) where n is between 1 and 231, it will + return the point group of the nth space group, as defined by the + International Tables of Crystallogrphy. Otherwise, it will be passed + to diffpy's dictionary of space group names for interpretation. + + Thus, 222 and '222' will both return symmetry.Oh (ie "432", the point + group of SG#222==Pn-3n), but 'P222' will return symmetry.D2 + (ie "222", the proper point group of SG#16=='P222'). + + proper: bool Whether to return the point group with proper rotations only - (``True``), or just the point group (``False``). Default is + (``True``), or the full point group (``False``). Default is ``False``. Returns @@ -854,6 +1194,18 @@ def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: point_group One of the 11 proper or 32 point groups. + Notes: + ---------- + This function uses diffpy.structure.spacegroups to convert names to + space group IDs, and has some allowances for spelling and spacing + differences. Thus, variations like "Pm-3m", 221, "PM3m", and "Pn3n" all + map to symmetry.Oh == 'm-3m'. To see a full list of all name options + avaiable, use the following snippet: + + >>> import diffpy.structure.spacegroups as sg + >>> sg._buildSGLookupTable() + >>> sg._sg_lookup_table.keys() + Examples -------- >>> from orix.quaternion.symmetry import get_point_group diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index a8e87ee9b..a0e4df286 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -37,13 +37,15 @@ T, O, Oh, - _groups, - _proper_groups, + get_point_groups, ) -from orix.vector import Miller, Vector3d +from orix.vector import Miller, Vector3d # isort: on # fmt: on +groups = get_point_groups("all") +proper_groups = get_point_groups("proper") + @pytest.fixture def vector(request): @@ -310,9 +312,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 @@ -366,7 +366,7 @@ def test_get_distance_matrix_progressbar_chunksize(self): angle2 = m.get_distance_matrix(chunk_size=10, progressbar=False) assert np.allclose(angle1, angle2) - @pytest.mark.parametrize("symmetry", _groups[:-1]) + @pytest.mark.parametrize("symmetry", groups[:-1]) def test_get_distance_matrix_equal_explicit_calculation(self, symmetry): # do not test Oh, as this takes ~4 GB m = Misorientation.random((5,)) @@ -600,9 +600,7 @@ def test_from_scipy_rotation(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) @@ -629,15 +627,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) @@ -684,9 +678,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) @@ -721,9 +713,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 @@ -813,12 +803,10 @@ def test_in_fundamental_region(self): (-0.3874, 0.6708, -0.1986, 0.6004), ) ) - for pg in _proper_groups: + 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_symmetry.py b/orix/tests/quaternion/test_symmetry.py index a92ccdc4e..923890a06 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -34,12 +34,16 @@ C3, S6, D3x, D3y, D3, C3v, D3d, # trigonal C6, C3h, C6h, D6, C6v, D3h, D6h, # hexagonal T, Th, O, Td, Oh, # cubic - spacegroup2pointgroup_dict, _groups, _get_unique_symmetry_elements + spacegroup2pointgroup_dict, + get_point_groups, + _get_unique_symmetry_elements ) # isort: on # fmt: on from orix.vector import Vector3d +_groups = get_point_groups("all") + @pytest.fixture(params=[(1, 2, 3)]) def vector(request): @@ -276,8 +280,8 @@ def test_is_proper(symmetry, expected): "symmetry, expected", [ (C1, [C1]), - (D2, [C1, C2x, C2y, C2z, D2]), - (C6v, [C1, C2z, Csx, Csy, C2v, C3, C3v, C6, C6v]), + (D2, [C1, C2, C2x, C2y, D2]), + (C6v, [C1, C2, Csx, Csy, C2v, C3, C3v, C6, C6v]), ], ) def test_subgroups(symmetry, expected): @@ -288,8 +292,8 @@ def test_subgroups(symmetry, expected): "symmetry, expected", [ (C1, [C1]), - (D2, [C1, C2x, C2y, C2z, D2]), - (C6v, [C1, C2z, C3, C6]), + (D2, [C1, C2x, C2y, C2, D2]), + (C6v, [C1, C2, C3, C6]), ], ) def test_proper_subgroups(symmetry, expected): @@ -444,13 +448,8 @@ def test_get_point_group(): sg = GetSpaceGroup(sg_number) pg = get_point_group(sg_number, proper=False) - assert ( - proper_pg - == spacegroup2pointgroup_dict[sg.point_group_name]["proper"] - ) - assert ( - pg == spacegroup2pointgroup_dict[sg.point_group_name]["improper"] - ) + assert proper_pg == spacegroup2pointgroup_dict[sg.point_group_name]["proper"] + assert pg == spacegroup2pointgroup_dict[sg.point_group_name]["improper"] def test_unique_symmetry_elements_subgroups(all_symmetries): @@ -542,9 +541,7 @@ def test_symmetry_plot(pg): @pytest.mark.parametrize("symmetry", [C1, C4, Oh]) def test_symmetry_plot_raises(symmetry): - with pytest.raises( - TypeError, match="Orientation must be a Rotation instance" - ): + with pytest.raises(TypeError, match="Orientation must be a Rotation instance"): _ = symmetry.plot(return_figure=True, orientation="test") @@ -644,9 +641,7 @@ def test_fundamental_sector_d4(self): def test_fundamental_sector_c4v(self): pg = C4v # 4mm fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 1, 0], [0.7071, -0.7071, 0]], atol=1e-4 - ) + assert np.allclose(fs.data, [[0, 1, 0], [0.7071, -0.7071, 0]], atol=1e-4) assert np.allclose(fs.vertices.data, [[0, 0, 1], [0, 0, -1]]) assert np.allclose(fs.center.data, [[0.3536, 0.1464, 0]], atol=1e-4) @@ -687,9 +682,7 @@ def test_fundamental_sector_c3(self): def test_fundamental_sector_s6(self): pg = S6 # -3 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], @@ -700,9 +693,7 @@ def test_fundamental_sector_s6(self): def test_fundamental_sector_d3(self): pg = D3 # 32 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], @@ -713,9 +704,7 @@ def test_fundamental_sector_d3(self): def test_fundamental_sector_c3v(self): pg = C3v # 3m fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0.5, 0.866, 0], [0.5, -0.866, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0.5, 0.866, 0], [0.5, -0.866, 0]], atol=1e-3) assert np.allclose(fs.vertices.data, [[0, 0, 1], [0, 0, -1]]) assert np.allclose(fs.center.data, [[0.5, 0, 0]]) @@ -742,9 +731,7 @@ def test_fundamental_sector_c6(self): def test_fundamental_sector_c3h(self): pg = C3h # -6 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, 0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [-0.5, 0.866, 0]], @@ -755,9 +742,7 @@ def test_fundamental_sector_c3h(self): def test_fundamental_sector_c6h(self): pg = C6h # 6/m fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], @@ -768,9 +753,7 @@ def test_fundamental_sector_c6h(self): def test_fundamental_sector_d6(self): pg = D6 # 622 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], @@ -788,9 +771,7 @@ def test_fundamental_sector_c6v(self): def test_fundamental_sector_d3h(self): pg = D3h # -6m2 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.866, -0.5, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.5, 0.866, 0]], @@ -801,9 +782,7 @@ def test_fundamental_sector_d3h(self): def test_fundamental_sector_d6h(self): pg = D6h # 6/mmm fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[0, 0, 1], [0, 1, 0], [0.5, -0.866, 0]], atol=1e-3 - ) + assert np.allclose(fs.data, [[0, 0, 1], [0, 1, 0], [0.5, -0.866, 0]], atol=1e-3) assert np.allclose( fs.vertices.data, [[1, 0, 0], [0, 0, 1], [0.866, 0.5, 0]], @@ -814,9 +793,7 @@ def test_fundamental_sector_d6h(self): def test_fundamental_sector_t(self): pg = T # 23 fs = pg.fundamental_sector - assert np.allclose( - fs.data, [[1, 1, 0], [1, -1, 0], [0, -1, 1], [0, 1, 1]] - ) + assert np.allclose(fs.data, [[1, 1, 0], [1, -1, 0], [0, -1, 1], [0, 1, 1]]) assert np.allclose( fs.vertices.data, [ @@ -827,9 +804,7 @@ def test_fundamental_sector_t(self): ], atol=1e-4, ) - assert np.allclose( - fs.center.data, [[0.7076, -0.0004, 0.7067]], atol=1e-4 - ) + assert np.allclose(fs.center.data, [[0.7076, -0.0004, 0.7067]], atol=1e-4) def test_fundamental_sector_th(self): pg = Th # m-3 @@ -848,9 +823,7 @@ def test_fundamental_sector_th(self): ], atol=1e-3, ) - assert np.allclose( - fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4 - ) + assert np.allclose(fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4) def test_fundamental_sector_o(self): pg = O # 432 @@ -868,9 +841,7 @@ def test_fundamental_sector_o(self): ], atol=1e-3, ) - assert np.allclose( - fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4 - ) + assert np.allclose(fs.center.data, [[0.3499, 0.3481, 0.8697]], atol=1e-4) def test_fundamental_sector_td(self): pg = Td # -43m @@ -892,9 +863,7 @@ def test_fundamental_sector_oh(self): [[0.5774, 0.5774, 0.5774], [0.7071, 0, 0.7071], [0, 0, 1]], atol=1e-4, ) - assert np.allclose( - fs.center.data, [[0.4282, 0.1925, 0.7615]], atol=1e-4 - ) + assert np.allclose(fs.center.data, [[0.4282, 0.1925, 0.7615]], atol=1e-4) # ---------- End of the 32 crystallographic point groups --------- # @@ -980,7 +949,7 @@ def test_euler_fundamental_region(self): # All point groups provide a region for pg in _groups: angles = pg.euler_fundamental_region - if pg.name in ["1", "-1", "2", "m11", "1m1", "11m"]: + if pg.name in ["1", "-1", "m11", "1m1", "11m", "m"]: assert np.allclose(angles, (360, 180, 360)) else: assert not np.allclose(angles, (360, 180, 360)) @@ -1007,16 +976,10 @@ def test_primary_axis_order(self): def test_special_rotation(self): for pg in [C1, C2z, C3, C4, C6]: assert np.allclose(pg._special_rotation.data, (1, 0, 0, 0)) - assert np.allclose( - C2x._special_rotation.data, ((1, 0, 0, 0), (0, 1, 0, 0)) - ) - assert np.allclose( - C2y._special_rotation.data, ((1, 0, 0, 0), (0, 0, 1, 0)) - ) + assert np.allclose(C2x._special_rotation.data, ((1, 0, 0, 0), (0, 1, 0, 0))) + assert np.allclose(C2y._special_rotation.data, ((1, 0, 0, 0), (0, 0, 1, 0))) for pg in [D2, D4, D6, D3]: - assert np.allclose( - pg._special_rotation.data, ((1, 0, 0, 0), (0, -1, 0, 0)) - ) + assert np.allclose(pg._special_rotation.data, ((1, 0, 0, 0), (0, -1, 0, 0))) assert np.allclose( D3y._special_rotation.data, ((1, 0, 0, 0), (0, -1 / np.sqrt(2), 1 / np.sqrt(2), 0)), @@ -1045,9 +1008,7 @@ def test_special_rotation(self): ) unrecognized_symmetry = Symmetry.random(4) - assert np.allclose( - unrecognized_symmetry._special_rotation.data, (1, 0, 0, 0) - ) + assert np.allclose(unrecognized_symmetry._special_rotation.data, (1, 0, 0, 0)) # All point groups provide at least one rotation for pg in _groups: diff --git a/orix/tests/test_crystal_map.py b/orix/tests/test_crystal_map.py index c7d773048..9d4ad2e4a 100644 --- a/orix/tests/test_crystal_map.py +++ b/orix/tests/test_crystal_map.py @@ -174,9 +174,7 @@ def test_init_with_phase_list(self, crystal_map_input, expected_presence): ): assert (p1 == p2) == expected - unique_phase_ids = list( - np.unique(crystal_map_input["phase_id"]).astype(int) - ) + unique_phase_ids = list(np.unique(crystal_map_input["phase_id"]).astype(int)) assert xmap.phases.ids == unique_phase_ids def test_init_with_sparse_phase_list(self): @@ -310,29 +308,21 @@ def test_is_in_data(self, crystal_map_input): # All points in data xmap = CrystalMap(is_in_data=is_in_data.flatten(), **crystal_map_input) assert xmap.shape == xmap._original_shape == map_shape - assert np.allclose( - xmap.get_map_data("phase_id"), np.zeros(is_in_data.shape) - ) + assert np.allclose(xmap.get_map_data("phase_id"), np.zeros(is_in_data.shape)) # First row not in data is_in_data1 = is_in_data.copy() is_in_data1[0, :] = False - xmap1 = CrystalMap( - is_in_data=is_in_data1.flatten(), **crystal_map_input - ) + xmap1 = CrystalMap(is_in_data=is_in_data1.flatten(), **crystal_map_input) new_shape1 = (map_shape[0] - 1, map_shape[1]) assert xmap1.shape == new_shape1 assert xmap1._original_shape == map_shape - assert np.allclose( - xmap1.get_map_data("phase_id"), np.zeros(new_shape1) - ) + assert np.allclose(xmap1.get_map_data("phase_id"), np.zeros(new_shape1)) # Second row not in data is_in_data2 = is_in_data.copy() is_in_data2[1, :] = False - xmap2 = CrystalMap( - is_in_data=is_in_data2.flatten(), **crystal_map_input - ) + xmap2 = CrystalMap(is_in_data=is_in_data2.flatten(), **crystal_map_input) assert xmap2.shape == xmap2._original_shape == map_shape desired_phase_id = np.zeros(is_in_data.shape) desired_phase_id[1, :] = np.nan @@ -343,15 +333,11 @@ def test_is_in_data(self, crystal_map_input): # Last column not in data is_in_data3 = is_in_data.copy() is_in_data3[:, -1] = False - xmap3 = CrystalMap( - is_in_data=is_in_data3.flatten(), **crystal_map_input - ) + xmap3 = CrystalMap(is_in_data=is_in_data3.flatten(), **crystal_map_input) new_shape3 = (map_shape[0], map_shape[1] - 1) assert xmap3.shape == new_shape3 assert xmap3._original_shape == map_shape - assert np.allclose( - xmap3.get_map_data("phase_id"), np.zeros(new_shape3) - ) + assert np.allclose(xmap3.get_map_data("phase_id"), np.zeros(new_shape3)) def test_set_phases(self, phase_list): assert phase_list.size == 3 @@ -363,9 +349,7 @@ def test_set_phases(self, phase_list): # Not OK xmap._phase_id = np.array([0, 1, 2, 1, 2, 3]) - with pytest.raises( - ValueError, match="There must be at least as many phases " - ): + with pytest.raises(ValueError, match="There must be at least as many phases "): xmap.phases = phase_list @@ -397,9 +381,7 @@ class TestCrystalMapGetItem: ], indirect=["crystal_map_input"], ) - def test_get_by_slice( - self, crystal_map_input, slice_tuple, expected_shape - ): + def test_get_by_slice(self, crystal_map_input, slice_tuple, expected_shape): xmap = CrystalMap(**crystal_map_input) xmap2 = xmap[slice_tuple] @@ -416,9 +398,7 @@ def test_get_by_phase_name(self, crystal_map_input, phase_list): # Get number of points with each phase ID n_points_per_phase = {} for phase_i, phase in phase_list: - n_points_per_phase[phase.name] = len( - np.where(phase_ids == phase_i)[0] - ) + n_points_per_phase[phase.name] = len(np.where(phase_ids == phase_i)[0]) crystal_map_input.pop("phase_id") xmap = CrystalMap(phase_id=phase_ids, **crystal_map_input) @@ -502,9 +482,7 @@ def test_get_by_integer( point = xmap[integer_slices] expected_point = xmap[xmap.id == expected_id] - assert np.allclose( - point.rotations.data, expected_point.rotations.data - ) + assert np.allclose(point.rotations.data, expected_point.rotations.data) assert point._coordinates == expected_point._coordinates def test_get_twice(self): @@ -624,17 +602,11 @@ def test_set_phase_ids(self, crystal_map, set_phase_id): assert "not_indexed" in xmap.phases.names def test_set_phase_ids_raises(self, crystal_map): - with pytest.raises( - ValueError, match="NumPy boolean array indexing assignment" - ): + with pytest.raises(ValueError, match="NumPy boolean array indexing assignment"): crystal_map[1, 1].phase_id = -1 * np.ones(10) - @pytest.mark.parametrize( - "set_phase_id, index_error", [(-1, False), (1, True)] - ) - def test_set_phase_id_with_unknown_id( - self, crystal_map, set_phase_id, index_error - ): + @pytest.mark.parametrize("set_phase_id, index_error", [(-1, False), (1, True)]) + def test_set_phase_id_with_unknown_id(self, crystal_map, set_phase_id, index_error): xmap = crystal_map condition = xmap.x > 1.5 @@ -668,9 +640,7 @@ def test_phases_in_data(self, crystal_map, phase_list): ) condition1 = xmap.x > 1.5 condition2 = xmap.y > 1.5 - for new_id, condition in zip( - ids_not_in_data, [condition1, condition2] - ): + for new_id, condition in zip(ids_not_in_data, [condition1, condition2]): xmap[condition].phase_id = new_id assert xmap.phases_in_data.names == xmap.phases.names @@ -710,9 +680,7 @@ def test_orientations(self, crystal_map_input, phase_list): (O, [(0.6088, 0, 0, 0.7934)], [(0.9914, 0, 0, 0.1305)]), ], ) - def test_orientations_symmetry( - self, point_group, rotation, expected_orientation - ): + def test_orientations_symmetry(self, point_group, rotation, expected_orientation): r = Rotation(rotation) xmap = CrystalMap(rotations=r, phase_id=np.array([0])) xmap.phases = PhaseList(Phase("a", point_group=point_group)) @@ -733,9 +701,7 @@ def test_orientations_none_symmetry_raises(self, crystal_map_input): with pytest.raises(TypeError, match="Value must be an instance of"): _ = xmap.orientations - def test_orientations_multiple_phases_raises( - self, crystal_map, phase_list - ): + def test_orientations_multiple_phases_raises(self, crystal_map, phase_list): xmap = crystal_map xmap.phases = phase_list @@ -784,9 +750,7 @@ def test_overwrite_crystal_map_property_values(self, crystal_map): new_prop_value = -1 xmap.__setattr__(prop_name, new_prop_value) - assert np.allclose( - xmap.prop[prop_name], np.ones(xmap.size) * new_prop_value - ) + assert np.allclose(xmap.prop[prop_name], np.ones(xmap.size) * new_prop_value) class TestCrystalMapMasking: @@ -813,16 +777,12 @@ class TestCrystalMapGetMapData: ( ((4, 4), (0.5, 1), 2, [0]), "y", - np.array( - [[i * 0.5] * 4 for i in range(4)] - ), # [0, 0, 0, 0, 0.5, ...] + np.array([[i * 0.5] * 4 for i in range(4)]), # [0, 0, 0, 0, 0.5, ...] ), ], indirect=["crystal_map_input"], ) - def test_get_coordinate_array( - self, crystal_map_input, to_get, expected_array - ): + def test_get_coordinate_array(self, crystal_map_input, to_get, expected_array): xmap = CrystalMap(**crystal_map_input) # Get via string @@ -955,9 +915,7 @@ def test_get_boolean_array(self, crystal_map): assert np.issubdtype(xmap.get_map_data("is_indexed").dtype, bool) assert np.issubdtype(xmap.get_map_data(xmap.is_in_data).dtype, bool) - @pytest.mark.parametrize( - "dtype_in", [np.uint8, int, np.float32, float, bool] - ) + @pytest.mark.parametrize("dtype_in", [np.uint8, int, np.float32, float, bool]) def test_preserve_dtype(self, crystal_map, dtype_in): xmap = crystal_map prop_name = "new_prop" @@ -1023,15 +981,11 @@ def test_representation(self, crystal_map, phase_list): class TestCrystalMapCopying: def test_shallowcopy_crystal_map(self, crystal_map): - xmap2 = crystal_map[ - : - ] # Everything except `is_in_data` is shallow copied + xmap2 = crystal_map[:] # Everything except `is_in_data` is shallow copied xmap3 = crystal_map # These are the same objects (of course) assert np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) - assert np.may_share_memory( - xmap2._rotations.data, crystal_map._rotations.data - ) + assert np.may_share_memory(xmap2._rotations.data, crystal_map._rotations.data) crystal_map[3, 0].phase_id = -2 assert np.allclose(xmap2.phase_id, crystal_map.phase_id) @@ -1049,10 +1003,7 @@ def test_deepcopy_crystal_map(self, crystal_map): crystal_map[3, 0].phase_id = -2 assert np.allclose(xmap2.phase_id, crystal_map.phase_id) is False - assert ( - np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) - is False - ) + assert np.may_share_memory(xmap2._phase_id, crystal_map._phase_id) is False class TestCrystalMapShape: @@ -1079,9 +1030,7 @@ class TestCrystalMapShape: ], indirect=["crystal_map_input"], ) - def test_data_slices_from_coordinates( - self, crystal_map_input, expected_slices - ): + def test_data_slices_from_coordinates(self, crystal_map_input, expected_slices): xmap = CrystalMap(**crystal_map_input) assert xmap._data_slices_from_coordinates() == expected_slices @@ -1163,9 +1112,7 @@ def test_rotation_shape(self, crystal_map_input, expected_shape): ], indirect=["crystal_map_input"], ) - def test_coordinate_axes( - self, crystal_map_input, expected_coordinate_axes - ): + def test_coordinate_axes(self, crystal_map_input, expected_coordinate_axes): xmap = CrystalMap(**crystal_map_input) assert xmap._coordinate_axes == expected_coordinate_axes @@ -1208,7 +1155,5 @@ def test_plot(self, crystal_map): class TestCreateCoordinateArrays: def test_create_coordinate_arrays_raises(self): - with pytest.raises( - ValueError, match="Can only create coordinate arrays for " - ): + with pytest.raises(ValueError, match="Can only create coordinate arrays for "): _ = create_coordinate_arrays((1, 2, 3)) diff --git a/orix/tests/test_miller.py b/orix/tests/test_miller.py index 6bc7a5b91..03046f808 100644 --- a/orix/tests/test_miller.py +++ b/orix/tests/test_miller.py @@ -49,26 +49,17 @@ def test_init_raises(self): with pytest.raises(ValueError, match="Exactly *"): _ = Miller(phase=Phase(point_group="m-3m")) with pytest.raises(ValueError, match="Exactly *"): - _ = Miller( - xyz=[0, 1, 2], hkl=[3, 4, 5], phase=Phase(point_group="m-3m") - ) - with pytest.raises( - ValueError, match="A phase with a crystal lattice and " - ): + _ = Miller(xyz=[0, 1, 2], hkl=[3, 4, 5], phase=Phase(point_group="m-3m")) + with pytest.raises(ValueError, match="A phase with a crystal lattice and "): _ = Miller(hkl=[1, 1, 1]) def test_repr(self): m = Miller(hkil=[1, 1, -2, 0], phase=TRIGONAL_PHASE) - assert ( - repr(m) == "Miller (1,), point group 321, hkil\n" - "[[ 1. 1. -2. 0.]]" - ) + assert repr(m) == "Miller (1,), point group 321, hkil\n" "[[ 1. 1. -2. 0.]]" def test_coordinate_format_raises(self): m = Miller(hkl=[1, 1, 1], phase=TETRAGONAL_PHASE) - with pytest.raises( - ValueError, match="Available coordinate formats are " - ): + with pytest.raises(ValueError, match="Available coordinate formats are "): m.coordinate_format = "abc" def test_set_coordinates(self): @@ -93,19 +84,11 @@ def test_set_hkl_hkil(self): assert np.allclose([m1.h, m1.k, m1.l], [[1, 1], [2, 1], [0, 0]]) m2 = Miller(hkil=[[1, 1, -2, 1], [2, 0, -2, 0]], phase=TRIGONAL_PHASE) - assert np.allclose( - m2.data, [[0.20, 0.35, 0.19], [0.41, 0.24, 0]], atol=0.01 - ) - assert np.allclose( - [m2.h, m2.k, m2.i, m2.l], [[1, 2], [1, 0], [-2, -2], [1, 0]] - ) + assert np.allclose(m2.data, [[0.20, 0.35, 0.19], [0.41, 0.24, 0]], atol=0.01) + assert np.allclose([m2.h, m2.k, m2.i, m2.l], [[1, 2], [1, 0], [-2, -2], [1, 0]]) m2.hkil = [[1, 2, -3, 1], [2, 1, -3, 1]] - assert np.allclose( - m2.data, [[0.20, 0.59, 0.19], [0.41, 0.47, 0.19]], atol=0.01 - ) - assert np.allclose( - [m2.h, m2.k, m2.i, m2.l], [[1, 2], [2, 1], [-3, -3], [1, 1]] - ) + assert np.allclose(m2.data, [[0.20, 0.59, 0.19], [0.41, 0.47, 0.19]], atol=0.01) + assert np.allclose([m2.h, m2.k, m2.i, m2.l], [[1, 2], [2, 1], [-3, -3], [1, 1]]) def test_set_uvw_UVTW(self): m1 = Miller(uvw=[[1, 1, 1], [2, 0, 0]], phase=TETRAGONAL_PHASE) @@ -116,19 +99,11 @@ def test_set_uvw_UVTW(self): assert np.allclose([m1.u, m1.v, m1.w], [[1, 1], [2, 1], [0, 0]]) m2 = Miller(UVTW=[[1, 1, -2, 1], [2, 0, -2, 0]], phase=TRIGONAL_PHASE) - assert np.allclose( - m2.data, [[7.35, 12.73, 5.4], [14.7, 8.49, 0]], atol=0.01 - ) - assert np.allclose( - [m2.U, m2.V, m2.T, m2.W], [[1, 2], [1, 0], [-2, -2], [1, 0]] - ) + assert np.allclose(m2.data, [[7.35, 12.73, 5.4], [14.7, 8.49, 0]], atol=0.01) + assert np.allclose([m2.U, m2.V, m2.T, m2.W], [[1, 2], [1, 0], [-2, -2], [1, 0]]) m2.UVTW = [[1, 2, -3, 1], [2, 1, -3, 1]] - assert np.allclose( - m2.data, [[7.35, 21.22, 5.4], [14.7, 16.97, 5.4]], atol=0.01 - ) - assert np.allclose( - [m2.U, m2.V, m2.T, m2.W], [[1, 2], [2, 1], [-3, -3], [1, 1]] - ) + assert np.allclose(m2.data, [[7.35, 21.22, 5.4], [14.7, 16.97, 5.4]], atol=0.01) + assert np.allclose([m2.U, m2.V, m2.T, m2.W], [[1, 2], [2, 1], [-3, -3], [1, 1]]) def test_length(self): # Direct lattice vectors @@ -141,9 +116,7 @@ def test_length(self): assert np.allclose(1 / m2.length, [0.224, 0.156], atol=1e-3) # Vectors - m3 = Miller( - xyz=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], phase=TETRAGONAL_PHASE - ) + m3 = Miller(xyz=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], phase=TETRAGONAL_PHASE) assert np.allclose(m3.length, [1, 1, 1]) def test_init_from_highest_indices(self): @@ -152,21 +125,15 @@ def test_init_from_highest_indices(self): m2 = Miller.from_highest_indices(phase=TETRAGONAL_PHASE, uvw=[1, 2, 3]) assert np.allclose(np.max(m2.uvw, axis=0), [1, 2, 3]) - with pytest.raises( - ValueError, match="Either highest `hkl` or `uvw` indices " - ): + with pytest.raises(ValueError, match="Either highest `hkl` or `uvw` indices "): _ = Miller.from_highest_indices(phase=TETRAGONAL_PHASE) with pytest.raises(ValueError, match="All indices*"): - _ = Miller.from_highest_indices( - phase=TETRAGONAL_PHASE, hkl=[3, 2, -1] - ) + _ = Miller.from_highest_indices(phase=TETRAGONAL_PHASE, hkl=[3, 2, -1]) def test_init_from_min_dspacing(self): # Tested against EMsoft v5.0 - m1 = Miller.from_min_dspacing( - phase=TETRAGONAL_PHASE, min_dspacing=0.05 - ) + m1 = Miller.from_min_dspacing(phase=TETRAGONAL_PHASE, min_dspacing=0.05) assert m1.coordinate_format == "hkl" assert m1.size == 14078 assert np.allclose(np.max(m1.hkl, axis=0), [9, 9, 19]) @@ -276,9 +243,7 @@ def test_unique(self): m3, idx = m2.unit.unique(return_index=True) assert m3.size == 205 assert isinstance(m3, Miller) - assert np.allclose( - idx[:10], [65, 283, 278, 269, 255, 235, 208, 282, 272, 276] - ) + assert np.allclose(idx[:10], [65, 283, 278, 269, 255, 235, 208, 282, 272, 276]) def test_multiply_orientation(self): o = Orientation.from_euler(np.deg2rad([45, 0, 0])) @@ -287,38 +252,26 @@ def test_multiply_orientation(self): m2 = o * m assert isinstance(m2, Miller) assert m2.coordinate_format == "hkl" - assert np.allclose( - m2.data, [[np.sqrt(2), 0, 1], [np.sqrt(2), -np.sqrt(2), 0]] - ) + assert np.allclose(m2.data, [[np.sqrt(2), 0, 1], [np.sqrt(2), -np.sqrt(2), 0]]) def test_overwritten_vector3d_methods(self): lattice = Lattice(1, 1, 1, 90, 90, 90) - phase1 = Phase( - point_group="m-3m", structure=Structure(lattice=lattice) - ) + phase1 = Phase(point_group="m-3m", structure=Structure(lattice=lattice)) phase2 = Phase(point_group="432", structure=Structure(lattice=lattice)) m1 = Miller(hkl=[[1, 1, 1], [2, 0, 0]], phase=phase1) m2 = Miller(hkil=[[1, 1, -2, 0], [2, 1, -3, 1]], phase=phase2) assert not m1._compatible_with(m2) - with pytest.raises( - ValueError, match="The crystal lattices and symmetries" - ): + with pytest.raises(ValueError, match="The crystal lattices and symmetries"): _ = m1.angle_with(m2) - with pytest.raises( - ValueError, match="The crystal lattices and symmetries" - ): + with pytest.raises(ValueError, match="The crystal lattices and symmetries"): _ = m1.cross(m2) - with pytest.raises( - ValueError, match="The crystal lattices and symmetries" - ): + with pytest.raises(ValueError, match="The crystal lattices and symmetries"): _ = m1.dot(m2) - with pytest.raises( - ValueError, match="The crystal lattices and symmetries" - ): + with pytest.raises(ValueError, match="The crystal lattices and symmetries"): _ = m1.dot_outer(m2) m3 = Miller(hkl=[[2, 0, 0], [1, 1, 1]], phase=phase1) @@ -326,14 +279,10 @@ def test_overwritten_vector3d_methods(self): def test_is_hexagonal(self): assert Miller(hkil=[1, 1, -2, 1], phase=TRIGONAL_PHASE).is_hexagonal - assert not Miller( - hkil=[1, 1, -2, 1], phase=TETRAGONAL_PHASE - ).is_hexagonal + assert not Miller(hkil=[1, 1, -2, 1], phase=TETRAGONAL_PHASE).is_hexagonal def test_various_shapes(self): - v = np.array( - [[1, 2, 0], [3, 1, 1], [1, 1, 1], [2, 0, 0], [1, 2, 3], [2, 2, 2]] - ) + v = np.array([[1, 2, 0], [3, 1, 1], [1, 1, 1], [2, 0, 0], [1, 2, 3], [2, 2, 2]]) # Initialization of vectors work as expected shape1 = (2, 3) @@ -443,9 +392,7 @@ def test_transform_space(self): assert not np.may_share_memory(v1, v2) # Incorrect space - with pytest.raises( - ValueError, match="`space_in` and `space_out` must be one " - ): + with pytest.raises(ValueError, match="`space_in` and `space_out` must be one "): _transform_space(v1, "direct", "cartesian", lattice) # uvw -> hkl -> uvw @@ -515,12 +462,8 @@ def test_uvw2UVTW(self): assert np.allclose(m1.unit.data, m2.unit.data) # MTEX convention - assert np.allclose( - _uvw2UVTW(uvw, convention="mtex") / 3, _uvw2UVTW(uvw) - ) - assert np.allclose( - _UVTW2uvw(UVTW, convention="mtex") * 3, _UVTW2uvw(UVTW) - ) + assert np.allclose(_uvw2UVTW(uvw, convention="mtex") / 3, _uvw2UVTW(uvw)) + assert np.allclose(_UVTW2uvw(UVTW, convention="mtex") * 3, _UVTW2uvw(UVTW)) def test_mtex_convention(self): # Same result without convention="mtex" because of rounding... @@ -579,18 +522,12 @@ def test_trigonal_crystal(self): m7 = Miller(hkil=[-1, -1, 2, 0], phase=TRIGONAL_PHASE) assert np.allclose(m6.angle_with(m7, degrees=True)[0], 180) - assert np.allclose( - m6.angle_with(m7, use_symmetry=True, degrees=True)[0], 60 - ) + assert np.allclose(m6.angle_with(m7, use_symmetry=True, degrees=True)[0], 60) def test_convention_not_met(self): - with pytest.raises( - ValueError, match="The Miller-Bravais indices convention" - ): + with pytest.raises(ValueError, match="The Miller-Bravais indices convention"): _ = Miller(hkil=[1, 1, -1, 0], phase=TETRAGONAL_PHASE) - with pytest.raises( - ValueError, match="The Miller-Bravais indices convention" - ): + with pytest.raises(ValueError, match="The Miller-Bravais indices convention"): _ = Miller(UVTW=[1, 1, -1, 0], phase=TETRAGONAL_PHASE) @@ -602,9 +539,7 @@ def test_tetragonal_crystal(self): lattice = TETRAGONAL_LATTICE # Example 1.1: Direct metric tensor - assert np.allclose( - lattice.metrics, [[0.25, 0, 0], [0, 0.25, 0], [0, 0, 1]] - ) + assert np.allclose(lattice.metrics, [[0.25, 0, 0], [0, 0.25, 0], [0, 0, 1]]) # Example 1.2: Distance between two points (length of a vector) answer = np.sqrt(5) / 4 # nm @@ -618,15 +553,11 @@ def test_tetragonal_crystal(self): m2 = Miller(uvw=[1, 2, 0], phase=TETRAGONAL_PHASE) m3 = Miller(uvw=[3, 1, 1], phase=TETRAGONAL_PHASE) assert np.allclose(m2.dot(m3), 5 / 4) # nm^2 - assert np.allclose( - m2.angle_with(m3, degrees=True)[0], 53.30, atol=0.01 - ) + assert np.allclose(m2.angle_with(m3, degrees=True)[0], 53.30, atol=0.01) # Example 1.5: Reciprocal metric tensor lattice_recip = lattice.reciprocal() - assert np.allclose( - lattice_recip.metrics, [[4, 0, 0], [0, 4, 0], [0, 0, 1]] - ) + assert np.allclose(lattice_recip.metrics, [[4, 0, 0], [0, 4, 0], [0, 0, 1]]) # Example 1.6, 1.7: Angle between two plane normals m4 = Miller(hkl=[1, 2, 0], phase=TETRAGONAL_PHASE) From daf7b6581f24493584577e985ee3d1a2bb09049f Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 16:08:59 -0600 Subject: [PATCH 19/40] update _get_laue_group_name to be proceedural instead of a name-based lookup table --- orix/quaternion/symmetry.py | 66 ++++++++++++++++---------- orix/tests/quaternion/test_symmetry.py | 3 +- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 8448c145a..3c7531bfc 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1004,6 +1004,7 @@ def get_point_groups(subset: str = "unique"): C2, # Orthorhombic D2, + D4, # Tetragonal C4, # Trigonal @@ -1017,7 +1018,26 @@ def get_point_groups(subset: str = "unique"): O, ] elif subset == "laue": - return [x * Ci for x in get_point_groups("proper")] + return [ + # Triclinic + Ci, + # Monoclinic + C2h, + # Orthorhombic + D2h, + D4h, + # Tetragonal + C4h, + # Trigonal + C3i, + D3d, + # Hexagonal + C6h, + D6h, + # cubic + Th, + Oh, + ] elif subset == "proper_all": return [ # Triclinic @@ -1240,30 +1260,26 @@ def get_point_group_from_space_group( def _get_laue_group_name(name: str) -> str | None: - if name in ["1", "-1"]: - return "-1" - elif name in ["2", "211", "121", "112", "m11", "1m1", "11m", "2/m"]: - return "2/m" - elif name in ["222", "mm2", "mmm"]: - return "mmm" - elif name in ["4", "-4", "4/m"]: - return "4/m" - elif name in ["422", "4mm", "-42m", "4/mmm"]: - return "4/mmm" - elif name in ["3", "-3"]: - return "-3" - elif name in ["321", "312", "32", "3m", "-3m"]: - return "-3m" - elif name in ["6", "-6", "6/m"]: - return "6/m" - elif name in ["6mm", "-6m2", "6/mmm", "622"]: - return "6/mmm" - elif name in ["23", "m-3"]: - return "m-3" - elif name in ["432", "-43m", "m-3m"]: - return "m-3m" - else: - return None + # search through all the point groups defined in orix for one with a + # matching name. + valid_name = False + for g in get_point_groups("all_repeated"): + if g.name == name: + valid_name = True + break + if valid_name == False: + raise ValueError("{} is not a valid point group name") + # add an inversion to get the laue group. + g_laue = _get_unique_symmetry_elements(g, Ci) + # find a laue group with matching operators + for laue in get_point_groups("laue"): + # first check for length + if g_laue.shape != laue.shape: + continue + # then check for identical operators (regardless of order) + if np.min(g_laue.outer(laue).angle ** 2, 1).max() < 1e-4: + return laue.name + raise ValueError("Could not find Laue group name for {}".format(g.name)) def _get_unique_symmetry_elements( diff --git a/orix/tests/quaternion/test_symmetry.py b/orix/tests/quaternion/test_symmetry.py index 923890a06..070045e56 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -913,7 +913,8 @@ def test_laue_group_name(self): assert D6h.laue.name == "6/mmm" assert Th.laue.name == "m-3" assert Oh.laue.name == "m-3m" - assert Symmetry(((1, 0, 0, 0), (1, 1, 0, 0))).laue.name is None + with pytest.raises(ValueError): + Symmetry(((1, 0, 0, 0), (1, 1, 0, 0))).laue.name class TestEulerFundamentalRegion: From 5e1f8cdb4cf68332716d0d37de4d436df98c5bd4 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:39:17 -0600 Subject: [PATCH 20/40] expand symmetry marker and add example --- .../plot_symmetry_operations.py | 161 ++++++++++++---- orix/plot/_symmetry_marker.py | 125 ------------- orix/plot/stereographic_plot.py | 173 +++++++++++++++--- orix/quaternion/symmetry.py | 8 +- orix/tests/plot/test_stereographic_plot.py | 117 ++++-------- orix/tests/quaternion/test_symmetry.py | 42 +++++ 6 files changed, 350 insertions(+), 276 deletions(-) delete mode 100644 orix/plot/_symmetry_marker.py diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index f50c03150..ed4460041 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -3,51 +3,134 @@ Plot symmetry operations ======================== -This example shows how to draw proper symmetry operations :math:`s` -(no reflections or inversions). +This example is one method for producing stereographic projections with +symmetry operators for the 32 crystallographic point groups. This method +loosely follows the one laid out in section 9.2 of "Structures of Materials" +(DeGraef et.al, 2nd edition, 2012). + +There are generated proceedurally, and vary slightly from some other +approaches. Consider, for example, more curated +approaches. Consider, for example, the plot for the D2h == "mmm" point group, +which displays the inversion center as a dot in the center 2-fold marker, +whereas Figure 9.9 of "Structure of Materials" leaves these markers out. +Additionally, like the International tables of crystallography (ITOC) Table +10.2.2, which is the generally accepted standard for stereographic projections, +the inversion symmetry dots have been removed from markers along the +equator. """ import matplotlib.pyplot as plt +import numpy as np from orix import plot +from orix.quaternion.symmetry import * from orix.vector import Vector3d -marker_size = 200 -fig, (ax0, ax1) = plt.subplots( - ncols=2, - subplot_kw={"projection": "stereographic"}, - layout="tight", -) +# generate a list of the 32 crystallographic point groups. +# NOTE: This could instead be done with "get_point_groups()", but the +# following list shows a more logical addition of symmetry operators. +point_groups = get_point_groups("procedural") + + +# Set marker sizes and colors to help differentiate elements +s = 160 +colors = {1: "magenta", 2: "green", 3: "red", 4: "purple", 6: "black"} +mirror_linewidth = 1 +mirror_color = "blue" + -ax0.set_title("432", pad=20) -# 4-fold (outer markers will be clipped a bit...) -v4fold = Vector3d([[0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]]) -ax0.symmetry_marker(v4fold, fold=4, c="C4", s=marker_size) -ax0.draw_circle(v4fold, color="C4") -# 3-fold -v3fold = Vector3d([[1, 1, 1], [1, -1, 1], [-1, -1, 1], [-1, 1, 1]]) -ax0.symmetry_marker(v3fold, fold=3, c="C3", s=marker_size) -ax0.draw_circle(v3fold, color="C3") -# 2-fold -# fmt: off -v2fold = Vector3d( - [ - [ 1, 0, 1], - [ 0, 1, 1], - [-1, 0, 1], - [ 0, -1, 1], - [ 1, 1, 0], - [-1, -1, 0], - [-1, 1, 0], - [ 1, -1, 0], - ] +# Create the plot and subplots using ORIX's stereographic projection +fig, ax = plt.subplots( + 4, 8, subplot_kw={"projection": "stereographic"}, figsize=[14, 10] ) -# fmt: on -ax0.symmetry_marker(v2fold, fold=2, c="C2", s=marker_size) -ax0.draw_circle(v2fold, color="C2") - -ax1.set_title("222", pad=20) -# 2-fold -v2fold = Vector3d([[0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]]) -ax1.symmetry_marker(v2fold, fold=2, c="C2", s=2 * marker_size) -ax1.draw_circle(v2fold, color="C2") +ax = ax.flatten() + + +# Iterate through the 32 Point groups +for i, pg in enumerate(point_groups): + ax[i].set_title(pg.name) + # get unique axis families (should just be <100>, <110>, and/or <111>) + unique_axes = Vector3d( + np.unique(np.around(pg.axis.in_fundamental_sector(pg).data, 5), axis=0) + ) + # create masks to sort out which elements are rotations, mirrors, + # inversions, and rotoinversions + p_mask = ~pg.improper + m_mask = (np.abs(pg.angle - np.pi) < 1e-4) * pg.improper + r_mask = pg.angle**2 > 1e-4 + roto_mask = r_mask * ~p_mask * ~m_mask + i_mask = (~r_mask) * pg.improper + # to avoid repetition, look at only the unique fundamental representations + # of the possible rotation axes + fs_axes = pg.axis.in_fundamental_sector(pg) + decorated_axes = [] + + # iterate through each primary axis, plotting their symmetry elements + # as we go. + for axis in unique_axes: + axis_mask = np.sum(np.abs((fs_axes - axis).data), 1) < 1e-4 + # plot any mirror planes perpendicular to the axis first + if np.any(m_mask * axis_mask): + for v in pg * axis: + ax[i].plot( + v.get_circle(), + color=mirror_color, + linewidth=mirror_linewidth, + ) + # if all rotations are proper rotations, plot the appropriate symbol + if np.all(p_mask[axis_mask]): + # if the only element is identity, move on. + if not np.any(r_mask * axis_mask): + continue + min_ang = np.abs(pg[r_mask * axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + c = colors[f] + ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) + decorated_axes.append(axis * 1) + # if there is an inversion center, plot the appropriate symbol + elif np.any(i_mask): + # this might just be the 1-fold inversion center + if not np.any(r_mask * p_mask): + f = 1 + else: + min_ang = np.abs(pg[r_mask * axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + c = colors[f] + if axis.z[0] ** 2 > 1e-4: + ax[i].symmetry_marker( + (pg * axis), fold=f, s=s, color=c, inner="dot" + ) + else: + ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) + + decorated_axes.append(axis * 1) + # the other option (besides empty) is a rotoinversion + elif np.any(roto_mask[axis_mask]): + min_ang = np.abs(pg[roto_mask * axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + c = colors[f] + if axis.z[0] ** 2 > 1e-4: + ax[i].symmetry_marker( + (pg * axis), fold=f, s=s, color=c, inner="half" + ) + else: + ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) + decorated_axes.append(axis * 1) + # Three-fold rotations around the 111 create a special subset of mirror + # planes, which for ease we will add in by hand. + if np.any(unique_axes.dot(Vector3d([1, 1, 1])) > 1.73): + m_vectors = Vector3d([[0, 0, 1], [0, 1, 1]]) + for v in (pg.outer(m_vectors)).flatten().unique(): + ax[i].plot(v.get_circle(), color="blue", linewidth=1) + + # Finally, the combination of inversion center and mirror planes + # creates 2-fold symmetries not on the primary axes. Let's add in any + # that didn't already get included from other operations + if np.sum(m_mask) > 1 and np.sum(i_mask) > 0: + dax = Vector3d([x.data for x in decorated_axes]) + dax_unique = dax.flatten().unique() + two_folds = pg.axis[m_mask].in_fundamental_sector(pg) + mask = np.abs(two_folds.dot_outer(dax_unique)).max(axis=1) < 0.99 + new_two_folds = two_folds[mask] + symm_two_folds = pg.outer(new_two_folds).flatten().unique() + ax[i].symmetry_marker(symm_two_folds, fold=2, s=s, color="g") diff --git a/orix/plot/_symmetry_marker.py b/orix/plot/_symmetry_marker.py deleted file mode 100644 index b02472c19..000000000 --- a/orix/plot/_symmetry_marker.py +++ /dev/null @@ -1,125 +0,0 @@ -# Copyright 2018-2024 the orix developers -# -# This file is part of orix. -# -# orix is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# orix is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with orix. If not, see . - -"""Symmetry element markers to plot in stereographic representations of -crystallographic point groups. - -Meant to be used indirectly in -:func:`~orix.plot.StereographicPlot.symmetry_marker`. -""" - -from typing import Union - -import matplotlib.path as mpath -import matplotlib.transforms as mtransforms -import numpy as np - -from orix.vector import Vector3d - - -class SymmetryMarker: - """Symmetry marker for use in plotting. - - Parameters - ---------- - v - size - """ - - fold = None - _marker = None - - def __init__(self, v: Union[Vector3d, np.ndarray, list, tuple], size: int = 1): - self._vector = Vector3d(v) - self._size = size - - @property - def angle_deg(self) -> np.ndarray: - """Position in degrees.""" - return np.rad2deg(self._vector.azimuth) + 90 - - @property - def size(self) -> np.ndarray: - """Multiplicity of each symmetry marker.""" - return np.ones(self.n) * self._size - - @property - def n(self) -> int: - """Number of symmetry markers.""" - return self._vector.size - - def __iter__(self): - for v, marker, size in zip(self._vector, self._marker, self.size): - yield v, marker, size - - -class TwoFoldMarker(SymmetryMarker): - """Two-fold symmetry marker.""" - - fold = 2 - - @property - def size(self) -> np.ndarray: - """Multiplicity of each symmetry marker.""" - # Assuming maximum polar angle is 90 degrees - radial = np.tan(self._vector.polar / 2) - radial = np.where(radial == 0, 1, radial) - return self._size / np.sqrt(radial) - - @property - def _marker(self): - # Make an ellipse path (https://matplotlib.org/stable/api/path_api.html) - circle = mpath.Path.circle() - verts = np.copy(circle.vertices) # Paths considered immutable - verts[:, 0] *= 2 - ellipse = mpath.Path(verts, circle.codes) - - # Set up rotations of ellipse - azimuth = self._vector.azimuth - trans = [mtransforms.Affine2D().rotate(a + (np.pi / 2)) for a in azimuth] - - return [ellipse.deepcopy().transformed(i) for i in trans] - - -class ThreeFoldMarker(SymmetryMarker): - """Three-fold symmetry marker.""" - - fold = 3 - - @property - def _marker(self): - return [(3, 0, angle) for angle in self.angle_deg] - - -class FourFoldMarker(SymmetryMarker): - """Four-fold symmetry marker.""" - - fold = 4 - - @property - def _marker(self): - return ["D"] * self.n - - -class SixFoldMarker(SymmetryMarker): - """Six-fold symmetry marker.""" - - fold = 6 - - @property - def _marker(self): - return ["h"] * self.n diff --git a/orix/plot/stereographic_plot.py b/orix/plot/stereographic_plot.py index 188532441..d4da01138 100644 --- a/orix/plot/stereographic_plot.py +++ b/orix/plot/stereographic_plot.py @@ -29,20 +29,14 @@ import matplotlib.path as mpath import matplotlib.projections as mprojections import matplotlib.pyplot as plt +import matplotlib.transforms as mtransforms import numpy as np -# fmt: off -# isort: off from orix.measure import pole_density_function -from orix.plot._symmetry_marker import ( - TwoFoldMarker, - ThreeFoldMarker, - FourFoldMarker, - SixFoldMarker, +from orix.projections import ( + InverseStereographicProjection, + StereographicProjection, ) -# isort: on -# fmt: on -from orix.projections import InverseStereographicProjection, StereographicProjection from orix.vector import FundamentalSector, Vector3d from orix.vector.fundamental_sector import _closed_edges_in_hemisphere @@ -458,7 +452,12 @@ def draw_circle( other_hemisphere = {"upper": "lower", "lower": "upper"} self._hemisphere = other_hemisphere[self._hemisphere] for i, c in enumerate(circles): - self.plot(c.azimuth, c.polar, color=color2[i], **reproject_plot_kwargs) + self.plot( + c.azimuth, + c.polar, + color=color2[i], + **reproject_plot_kwargs, + ) self._hemisphere = other_hemisphere[self._hemisphere] def restrict_to_sector( @@ -518,7 +517,11 @@ def restrict_to_sector( self.patches[0].set_visible(False) if show_edges: - for k, v in [("facecolor", "none"), ("edgecolor", "k"), ("linewidth", 1)]: + for k, v in [ + ("facecolor", "none"), + ("edgecolor", "k"), + ("linewidth", 1), + ]: kwargs.setdefault(k, v) patch = mpatches.PathPatch( mpath.Path(np.column_stack([x, y]), closed=True), @@ -630,34 +633,35 @@ def stereographic_grid( self.collections[index_polar].remove() self._stereographic_grid = False - def symmetry_marker(self, v: Vector3d, fold: int, **kwargs): - """Plot 2-, 3- 4- or 6-fold symmetry marker(s). + def symmetry_marker(self, v: Vector3d, fold: int, inner="fill", **kwargs): + """Plot symmetry marker(s). Parameters ---------- v Position of the marker(s) to plot. fold - Which symmetry element to plot, can be either 2, 3, 4 or 6. + Which symmetry element to plot, can be either 1, 2, 3, 4 or 6. **kwargs Keyword arguments passed to :meth:`scatter`. """ - if fold not in [2, 3, 4, 6]: - raise ValueError("Can only plot 2-, 3-, 4- or 6-fold elements.") + if fold not in [1, 2, 3, 4, 6]: + raise ValueError("Can only plot 1-, 2-, 3-, 4- or 6-fold elements.") - marker_classes = { - "2": TwoFoldMarker, - "3": ThreeFoldMarker, - "4": FourFoldMarker, - "6": SixFoldMarker, - } - marker = marker_classes[str(fold)](v, size=kwargs.pop("s", 1)) + marker = SymmetryMarker(v, size=kwargs.pop("s", 1), folds=fold, inner=inner) new_kwargs = dict(zorder=ZORDER["symmetry_marker"], clip_on=False) for k, v in new_kwargs.items(): kwargs.setdefault(k, v) for vec, marker, marker_size in marker: + if fold == 1: + marker_size = marker_size / 2 + if inner != "fill": + bg_kwargs = deepcopy(kwargs) + if "color" in bg_kwargs: + bg_kwargs.pop("color") + self.scatter(vec, color="w", s=marker_size * 0.5, **bg_kwargs) self.scatter(vec, marker=marker, s=marker_size, **kwargs) # TODO: Find a way to control padding, so that markers aren't @@ -968,3 +972,124 @@ def _order_in_hemisphere(polar: np.ndarray, pole: int) -> Union[np.ndarray, None order = np.roll(order, shift=-(indices[-1] + 1)) return order + + +class SymmetryMarker: + """ + Symmetry element markers to plot in stereographic representations of + crystallographic point groups. + + Intended to be used indirectly in + :func:`~orix.plot.StereographicPlot.symmetry_marker`. + + Parameters + ---------- + v (Vector3d object) + Vector3d object used for placing marker in StereographicPlot + + size (int or float) + value passed to matplotlib to determine relative marker size + + folds (integer) + rotational symmetry (typically 1, 2, 3, 4, or 6) for the + symmetry marker to represent with it's shape (circle, elliplse, + triangle, square, or hex)' + + inner ('fill' or 'dot' or 'half') + The shape to put inside the symmetry marker to express additional + symmetry information 'fill' adds nothing, 'dot' adds the dot + traditionally used for inversion centers and mirrors, and 'half' adds + an empty polygon with half as many folds as the marker within the + marker, such as what is used for rotoinversions. + """ + + def __init__( + self, + v: Union[Vector3d, np.ndarray, list, tuple], + size: int = 1, + folds=2, + inner="fill", + ): + assert np.isin(folds, [1, 2, 3, 4, 6]) + assert np.isin(inner, ["fill", "dot", "half"]) + self._vector = Vector3d(v) + self._size = size + self._folds = folds + self._inner_shape = inner + + @property + def angle_deg(self) -> np.ndarray: + """Position in degrees.""" + return np.rad2deg(self._vector.azimuth) + 90 + + @property + def size(self) -> np.ndarray: + """Multiplicity of each symmetry marker.""" + return np.ones(self.n) * self._size + + @property + def n(self) -> int: + """Number of symmetry markers.""" + return self._vector.size + + def __iter__(self): + for v, marker, size in zip(self._vector, self._marker, self.size): + yield v, marker, size + + @property + def _marker(self): + azimuth = self._vector.azimuth + # pre-define inner cirle (reused) + inner_circle = mpath.Path.circle((0, 0), 0.5) + i_vert = np.copy(inner_circle.vertices) + i_code = inner_circle.codes + # pre-define 2-fold ellipse (reused) + e_vert = np.copy(i_vert * 2) + e_code = np.copy(i_code) + e_vert[:, 1] = e_vert[:, 1] * ((1 - e_vert[:, 0] ** 2) ** 0.5) * 0.35 + if self._folds == 1: + # return either a normal circle, or a cirle with a dot in the + # center if this also anie, inversion center + circle = mpath.Path.circle((0, 0), 0.75) + vert = np.copy(circle.vertices) + code = circle.codes + if self._inner_shape == "dot": + verts = np.concatenate([vert, i_vert[::-1]]) + codes = np.concatenate([code, i_code]) + marker = mpath.Path(verts, codes) + else: + marker = mpath.Path(vert, code) + return [marker.deepcopy() for ang in azimuth] + if self._folds == 2: + # use the ellipse defined above + marker = mpath.Path(e_vert, e_code) + else: + # if it's not 2-fold, just use a default polygon + marker = mpath.Path.unit_regular_polygon(self._folds) + if self._inner_shape == "dot": + # add the inner circle + verts = np.concatenate([marker.vertices, i_vert[::-1]]) + codes = np.concatenate([marker.codes, i_code]) + marker = mpath.Path(verts, codes) + elif self._inner_shape == "half": + # add an inner shape with half the folds + vert = np.copy(marker.vertices) + code = np.copy(marker.codes) + if self._folds == 4: + inner = mpath.Path(e_vert, e_code) + else: + inner = mpath.Path.unit_regular_polygon(int(self._folds / 2)) + i_vert = np.copy(inner.vertices[::-1]) + i_code = np.copy(inner.codes) + verts = np.concatenate([vert, i_vert]) + codes = np.concatenate([code, i_code]) + marker = mpath.Path(verts, codes) + # rotate each marker to align with local symmetry lines + # icons are not, by default, properly aligned. Let's fix that. + if self._folds == 3: + azimuth -= np.pi / 2 + azimuth[self._vector.polar > 1e-6] -= np.pi / 2 + + trans = [mtransforms.Affine2D().rotate(a + (np.pi / 2)) for a in azimuth] + + return [marker.deepcopy().transformed(i) for i in trans] diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 3c7531bfc..96d67c33d 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1062,7 +1062,7 @@ def get_point_groups(subset: str = "unique"): T, O, ] - elif subset == "SoM": + elif subset == "procedural": return [ # Cyclic C1, @@ -1107,7 +1107,7 @@ def get_point_groups(subset: str = "unique"): Oh, ] else: - ValueError("{} is not a valid subset option".format(subset)) + raise ValueError("{} is not a valid subset option".format(subset)) def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: @@ -1278,8 +1278,8 @@ def _get_laue_group_name(name: str) -> str | None: continue # then check for identical operators (regardless of order) if np.min(g_laue.outer(laue).angle ** 2, 1).max() < 1e-4: - return laue.name - raise ValueError("Could not find Laue group name for {}".format(g.name)) + if np.min(g_laue.outer(laue).angle ** 2, 0).max() < 1e-4: + return laue.name def _get_unique_symmetry_elements( diff --git a/orix/tests/plot/test_stereographic_plot.py b/orix/tests/plot/test_stereographic_plot.py index b05c520ba..8f672b002 100644 --- a/orix/tests/plot/test_stereographic_plot.py +++ b/orix/tests/plot/test_stereographic_plot.py @@ -23,17 +23,7 @@ import pytest from orix import plot - -# fmt: off -# isort: off -from orix.plot.stereographic_plot import ( - TwoFoldMarker, - ThreeFoldMarker, - FourFoldMarker, - SixFoldMarker, -) -# isort: on -# fmt: on +from orix.plot.stereographic_plot import SymmetryMarker from orix.quaternion import symmetry from orix.vector import Vector3d @@ -182,7 +172,8 @@ def test_show_hemisphere_label(self): plt.close("all") @pytest.mark.parametrize( - "hemisphere, pole, hemi_str", [("uPPer", -1, "upper"), ("loweR", 1, "lower")] + "hemisphere, pole, hemi_str", + [("uPPer", -1, "upper"), ("loweR", 1, "lower")], ) def test_hemisphere_pole(self, hemisphere, pole, hemi_str): _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) @@ -296,83 +287,34 @@ def test_size_parameter(self): class TestSymmetryMarker: - def test_properties(self): - v2fold = Vector3d([[1, 0, 1], [0, 1, 1]]) - marker2fold = TwoFoldMarker(v2fold) - assert np.allclose(v2fold.data, marker2fold._vector.data) - assert marker2fold.fold == 2 - assert marker2fold.n == 2 - assert np.allclose(marker2fold.size, [1.55, 1.55], atol=1e-2) - assert isinstance(marker2fold._marker[0], mpath.Path) - - v3fold = Vector3d([1, 1, 1]) - marker3fold = ThreeFoldMarker(v3fold, size=5) - assert np.allclose(v3fold.data, marker3fold._vector.data) - assert marker3fold.fold == 3 - assert marker3fold.n == 1 - assert np.allclose(marker3fold.size, 5) - - # Iterating over markers - for i, (vec, mark, size) in enumerate(marker3fold): - assert np.allclose(vec.data, v3fold[i].data) - assert np.allclose(mark, (3, 0, 45 + 90)) - assert size == 5 - - v4fold = Vector3d([[0, 0, 1], [1, 0, 0], [0, 1, 0]]) - marker4fold = FourFoldMarker(v4fold, size=11) - assert np.allclose(v4fold.data, marker4fold._vector.data) - assert marker4fold.fold == 4 - assert marker4fold.n == 3 - assert np.allclose(marker4fold.size, [11, 11, 11]) - assert marker4fold._marker == ["D"] * 3 - - marker6fold = SixFoldMarker([0, 0, 1], size=15) - assert isinstance(marker6fold._vector, Vector3d) - assert np.allclose(marker6fold._vector.data, [0, 0, 1]) - assert marker6fold.fold == 6 - assert marker6fold.n == 1 - assert marker6fold.size == 15 - assert marker6fold._marker == ["h"] - - plt.close("all") + @pytest.mark.parametrize("v_data", [[0, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]) + @pytest.mark.parametrize("folds", [1, 2, 3, 4, 6]) + @pytest.mark.parametrize("fill", ["fill", "dot", "half"]) + def test_main_properties(self, v_data, folds, fill): + v = Vector3d(v_data) + marker = SymmetryMarker(v, folds=folds, inner=fill) + assert np.allclose(v.data, marker._vector.data) + assert marker._folds == folds + assert marker._inner_shape == fill + assert (marker.angle_deg - (np.rad2deg(v.azimuth) + 90)) ** 2 < 1e-4 def test_plot_symmetry_marker(self): _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) ax.stereographic_grid(False) marker_size = 500 - v4fold = Vector3d([[0, 0, 1], [1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0]]) - ax.symmetry_marker(v4fold, fold=4, c="C4", s=marker_size) - - v3fold = Vector3d([[1, 1, 1], [1, -1, 1], [-1, -1, 1], [-1, 1, 1]]) - ax.symmetry_marker(v3fold, fold=3, c="C3", s=marker_size) - - v2fold = Vector3d( - [ - [1, 0, 1], - [0, 1, 1], - [-1, 0, 1], - [0, -1, 1], - [1, 1, 0], - [-1, -1, 0], - [-1, 1, 0], - [1, -1, 0], - ] - ) - ax.symmetry_marker(v2fold, fold=2, c="C2", s=marker_size) - - ax.symmetry_marker([0, 0, 1], fold=6, s=marker_size) + v = Vector3d([[1, 0, 0], [0, 1, 1]]) + for i in [1, 2, 3, 4, 6]: + ax.symmetry_marker(v[0], fold=i, s=marker_size, color="k") + ax.symmetry_marker(v[1], fold=i, inner="dot", s=marker_size) + ax.symmetry_marker(v, fold=1, inner="dot", color="C1", s=marker_size) + for i in [4, 6]: + ax.symmetry_marker(v, fold=i, inner="half", s=marker_size) markers = ax.collections - assert len(markers) == 18 - assert np.allclose(markers[0]._sizes, marker_size) - assert np.allclose(markers[-1]._sizes, marker_size) - assert np.allclose(markers[0]._facecolors, mcolors.to_rgba("C4")) - assert np.allclose(markers[5]._facecolors, mcolors.to_rgba("C3")) - assert np.allclose(markers[-2]._facecolors, mcolors.to_rgba("C2")) - assert np.allclose(markers[-1]._facecolors, mcolors.to_rgba("C0")) - - with pytest.raises(ValueError, match="Can only plot 2"): + assert len(markers) == 43 + + with pytest.raises(ValueError, match="Can only plot 1"): ax.symmetry_marker([0, 0, 1], fold=5) plt.close("all") @@ -432,8 +374,14 @@ def test_draw_circle(self): assert len(ax[1].lines) == 3 assert ax[0].lines[0]._path._vertices.shape == (upper_steps, 2) assert ax[1].lines[0]._path._vertices.shape == (lower_steps, 2) - assert ax[1].lines[1]._path._vertices.shape == (lower_steps // 2 + 1, 2) - assert ax[1].lines[1]._path._vertices.shape == (lower_steps // 2 + 1, 2) + assert ax[1].lines[1]._path._vertices.shape == ( + lower_steps // 2 + 1, + 2, + ) + assert ax[1].lines[1]._path._vertices.shape == ( + lower_steps // 2 + 1, + 2, + ) plt.close("all") @@ -482,7 +430,8 @@ def test_pdf_args(self): def test_pdf_args_raises(self): fig, ax = plt.subplots(subplot_kw=dict(projection="stereographic")) with pytest.raises( - TypeError, match="If one argument is passed it must be an instance of " + TypeError, + match="If one argument is passed it must be an instance of ", ): ax.pole_density_function("test") diff --git a/orix/tests/quaternion/test_symmetry.py b/orix/tests/quaternion/test_symmetry.py index 070045e56..67dff03de 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -890,6 +890,48 @@ def test_equality(symmetry): assert Rotation(symmetry) == symmetry +@pytest.mark.parametrize( + ["subset", "length", "proper_count"], + [ + ["unique", 32, 11], + ["all", 37, 14], + ["all_repeated", 44, 17], + ["proper", 11, 11], + ["proper_all", 14, 14], + ["laue", 11, 0], + ["procedural", 32, 11], + ], +) +def test_get_point_groups(subset, length, proper_count): + # check that we get the expected number of proper and total point groups. + group = get_point_groups(subset) + assert len(group) == length + assert np.sum([x.is_proper for x in group]) == proper_count + + +def test_get_point_groups_unique(): + group = get_point_groups() + # this is just a check to see if each element is unique, and if there are + # 32 of them. + assert np.all( + np.sum( + [ + [ + _get_unique_symmetry_elements(a, b) == b + and _get_unique_symmetry_elements(a, b) == a + for a in group + ] + for b in group + ], + 1, + ) + == np.ones(32) + ) + # additional test that nonsense returns nonsense + with pytest.raises(ValueError): + get_point_groups("banana") + + class TestLaueGroup: def test_crystal_system(self): assert Ci.system == "triclinic" From 9fc91d64e8ec19cbcb62ee5e1d07cd7a5879fbc8 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:56:00 -0600 Subject: [PATCH 21/40] formatting --- .../stereographic_projection/plot_symmetry_operations.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index ed4460041..7d4c28947 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -97,9 +97,7 @@ f = np.around(2 * np.pi / min_ang).astype(int) c = colors[f] if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker( - (pg * axis), fold=f, s=s, color=c, inner="dot" - ) + ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c, inner="dot") else: ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) @@ -110,9 +108,7 @@ f = np.around(2 * np.pi / min_ang).astype(int) c = colors[f] if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker( - (pg * axis), fold=f, s=s, color=c, inner="half" - ) + ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c, inner="half") else: ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) decorated_axes.append(axis * 1) From 54cd0a1c1af6627e1baea5fa320b14004f669b1d Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:15:01 -0600 Subject: [PATCH 22/40] make tables sphinx-compatable --- orix/quaternion/symmetry.py | 105 ++++++++++++++++++++---------------- 1 file changed, 59 insertions(+), 46 deletions(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 96d67c33d..62c5b0047 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -257,7 +257,9 @@ def fundamental_sector(self) -> "FundamentalSector": # Taken from MTEX center = Vector3d([0.707558, -0.000403, 0.706655]) elif name in ["m-3", "432"]: - n = Vector3d(np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data])) + n = Vector3d( + np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data]) + ) # Taken from MTEX center = Vector3d([0.349928, 0.348069, 0.869711]) elif name == "-43m": @@ -365,7 +367,9 @@ def __and__(self, other: Symmetry) -> Symmetry: return Symmetry.from_generators(*generators) def __hash__(self) -> int: - return hash(self.name.encode() + self.data.tobytes() + self.improper.tobytes()) + return hash( + self.name.encode() + self.data.tobytes() + self.improper.tobytes() + ) # ------------------------ Class methods ------------------------- # @@ -423,7 +427,9 @@ def get_axis_orders(self) -> dict[Vector3d, int]: return {} return { Vector3d(a): b + 1 - for a, b in zip(*np.unique(s.axis.data, axis=0, return_counts=True)) + for a, b in zip( + *np.unique(s.axis.data, axis=0, return_counts=True) + ) } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: @@ -669,9 +675,15 @@ def plot( D4h.name = "4/mmm" # 3-fold rotations -C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)]) -C3y = Symmetry([(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)]) -C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))]) +C3x = Symmetry( + [(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)] +) +C3y = Symmetry( + [(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)] +) +C3z = Symmetry( + [(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))] +) C3 = Symmetry(C3z) C3.name = "3" @@ -773,83 +785,84 @@ def get_point_groups(subset: str = "unique"): For convenience, the following table shows the 38 point groups contained in "all", of which the other lists are subsets of. - +-------------+--------+---------------------+------------+------------+ - | Schoenflies | System | ITOC/Herman_Mauguin | Laue class | Proper PG | - +-------------+--------+---------------------+------------+------------+ + + +-------------+--------------+---------------+------------+------------+ + | Schoenflies | System | HM | Laue Class | Proper PG | + +=============+==============+===============+============+============+ | C1 | Triclinic | 1 | -1 | 1 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Ci | Triclinic | -1 | -1 | 1 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C2 | Monoclinic | 2 or 112 | 2/m | 112 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C2x | Monoclinic | 211 | 2/m | 211 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C2y | Monoclinic | 121 | 2/m | 121 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Cs | Monoclinic | 11m or m | 2/m | 1 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Csx | Monoclinic | m11 | 2/m | 1 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Csy | Monoclinic | 1m1 | 2/m | 1 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C2h | Monoclinic | 2/m | 2/m | 112 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D2 | Orthorhombic | 222 | mmm | 222 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C2v | Orthorhombic | mm2 | mmm | 211 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D2h | Orthorhombic | mmm | mmm | 222 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C4 | Tetragonal | 4 | 4/m | 4 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | S4 | Tetragonal | -4 | 4/m | 112 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C4h | Tetragonal | 4/m | 4/m | 4 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D4 | Tetragonal | 422 | 4/mmm | 422 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C4v | Tetragonal | 4mm | 4/mmm | 4 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D2d | Tetragonal | -42m | 4/mmm | 222 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D4h | Tetragonal | 4/mmm | 4/mmm | 422 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C3 | Trigonal | 3 | -3 | 3 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C3i | Trigonal | -3 | -3 | 3 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D3 | Trigonal | 32 or 321 | -3m | 32 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D3y | Trigonal | 312 | -3m | 312 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C3v | Trigonal | 3m | -3m | 3 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D3d | Trigonal | -3m | -3m | 32 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C6 | Hexagonal | 6 | 6/m | 6 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C3h | Hexagonal | -6 | 6/m | 6 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C6h | Hexagonal | 6/m | 6/m | 622 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D6 | Hexagonal | 622 | 6/mmm | 622 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | C6v | Hexagonal | 6mm | 6/mmm | 6 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D3h | Hexagonal | -6m2 | 6/mmm | 312 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | D6h | Hexagonal | 6/mmm | 6/mmm | 622 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | T | Cubic | 23 | m-3 | 23 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Th | Cubic | m-3 | m-3 | 23 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | O | Cubic | 432 | m-3m | 432 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Td | Cubic | -43m | m-3m | 23 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ | Oh | Cubic | m-3m | m-3m | 432 | - +-------------+--------+---------------------+------------+------------+ + +-------------+--------------+---------------+------------+------------+ """ subset = str(subset).lower() From 70adbc7c76f80233f8af00c336e22c320dd1edc7 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:12:18 -0600 Subject: [PATCH 23/40] formatting --- orix/quaternion/symmetry.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 62c5b0047..9a9221d6a 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -257,9 +257,7 @@ def fundamental_sector(self) -> "FundamentalSector": # Taken from MTEX center = Vector3d([0.707558, -0.000403, 0.706655]) elif name in ["m-3", "432"]: - n = Vector3d( - np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data]) - ) + n = Vector3d(np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data])) # Taken from MTEX center = Vector3d([0.349928, 0.348069, 0.869711]) elif name == "-43m": @@ -367,9 +365,7 @@ def __and__(self, other: Symmetry) -> Symmetry: return Symmetry.from_generators(*generators) def __hash__(self) -> int: - return hash( - self.name.encode() + self.data.tobytes() + self.improper.tobytes() - ) + return hash(self.name.encode() + self.data.tobytes() + self.improper.tobytes()) # ------------------------ Class methods ------------------------- # @@ -427,9 +423,7 @@ def get_axis_orders(self) -> dict[Vector3d, int]: return {} return { Vector3d(a): b + 1 - for a, b in zip( - *np.unique(s.axis.data, axis=0, return_counts=True) - ) + for a, b in zip(*np.unique(s.axis.data, axis=0, return_counts=True)) } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: @@ -675,15 +669,9 @@ def plot( D4h.name = "4/mmm" # 3-fold rotations -C3x = Symmetry( - [(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)] -) -C3y = Symmetry( - [(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)] -) -C3z = Symmetry( - [(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))] -) +C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)]) +C3y = Symmetry([(1, 0, 0, 0), (0.5, 0, 0.75**0.5, 0), (0.5, 0, -(0.75**0.5), 0)]) +C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))]) C3 = Symmetry(C3z) C3.name = "3" From 18d90056882bd97d4644dad28cc2646f07f5a16d Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:26:52 -0600 Subject: [PATCH 24/40] add suggestions from #563 review --- .../plot_symmetry_operations.py | 14 ++- orix/plot/stereographic_plot.py | 108 ++++++++++-------- orix/tests/plot/test_stereographic_plot.py | 26 +++-- 3 files changed, 86 insertions(+), 62 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 7d4c28947..18b8f74a3 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -85,7 +85,7 @@ min_ang = np.abs(pg[r_mask * axis_mask].angle).min() f = np.around(2 * np.pi / min_ang).astype(int) c = colors[f] - ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) + ax[i].symmetry_marker((pg * axis), folds=f, s=s, color=c) decorated_axes.append(axis * 1) # if there is an inversion center, plot the appropriate symbol elif np.any(i_mask): @@ -97,9 +97,11 @@ f = np.around(2 * np.pi / min_ang).astype(int) c = colors[f] if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c, inner="dot") + ax[i].symmetry_marker( + (pg * axis), folds=f, s=s, color=c, modifier="inv" + ) else: - ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) + ax[i].symmetry_marker((pg * axis), folds=f, s=s, color=c) decorated_axes.append(axis * 1) # the other option (besides empty) is a rotoinversion @@ -108,7 +110,9 @@ f = np.around(2 * np.pi / min_ang).astype(int) c = colors[f] if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c, inner="half") + ax[i].symmetry_marker( + (pg * axis), folds=f, s=s, color=c, modifier="roto" + ) else: ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) decorated_axes.append(axis * 1) @@ -129,4 +133,4 @@ mask = np.abs(two_folds.dot_outer(dax_unique)).max(axis=1) < 0.99 new_two_folds = two_folds[mask] symm_two_folds = pg.outer(new_two_folds).flatten().unique() - ax[i].symmetry_marker(symm_two_folds, fold=2, s=s, color="g") + ax[i].symmetry_marker(symm_two_folds, folds=2, s=s, color="g") diff --git a/orix/plot/stereographic_plot.py b/orix/plot/stereographic_plot.py index d4da01138..03954e186 100644 --- a/orix/plot/stereographic_plot.py +++ b/orix/plot/stereographic_plot.py @@ -19,8 +19,8 @@ plotting :class:`~orix.vector.Vector3d`. """ -from copy import deepcopy -from typing import Any, List, Optional, Tuple, Union +from copy import copy, deepcopy +from typing import Any, List, Literal, Optional, Tuple, Union from matplotlib import rcParams import matplotlib.axes as maxes @@ -633,7 +633,7 @@ def stereographic_grid( self.collections[index_polar].remove() self._stereographic_grid = False - def symmetry_marker(self, v: Vector3d, fold: int, inner="fill", **kwargs): + def symmetry_marker(self, v: Vector3d, folds: int, modifier="none", **kwargs): """Plot symmetry marker(s). Parameters @@ -645,22 +645,25 @@ def symmetry_marker(self, v: Vector3d, fold: int, inner="fill", **kwargs): **kwargs Keyword arguments passed to :meth:`scatter`. """ - if fold not in [1, 2, 3, 4, 6]: - raise ValueError("Can only plot 1-, 2-, 3-, 4- or 6-fold elements.") - - marker = SymmetryMarker(v, size=kwargs.pop("s", 1), folds=fold, inner=inner) + marker = _SymmetryMarker( + v, size=kwargs.pop("s", 1), folds=folds, modifier=modifier + ) new_kwargs = dict(zorder=ZORDER["symmetry_marker"], clip_on=False) for k, v in new_kwargs.items(): kwargs.setdefault(k, v) for vec, marker, marker_size in marker: - if fold == 1: + if folds == 1: marker_size = marker_size / 2 - if inner != "fill": - bg_kwargs = deepcopy(kwargs) + # It is not (currently) possible to make two-tone markers using custom- + # defined Path objects in matplotlib. Instead, for inversion and + # rotoinversion markers, a background white dot is plotted first, whereas + # the top markers themselves have empty interiors. + if modifier != "none": + bg_kwargs = copy(kwargs) if "color" in bg_kwargs: - bg_kwargs.pop("color") + _ = bg_kwargs.pop("color") self.scatter(vec, color="w", s=marker_size * 0.5, **bg_kwargs) self.scatter(vec, marker=marker, s=marker_size, **kwargs) @@ -974,48 +977,55 @@ def _order_in_hemisphere(polar: np.ndarray, pole: int) -> Union[np.ndarray, None return order -class SymmetryMarker: - """ - Symmetry element markers to plot in stereographic representations of - crystallographic point groups. +class _SymmetryMarker: + """A class for creating Symmetry element markers. Intended for making + stereographic plots of the crystallographic point groups. Intended to be used indirectly in :func:`~orix.plot.StereographicPlot.symmetry_marker`. Parameters ---------- - v (Vector3d object) - Vector3d object used for placing marker in StereographicPlot - - size (int or float) - value passed to matplotlib to determine relative marker size - - folds (integer) - rotational symmetry (typically 1, 2, 3, 4, or 6) for the - symmetry marker to represent with it's shape (circle, elliplse, - triangle, square, or hex)' - - inner ('fill' or 'dot' or 'half') - The shape to put inside the symmetry marker to express additional - symmetry information 'fill' adds nothing, 'dot' adds the dot - traditionally used for inversion centers and mirrors, and 'half' adds - an empty polygon with half as many folds as the marker within the - marker, such as what is used for rotoinversions. + v + Vector(s) giving the positions of markers in a stereographic plot + size + Value(s) passed to matplotlib to determine relative marker size + folds + The rotational symmetry (typically 1, 2, 3, 4, or 6) that determines the + symmetry marker's shape(circle, elliplse, triangle, square, or hex)'. + modifier + Determines what alterations, if any, should be added to the marker. "none" or + None will add nothing. "roto" will add a white rotoinversion symbol inside + the marker, which for even-fold rotations is a polygon with half as many + corners, and for an odd-fold rotation is a white dot. "inv" will add + an inversion symbol, which is a white dot. The default is "none". """ def __init__( self, - v: Union[Vector3d, np.ndarray, list, tuple], + v: Vector3d | np.ndarray | list | tuple, size: int = 1, - folds=2, - inner="fill", + folds: Literal[1, 2, 3, 4, 6] = 2, + modifier: Literal["none", "roto", "inv"] = "none", ): - assert np.isin(folds, [1, 2, 3, 4, 6]) - assert np.isin(inner, ["fill", "dot", "half"]) + fold_opt = [1, 2, 3, 4, 6] + if folds not in fold_opt: + # NOTE: If anyone is interested insupoorting 5-fold, 7-fold,etc. rotations + # in the future, be aware you will also need to modify the affine rotation + # applied at the end of the self._marker property based on your axis + # choices, or the markers will not properly align. + raise ValueError( + f"Folds must be one of {', '.join(map(str, fold_opt))}, not {folds}" + ) + mod_opt = ["none", "roto", "inv"] + if modifier not in mod_opt: + raise ValueError( + f"Modifier must be one of {', '.join(map(str, mod_opt))}, not {modifier}" + ) self._vector = Vector3d(v) self._size = size self._folds = folds - self._inner_shape = inner + self._inner_shape = modifier @property def angle_deg(self) -> np.ndarray: @@ -1023,8 +1033,9 @@ def angle_deg(self) -> np.ndarray: return np.rad2deg(self._vector.azimuth) + 90 @property - def size(self) -> np.ndarray: - """Multiplicity of each symmetry marker.""" + def multiplicity(self) -> np.ndarray: + """Multiplicity of each symmetry marker, ie, how many symmetrically + equivalent markers will be plotted.""" return np.ones(self.n) * self._size @property @@ -1033,11 +1044,18 @@ def n(self) -> int: return self._vector.size def __iter__(self): - for v, marker, size in zip(self._vector, self._marker, self.size): + """Dunder function for iterating through multiple markers defined within a + single _SymmetryMarker Class. + + For example, if a _SymmetryMarker is created with 4 vertices, this allows for + iteration over those vertices in a 'for' loop.""" + for v, marker, size in zip(self._vector, self._marker, self.multiplicity): yield v, marker, size @property - def _marker(self): + def _marker(self) -> mpath.Path: + """Returns a matplotlib Path object that describes the symmetry marker's + shape""" azimuth = self._vector.azimuth # pre-define inner cirle (reused) inner_circle = mpath.Path.circle((0, 0), 0.5) @@ -1053,7 +1071,7 @@ def _marker(self): circle = mpath.Path.circle((0, 0), 0.75) vert = np.copy(circle.vertices) code = circle.codes - if self._inner_shape == "dot": + if self._inner_shape == "inv": verts = np.concatenate([vert, i_vert[::-1]]) codes = np.concatenate([code, i_code]) marker = mpath.Path(verts, codes) @@ -1066,12 +1084,12 @@ def _marker(self): else: # if it's not 2-fold, just use a default polygon marker = mpath.Path.unit_regular_polygon(self._folds) - if self._inner_shape == "dot": + if self._inner_shape == "inv": # add the inner circle verts = np.concatenate([marker.vertices, i_vert[::-1]]) codes = np.concatenate([marker.codes, i_code]) marker = mpath.Path(verts, codes) - elif self._inner_shape == "half": + elif self._inner_shape == "roto": # add an inner shape with half the folds vert = np.copy(marker.vertices) code = np.copy(marker.codes) diff --git a/orix/tests/plot/test_stereographic_plot.py b/orix/tests/plot/test_stereographic_plot.py index 8f672b002..cea15cfa3 100644 --- a/orix/tests/plot/test_stereographic_plot.py +++ b/orix/tests/plot/test_stereographic_plot.py @@ -23,7 +23,7 @@ import pytest from orix import plot -from orix.plot.stereographic_plot import SymmetryMarker +from orix.plot.stereographic_plot import _SymmetryMarker from orix.quaternion import symmetry from orix.vector import Vector3d @@ -289,14 +289,19 @@ def test_size_parameter(self): class TestSymmetryMarker: @pytest.mark.parametrize("v_data", [[0, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]) @pytest.mark.parametrize("folds", [1, 2, 3, 4, 6]) - @pytest.mark.parametrize("fill", ["fill", "dot", "half"]) - def test_main_properties(self, v_data, folds, fill): + @pytest.mark.parametrize("modifier", ["none", "roto", "inv"]) + def test_main_properties(self, v_data, folds, modifier): v = Vector3d(v_data) - marker = SymmetryMarker(v, folds=folds, inner=fill) + marker = _SymmetryMarker(v, folds=folds, modifier=modifier) assert np.allclose(v.data, marker._vector.data) assert marker._folds == folds - assert marker._inner_shape == fill + assert marker._inner_shape == modifier assert (marker.angle_deg - (np.rad2deg(v.azimuth) + 90)) ** 2 < 1e-4 + # check errors + with pytest.raises(ValueError, match="Folds must"): + _SymmetryMarker([0, 0, 1], folds=5) + with pytest.raises(ValueError, match="Modifier must"): + _SymmetryMarker([0, 0, 1], modifier="banana") def test_plot_symmetry_marker(self): _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) @@ -305,18 +310,15 @@ def test_plot_symmetry_marker(self): v = Vector3d([[1, 0, 0], [0, 1, 1]]) for i in [1, 2, 3, 4, 6]: - ax.symmetry_marker(v[0], fold=i, s=marker_size, color="k") - ax.symmetry_marker(v[1], fold=i, inner="dot", s=marker_size) - ax.symmetry_marker(v, fold=1, inner="dot", color="C1", s=marker_size) + ax.symmetry_marker(v[0], folds=i, s=marker_size, color="k") + ax.symmetry_marker(v[1], folds=i, modifier="inv", s=marker_size) + ax.symmetry_marker(v, folds=1, modifier="inv", color="C1", s=marker_size) for i in [4, 6]: - ax.symmetry_marker(v, fold=i, inner="half", s=marker_size) + ax.symmetry_marker(v, folds=i, modifier="roto", s=marker_size) markers = ax.collections assert len(markers) == 43 - with pytest.raises(ValueError, match="Can only plot 1"): - ax.symmetry_marker([0, 0, 1], fold=5) - plt.close("all") From ec32242c31dae2477f72d81d5468b9ed3b685c9d Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 3 Jul 2025 18:30:16 -0600 Subject: [PATCH 25/40] moving plot_symmetry_operations algo to Symmetry function --- .../plot_symmetry_operations.py | 2 +- orix/quaternion/symmetry.py | 73 ++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 18b8f74a3..789c1af1a 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -56,7 +56,7 @@ # create masks to sort out which elements are rotations, mirrors, # inversions, and rotoinversions p_mask = ~pg.improper - m_mask = (np.abs(pg.angle - np.pi) < 1e-4) * pg.improper + m_mask = (np.abs(pg.angle) - np.pi < 1e-4) * pg.improper r_mask = pg.angle**2 > 1e-4 roto_mask = r_mask * ~p_mask * ~m_mask i_mask = (~r_mask) * pg.improper diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 9a9221d6a..7374c571f 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Union +from copy import copy from diffpy.structure.spacegroups import GetSpaceGroup import matplotlib.figure as mfigure import numpy as np @@ -417,6 +418,76 @@ def from_generators(cls, *generators: Rotation) -> Symmetry: # --------------------- Other public methods --------------------- # + def get_symmetry_markers(self) -> list: + mirror_list = [] + marker_list = [] + has_inversion = False + # create masks to sort out which elements are what. + # proper elements + p_mask = ~self.improper + # mirror planes + m_mask = (np.abs(self.angle - np.pi) < 1e-4) * self.improper + # rotations (both proper and improper) + r_mask = np.abs(self.angle) > 1e-4 + # rotoinversions + roto_mask = r_mask * ~p_mask * ~m_mask + # the inversion symmetry + i_mask = (~r_mask) * self.improper + + # Find the unique axis familes. For the standard crystallographic point + # groups, this will be <100>, <110>, and/or <111>. + axis_families = (self.axis.in_fundamental_sector(self)).unique() + # to avoid repetition, look at only the unique fundamental representations + # of the possible symmetry elements (ie, no need to look at [111] and [11-1]) + fs_axes = self.axis.in_fundamental_sector(self) + # change the axis of the identity and (if present) inversion elements to + # [0,0,0], as neither have markers (inversion only modifies other markers) + fs_axes[p_mask * ~r_mask] = np.zeros(3) + if np.sum(i_mask) > 0: + has_inversion = True + fs_axes[i_mask] = np.zeros(3) + + # iterate through each primary axis and find what elements to add + for axis in axis_families: + axis_mask = np.sum(np.abs((fs_axes - axis).data), 1) < 1e-4 + # check for mirrors + if np.any(m_mask * axis_mask): + mirror_list.append(copy(axis)) + # if there are no rotations, continue to the next axis. + if np.sum(axis_mask * r_mask) == 0: + continue + # check to see if there are only proper rotations + if np.all(p_mask[axis_mask]): + min_ang = np.abs(self[axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + marker_list.append([copy(axis), f, "none"]) + # If there is also an inversion, append the appropriate symbol + elif has_inversion: + min_ang = np.abs(self[r_mask * axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + marker_list.append([copy(axis), f, "inv"]) + # The only othero option is improper rotations and no inversion, which + # is a rotoinversion. + else: + min_ang = np.abs(self[roto_mask * axis_mask].angle).min() + f = np.around(2 * np.pi / min_ang).astype(int) + + # Three-fold rotations around the 111 create <011> mirror planes, which for + # ease we will add in by hand. + if np.any(axis_families.dot(Vector3d([1, 1, 1])) > 1.73): + mirror_list.append(Vector3d([0, 1, 1])) + + # Finally, the combination of inversion center and mirror planes + # creates 2-fold symmetries not on the primary axes. Let's add in any + # that didn't already get included from other operations + if np.sum(m_mask) > 1 and has_inversion: + current_markers = Vector3d([x[0].data for x in marker_list]) + two_folds = self[m_mask].in_fundamental_sector(self) + mask = np.abs(two_folds.dot_outer(current_markers)).max(axis=1) < 0.99 + new_two_folds = two_folds[mask] + for ntf in new_two_folds: + marker_list.append([copy(ntf), 2]) + def get_axis_orders(self) -> dict[Vector3d, int]: s = self[self.angle > 0] if s.size == 0: @@ -544,7 +615,7 @@ def plot( # NOTE: ORIX uses Schoenflies symbols to define point groups. This is partly # because the notation is short and always starts with a letter (ie, they # make convenient python variables), and partly because it helps limit -# accidental misinterpretation of of Herman-Mauguin symbols as space group +# accidental misinterpretation of Herman-Mauguin symbols as space group # numbers. For example. "222" could be interpreted as SG#222 == Pn-3n, or # as PG'222'== D3. there are similar examples with 2, 3, 4, 32, etc. From 9478f333d9af3a232427565d8e0b3c2a3792ed9b Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 3 Jul 2025 18:31:16 -0600 Subject: [PATCH 26/40] formatting --- orix/quaternion/symmetry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 7374c571f..fe9004818 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -19,9 +19,9 @@ from __future__ import annotations +from copy import copy from typing import TYPE_CHECKING, Union -from copy import copy from diffpy.structure.spacegroups import GetSpaceGroup import matplotlib.figure as mfigure import numpy as np From e71059ede867748b294348ff976204aeb1f59fa1 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Tue, 8 Jul 2025 20:26:10 -0600 Subject: [PATCH 27/40] create PointGroups class, improve Symmetry.plot, and fix example --- .../plot_symmetry_operations.py | 137 +-- orix/crystal_map/phase_list.py | 14 +- orix/plot/stereographic_plot.py | 24 +- orix/quaternion/symmetry.py | 819 +++++++++++------- 4 files changed, 539 insertions(+), 455 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 789c1af1a..a27442033 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -3,134 +3,33 @@ Plot symmetry operations ======================== -This example is one method for producing stereographic projections with -symmetry operators for the 32 crystallographic point groups. This method -loosely follows the one laid out in section 9.2 of "Structures of Materials" -(DeGraef et.al, 2nd edition, 2012). - -There are generated proceedurally, and vary slightly from some other -approaches. Consider, for example, more curated -approaches. Consider, for example, the plot for the D2h == "mmm" point group, -which displays the inversion center as a dot in the center 2-fold marker, -whereas Figure 9.9 of "Structure of Materials" leaves these markers out. -Additionally, like the International tables of crystallography (ITOC) Table -10.2.2, which is the generally accepted standard for stereographic projections, -the inversion symmetry dots have been removed from markers along the -equator. +This example shows how stereographic projections with symmetry operators can be +automatically generated using orix for the 32 crystallographic point groups. + +The ordering follows the one given in section 9.2 of "Structures of Materials" +(DeGraef et.al, 2nd edition, 2012), starting with the cyclic groups, then the +dihedral groups, then those same groups plus inversion centers, then the successive +application of mirror planes and secondary rotational symmetries. + +The plots themselves as well as their labels follow the standards given in +Table 10.2.2 of the "International Tables of Crystallography, Volume A" (ITOC). +Both the nomenclature and marker styles thus differ slightly from some textbooks, as +there are some arbitrary convention choices in both Schoenflies notation and marker +styles. """ import matplotlib.pyplot as plt -import numpy as np - -from orix import plot -from orix.quaternion.symmetry import * -from orix.vector import Vector3d - -# generate a list of the 32 crystallographic point groups. -# NOTE: This could instead be done with "get_point_groups()", but the -# following list shows a more logical addition of symmetry operators. -point_groups = get_point_groups("procedural") - - -# Set marker sizes and colors to help differentiate elements -s = 160 -colors = {1: "magenta", 2: "green", 3: "red", 4: "purple", 6: "black"} -mirror_linewidth = 1 -mirror_color = "blue" +import orix.plot +from orix.quaternion.symmetry import PointGroups +# create a list of the 32 crystallographic point groups +point_groups = PointGroups.get_set("procedural") -# Create the plot and subplots using ORIX's stereographic projection fig, ax = plt.subplots( 4, 8, subplot_kw={"projection": "stereographic"}, figsize=[14, 10] ) ax = ax.flatten() - # Iterate through the 32 Point groups for i, pg in enumerate(point_groups): - ax[i].set_title(pg.name) - # get unique axis families (should just be <100>, <110>, and/or <111>) - unique_axes = Vector3d( - np.unique(np.around(pg.axis.in_fundamental_sector(pg).data, 5), axis=0) - ) - # create masks to sort out which elements are rotations, mirrors, - # inversions, and rotoinversions - p_mask = ~pg.improper - m_mask = (np.abs(pg.angle) - np.pi < 1e-4) * pg.improper - r_mask = pg.angle**2 > 1e-4 - roto_mask = r_mask * ~p_mask * ~m_mask - i_mask = (~r_mask) * pg.improper - # to avoid repetition, look at only the unique fundamental representations - # of the possible rotation axes - fs_axes = pg.axis.in_fundamental_sector(pg) - decorated_axes = [] - - # iterate through each primary axis, plotting their symmetry elements - # as we go. - for axis in unique_axes: - axis_mask = np.sum(np.abs((fs_axes - axis).data), 1) < 1e-4 - # plot any mirror planes perpendicular to the axis first - if np.any(m_mask * axis_mask): - for v in pg * axis: - ax[i].plot( - v.get_circle(), - color=mirror_color, - linewidth=mirror_linewidth, - ) - # if all rotations are proper rotations, plot the appropriate symbol - if np.all(p_mask[axis_mask]): - # if the only element is identity, move on. - if not np.any(r_mask * axis_mask): - continue - min_ang = np.abs(pg[r_mask * axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - c = colors[f] - ax[i].symmetry_marker((pg * axis), folds=f, s=s, color=c) - decorated_axes.append(axis * 1) - # if there is an inversion center, plot the appropriate symbol - elif np.any(i_mask): - # this might just be the 1-fold inversion center - if not np.any(r_mask * p_mask): - f = 1 - else: - min_ang = np.abs(pg[r_mask * axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - c = colors[f] - if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker( - (pg * axis), folds=f, s=s, color=c, modifier="inv" - ) - else: - ax[i].symmetry_marker((pg * axis), folds=f, s=s, color=c) - - decorated_axes.append(axis * 1) - # the other option (besides empty) is a rotoinversion - elif np.any(roto_mask[axis_mask]): - min_ang = np.abs(pg[roto_mask * axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - c = colors[f] - if axis.z[0] ** 2 > 1e-4: - ax[i].symmetry_marker( - (pg * axis), folds=f, s=s, color=c, modifier="roto" - ) - else: - ax[i].symmetry_marker((pg * axis), fold=f, s=s, color=c) - decorated_axes.append(axis * 1) - # Three-fold rotations around the 111 create a special subset of mirror - # planes, which for ease we will add in by hand. - if np.any(unique_axes.dot(Vector3d([1, 1, 1])) > 1.73): - m_vectors = Vector3d([[0, 0, 1], [0, 1, 1]]) - for v in (pg.outer(m_vectors)).flatten().unique(): - ax[i].plot(v.get_circle(), color="blue", linewidth=1) - - # Finally, the combination of inversion center and mirror planes - # creates 2-fold symmetries not on the primary axes. Let's add in any - # that didn't already get included from other operations - if np.sum(m_mask) > 1 and np.sum(i_mask) > 0: - dax = Vector3d([x.data for x in decorated_axes]) - dax_unique = dax.flatten().unique() - two_folds = pg.axis[m_mask].in_fundamental_sector(pg) - mask = np.abs(two_folds.dot_outer(dax_unique)).max(axis=1) < 0.99 - new_two_folds = two_folds[mask] - symm_two_folds = pg.outer(new_two_folds).flatten().unique() - ax[i].symmetry_marker(symm_two_folds, folds=2, s=s, color="g") + pg.plot_elements(plt_axis=ax[i], itoc_style=True) diff --git a/orix/crystal_map/phase_list.py b/orix/crystal_map/phase_list.py index 71f13d724..9f3179796 100644 --- a/orix/crystal_map/phase_list.py +++ b/orix/crystal_map/phase_list.py @@ -33,14 +33,8 @@ import matplotlib.colors as mcolors import numpy as np -from orix.quaternion.symmetry import ( - _EDAX_POINT_GROUP_ALIASES, - Symmetry, - get_point_group, - get_point_groups, -) -from orix.vector.miller import Miller -from orix.vector.vector3d import Vector3d +from orix.quaternion.symmetry import _EDAX_POINT_GROUP_ALIASES, Symmetry, PointGroups +from orix.vector import Miller, Vector3d # All named Matplotlib colors (tableau and xkcd already lower case hex) ALL_COLORS = mcolors.TABLEAU_COLORS @@ -235,14 +229,14 @@ def point_group(self) -> Symmetry | None: Point group. """ if self.space_group is not None: - return get_point_group(self.space_group.number) + return PointGroups.from_space_group(self.space_group.number) else: return self._point_group @point_group.setter def point_group(self, value: int | str | Symmetry | None) -> None: """Set the point group.""" - groups = get_point_groups("all_repeated") + groups = PointGroups._pg_sets["all_repeated"] if isinstance(value, int): value = str(value) if isinstance(value, str): diff --git a/orix/plot/stereographic_plot.py b/orix/plot/stereographic_plot.py index 03954e186..3926d07ef 100644 --- a/orix/plot/stereographic_plot.py +++ b/orix/plot/stereographic_plot.py @@ -664,7 +664,7 @@ def symmetry_marker(self, v: Vector3d, folds: int, modifier="none", **kwargs): bg_kwargs = copy(kwargs) if "color" in bg_kwargs: _ = bg_kwargs.pop("color") - self.scatter(vec, color="w", s=marker_size * 0.5, **bg_kwargs) + self.scatter(vec, color="w", s=marker_size * 0.2, **bg_kwargs) self.scatter(vec, marker=marker, s=marker_size, **kwargs) # TODO: Find a way to control padding, so that markers aren't @@ -995,10 +995,10 @@ class _SymmetryMarker: symmetry marker's shape(circle, elliplse, triangle, square, or hex)'. modifier Determines what alterations, if any, should be added to the marker. "none" or - None will add nothing. "roto" will add a white rotoinversion symbol inside - the marker, which for even-fold rotations is a polygon with half as many - corners, and for an odd-fold rotation is a white dot. "inv" will add - an inversion symbol, which is a white dot. The default is "none". + None will add nothing. "rotoinversion" will add a white rotoinversion symbol + inside the marker, which for even-fold rotations is a polygon with half as + many corners, and for an odd-fold rotation is a white dot. "inversion" will + add an inversion symbol, which is a white dot. The default is "none". """ def __init__( @@ -1006,7 +1006,9 @@ def __init__( v: Vector3d | np.ndarray | list | tuple, size: int = 1, folds: Literal[1, 2, 3, 4, 6] = 2, - modifier: Literal["none", "roto", "inv"] = "none", + modifier: Literal[ + None, "none", "rotation", "rotoinversion", "inversion" + ] = "none", ): fold_opt = [1, 2, 3, 4, 6] if folds not in fold_opt: @@ -1017,7 +1019,7 @@ def __init__( raise ValueError( f"Folds must be one of {', '.join(map(str, fold_opt))}, not {folds}" ) - mod_opt = ["none", "roto", "inv"] + mod_opt = [None, "none", "rotation", "rotoinversion", "inversion"] if modifier not in mod_opt: raise ValueError( f"Modifier must be one of {', '.join(map(str, mod_opt))}, not {modifier}" @@ -1067,11 +1069,11 @@ def _marker(self) -> mpath.Path: e_vert[:, 1] = e_vert[:, 1] * ((1 - e_vert[:, 0] ** 2) ** 0.5) * 0.35 if self._folds == 1: # return either a normal circle, or a cirle with a dot in the - # center if this also anie, inversion center + # center if this also an inversion center circle = mpath.Path.circle((0, 0), 0.75) vert = np.copy(circle.vertices) code = circle.codes - if self._inner_shape == "inv": + if self._inner_shape == "inversion": verts = np.concatenate([vert, i_vert[::-1]]) codes = np.concatenate([code, i_code]) marker = mpath.Path(verts, codes) @@ -1084,12 +1086,12 @@ def _marker(self) -> mpath.Path: else: # if it's not 2-fold, just use a default polygon marker = mpath.Path.unit_regular_polygon(self._folds) - if self._inner_shape == "inv": + if self._inner_shape == "inversion": # add the inner circle verts = np.concatenate([marker.vertices, i_vert[::-1]]) codes = np.concatenate([marker.codes, i_code]) marker = mpath.Path(verts, codes) - elif self._inner_shape == "roto": + elif self._inner_shape == "rotoinversion": # add an inner shape with half the folds vert = np.copy(marker.vertices) code = np.copy(marker.codes) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index fe9004818..be593a43f 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -20,10 +20,10 @@ from __future__ import annotations from copy import copy -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Literal, Union from diffpy.structure.spacegroups import GetSpaceGroup -import matplotlib.figure as mfigure +import matplotlib.pyplot as plt import numpy as np from orix._util import deprecated @@ -60,6 +60,7 @@ class Symmetry(Rotation): """ name = "" + _s_name = "" # -------------------------- Properties -------------------------- # @@ -76,7 +77,7 @@ def is_proper(self) -> bool: @property def subgroups(self) -> list[Symmetry]: """Return the list groups that are subgroups of this group.""" - groups = get_point_groups("all") + groups = PointGroups._pg_sets["all"] return [g for g in groups if g._tuples <= self._tuples] @property @@ -418,75 +419,130 @@ def from_generators(cls, *generators: Rotation) -> Symmetry: # --------------------- Other public methods --------------------- # - def get_symmetry_markers(self) -> list: - mirror_list = [] - marker_list = [] - has_inversion = False + def _get_symmetry_elements(self) -> list: + """ + Returns all the crystallographically unique axes and their associated + symmetry elements (mirrors, rotations, rotoinversions, etc). + + Returns + ------- + axes Vector3d + Vector(s) that are each either parallel to an axis of rotation or + perpendicular to a mirror plane, or both. + is_mirror bool + Indicates whether each primary_axis is or is not perpendicular to a + mirror plane + s_type str ("inversion", "rotoinversion", "rotation", or"none") + Indicates whether the rotational symmetry associated with the axis + contains an inversion, a rotoinversion, purely rotational, or just an + 1-fold axis perpendicular to a mirror. + folds + Indicats the order of the rotational symmetry (1,2,3,4, or 6) + + Notes + ------- + This function does not return ALL the axes and angles, + (that function would be `Symmetry.to_axes_angles`), nor does it return + the minimum generating elements. Instead, it returns all the primary axes + plus information about the rotations, inversions, and/or mirrors associated + with each. + """ # create masks to sort out which elements are what. # proper elements p_mask = ~self.improper # mirror planes - m_mask = (np.abs(self.angle - np.pi) < 1e-4) * self.improper + m_mask = (np.abs(np.abs(self.angle) - np.pi) < 1e-4) * self.improper # rotations (both proper and improper) r_mask = np.abs(self.angle) > 1e-4 # rotoinversions roto_mask = r_mask * ~p_mask * ~m_mask # the inversion symmetry i_mask = (~r_mask) * self.improper - - # Find the unique axis familes. For the standard crystallographic point - # groups, this will be <100>, <110>, and/or <111>. - axis_families = (self.axis.in_fundamental_sector(self)).unique() - # to avoid repetition, look at only the unique fundamental representations - # of the possible symmetry elements (ie, no need to look at [111] and [11-1]) - fs_axes = self.axis.in_fundamental_sector(self) - # change the axis of the identity and (if present) inversion elements to - # [0,0,0], as neither have markers (inversion only modifies other markers) - fs_axes[p_mask * ~r_mask] = np.zeros(3) if np.sum(i_mask) > 0: has_inversion = True - fs_axes[i_mask] = np.zeros(3) + else: + has_inversion = False + + elements = [] + # Find the unique axis familes. + axis_families = (self.axis.in_fundamental_sector(self)).unique() # iterate through each primary axis and find what elements to add for axis in axis_families: - axis_mask = np.sum(np.abs((fs_axes - axis).data), 1) < 1e-4 + # mask out just axes elements in the fundamental sector to avoid repeats + axis_mask = ( + np.sum(np.abs((self.axis.in_fundamental_sector(self) - axis).data), 1) + < 1e-4 + ) + m_flag = False + folds = 0 + s_type = "empty" # check for mirrors if np.any(m_mask * axis_mask): - mirror_list.append(copy(axis)) - # if there are no rotations, continue to the next axis. - if np.sum(axis_mask * r_mask) == 0: - continue + m_flag = True # check to see if there are only proper rotations if np.all(p_mask[axis_mask]): - min_ang = np.abs(self[axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - marker_list.append([copy(axis), f, "none"]) - # If there is also an inversion, append the appropriate symbol + # This might just be the identity. + if not np.any(r_mask * axis_mask): + elements.append( + (copy(axis), m_flag, 1, "none"), + ) + continue + min_ang = np.abs(self[r_mask * axis_mask].angle).min() + folds = np.around(2 * np.pi / min_ang).astype(int) + elements.append( + (copy(axis), m_flag, folds, "rotation"), + ) + continue + # Check if there is a rotation with an inversion elif has_inversion: + # this might just be the 1-fold inversion center + if not np.any(r_mask * axis_mask): + elements.append( + (copy(axis), m_flag, 1, "inversion"), + ) + continue min_ang = np.abs(self[r_mask * axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - marker_list.append([copy(axis), f, "inv"]) - # The only othero option is improper rotations and no inversion, which - # is a rotoinversion. - else: + folds = np.around(2 * np.pi / min_ang).astype(int) + elements.append( + (copy(axis), m_flag, folds, "inversion"), + ) + continue + # the only other important option is a rotoinversion + elif np.any(roto_mask[axis_mask]): min_ang = np.abs(self[roto_mask * axis_mask].angle).min() - f = np.around(2 * np.pi / min_ang).astype(int) - - # Three-fold rotations around the 111 create <011> mirror planes, which for - # ease we will add in by hand. - if np.any(axis_families.dot(Vector3d([1, 1, 1])) > 1.73): - mirror_list.append(Vector3d([0, 1, 1])) - - # Finally, the combination of inversion center and mirror planes - # creates 2-fold symmetries not on the primary axes. Let's add in any - # that didn't already get included from other operations - if np.sum(m_mask) > 1 and has_inversion: - current_markers = Vector3d([x[0].data for x in marker_list]) - two_folds = self[m_mask].in_fundamental_sector(self) - mask = np.abs(two_folds.dot_outer(current_markers)).max(axis=1) < 0.99 - new_two_folds = two_folds[mask] - for ntf in new_two_folds: - marker_list.append([copy(ntf), 2]) + folds = np.around(2 * np.pi / min_ang).astype(int) + elements.append( + (copy(axis), m_flag, folds, "rotoinversion"), + ) + continue + # if it it not a rotational symmetry of any type, it's a mirror + else: + elements.append( + (copy(axis), m_flag, 1, "none"), + ) + # Finally, 3-fold rotations around the 111 create <110> mirrors, and + # inversion combined with mirror planes can create 2-fold symmetries + # not on the primary axis. These we can add by hand if they are missing. + if np.any(np.abs(Vector3d([1, 1, 1]).angle_with(self.axis)) < 1e-4): + catch = False + v = Vector3d([0, 1, 1]).in_fundamental_sector(self) + for i, e in enumerate(elements): + if np.abs(v.data - e[0].data).sum() < 1e-4: + elements[i][1] = True + catch = True + break + if catch is False: + elements.append( + (v, True, 1, "none"), + ) + + # split the list of lists into 4 variables. + axes = [x[0] for x in elements] + is_mirror = [x[1] for x in elements] + s_type = [x[3] for x in elements] + folds = [x[2] for x in elements] + return axes, is_mirror, s_type, folds def get_axis_orders(self) -> dict[Vector3d, int]: s = self[self.angle > 0] @@ -541,12 +597,88 @@ def fundamental_zone(self) -> Vector3d: sr = SphericalRegion(n.unique()) return sr + def plot_elements( + self, + color_dict: dict = {}, + symmetry_color: str | None = None, + mirror_color: str | None = None, + s: float = 160, + mirror_lw: float = 1, + plt_axis: plt.Axes | None = None, + return_figure: bool = False, + itoc_style: bool = True, + show_name: bool = True, + ): + # import orix.plot so matplotlib knows what the stereographic projection is. + import orix.plot + + # dictionary of default colors + colors = { + 1: "black", + 2: "green", + 3: "red", + 4: "purple", + 6: "magenta", + "m": "blue", + } + # if a symmetry or mirror color was passed in, reset default values. + if symmetry_color is not None: + for i in [1, 2, 3, 4, 6]: + colors[i] = symmetry_color + if mirror_color is not None: + colors["m"] = mirror_color + # after resetting defaults, update color choices passed in via color_dict + colors.update(color_dict) + # if the user did not pass in an axis, generate one + if plt_axis is None: + fig, plt_axis = plt.subplots(subplot_kw={"projection": "stereographic"}) + else: + fig = plt_axis.get_figure() + + # add titles and labels if requested + if show_name: + plt_axis.set_title(self._s_name + " | " + self.name) + + # determine the symnmetry elements and plot them. + elements = self._get_symmetry_elements() + for v, m, t, f in zip(*elements): + # plot each symmetrically equivalent mirror plane only once + if m: + for mv in (self * v).unique(): + m_circ = mv.get_circle() + plt_axis.plot(m_circ, color=colors["m"], linewidth=mirror_lw) + # plot each symmetrically equivalent rotation element only once + c = colors[f] + if f > 1: + for sv in (self * v).unique(): + # ITOC doesn't plot inversion or rotoinversion markers for + # symmetry elements with axes perpendicular to the out-of plane + # direction, as the information is redundant. + z_ang = np.abs(sv.angle_with(Vector3d.zvector())) + is_perp = np.abs(z_ang - (np.pi / 2)) < 1e-4 + if itoc_style and is_perp: + plt_axis.symmetry_marker( + sv, folds=f, s=s, color=c, modifier=None + ) + else: + plt_axis.symmetry_marker(sv, folds=f, s=s, color=c, modifier=t) + # if this is the primary axis and there is no rotation but an inversion + # (ie, this is symmetry.Ci, the `-1` PG), add the appropriate marker. + elif f == 1 and np.abs(v.angle_with(Vector3d.zvector())) < 1e-4: + if t != "inversion": + continue + for sv in (self * v).unique(): + plt_axis.symmetry_marker(sv, folds=f, s=s, color=c, modifier=t) + # return the figure if requested + if return_figure: + return fig + def plot( self, orientation: "Orientation | None" = None, reproject_scatter_kwargs: dict | None = None, **kwargs, - ) -> mfigure.Figure | None: + ) -> plt.Figure | None: """Stereographic projection of symmetry operations. The upper hemisphere of the stereographic projection is shown. @@ -641,13 +773,16 @@ def plot( # Triclinic C1 = Symmetry((1, 0, 0, 0)) C1.name = "1" +C1._s_name = "C1" Ci = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) Ci.improper = [0, 1] Ci.name = "-1" +Ci._s_name = "Ci" # include redundant point group S2 == Ci S2 = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) S2.improper = [0, 1] S2.name = "-1" +S2._s_name = "S2" # Special generators _mirror_xy = Symmetry([(1, 0, 0, 0), (0, 0.75**0.5, -(0.75**0.5), 0)]) @@ -657,40 +792,53 @@ def plot( # 2-fold rotations C2x = Symmetry([(1, 0, 0, 0), (0, 1, 0, 0)]) C2x.name = "211" +C2x._s_name = "C2x" C2y = Symmetry([(1, 0, 0, 0), (0, 0, 1, 0)]) C2y.name = "121" +C2y._s_name = "C2y" C2z = Symmetry([(1, 0, 0, 0), (0, 0, 0, 1)]) C2z.name = "112" +C2z._s_name = "C2z" C2 = Symmetry(C2z) C2.name = "2" +C2._s_name = "C2" # included redundant point group D1 == C2 D1 = Symmetry(C2z) D1.name = "2" +D1._s_name = "D1" # Mirrors Csx = Symmetry([(1, 0, 0, 0), (0, 1, 0, 0)]) Csx.improper = [0, 1] Csx.name = "m11" +Csx._s_name = "Csx" Csy = Symmetry([(1, 0, 0, 0), (0, 0, 1, 0)]) Csy.improper = [0, 1] Csy.name = "1m1" +Csy._s_name = "Csy" Csz = Symmetry([(1, 0, 0, 0), (0, 0, 0, 1)]) Csz.improper = [0, 1] Csz.name = "11m" +Csz._s_name = "Csz" Cs = Symmetry(Csz) Cs.name = "m" +Cs._s_name = "Cs" # Monoclinic C2h = Symmetry.from_generators(C2, Cs) C2h.name = "2/m" +C2h._s_name = "C2h" # Orthorhombic D2 = Symmetry.from_generators(C2z, C2x, C2y) D2.name = "222" +D2._s_name = "D2" C2v = Symmetry.from_generators(C2z, Csx) C2v.name = "mm2" +C2v._s_name = "C2v" D2h = Symmetry.from_generators(Csz, Csx, Csy) D2h.name = "mmm" +D2h._s_name = "D2h" # 4-fold rotations C4x = Symmetry( @@ -719,25 +867,33 @@ def plot( ) C4 = Symmetry(C4z) C4.name = "4" +C4._s_name = "C4" # Tetragonal S4 = Symmetry(C4) S4.improper = [0, 1, 0, 1] S4.name = "-4" +S4._s_name = "S4" # include redundant point group C4i == S4 C4i = Symmetry(C4) C4i.improper = [0, 1, 0, 1] C4i.name = "-4" +C4i._s_name = "C4i" C4h = Symmetry.from_generators(C4, Cs) C4h.name = "4/m" +C4h._s_name = "C4h" D4 = Symmetry.from_generators(C4, C2x, C2y) D4.name = "422" +D4._s_name = "D4" C4v = Symmetry.from_generators(C4, Csx) C4v.name = "4mm" +C4v._s_name = "C4v" D2d = Symmetry.from_generators(D2, _mirror_xy) D2d.name = "-42m" +D2d._s_name = "D2d" D4h = Symmetry.from_generators(C4h, Csx, Csy) D4h.name = "4/mmm" +D4h._s_name = "D4h" # 3-fold rotations C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)]) @@ -745,188 +901,77 @@ def plot( C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))]) C3 = Symmetry(C3z) C3.name = "3" +C3._s_name = "C3" # Trigonal C3i = Symmetry.from_generators(C3, Ci) C3i.name = "-3" +C3i._s_name = "C3i" # include redundant point group S6==C3i S6 = Symmetry.from_generators(C3, Ci) S6.name = "-3" +S6._s_name = "S6" D3x = Symmetry.from_generators(C3, C2x) D3x.name = "321" +D3x._s_name = "D3x" D3y = Symmetry.from_generators(C3, C2y) D3y.name = "312" +D3y._s_name = "D3y" D3 = Symmetry(D3x) D3.name = "32" +D3._s_name = "D3" C3v = Symmetry.from_generators(C3, Csx) C3v.name = "3m" +C3v._s_name = "C3v" D3d = Symmetry.from_generators(S6, Csx) D3d.name = "-3m" +D3d._s_name = "D3d" # Hexagonal C6 = Symmetry.from_generators(C3, C2) C6.name = "6" +C6._s_name = "C6" C3h = Symmetry.from_generators(C3, Cs) C3h.name = "-6" +C3h._s_name = "C3h" C6h = Symmetry.from_generators(C6, Cs) C6h.name = "6/m" +C6h._s_name = "C6h" D6 = Symmetry.from_generators(C6, C2x, C2y) D6.name = "622" +D6._s_name = "D6" C6v = Symmetry.from_generators(C6, Csx) C6v.name = "6mm" +C6v._s_name = "C6v" D3h = Symmetry.from_generators(C3, C2y, Csz) D3h.name = "-6m2" +D3h._s_name = "-D3h" D6h = Symmetry.from_generators(D6, Csz) D6h.name = "6/mmm" +D6h._s_name = "D6h" # Cubic T = Symmetry.from_generators(C2, _cubic) T.name = "23" +T._s_name = "T" Th = Symmetry.from_generators(T, Ci) Th.name = "m-3" +Th._s_name = "Th" O = Symmetry.from_generators(C4, _cubic, C2x) O.name = "432" +O._s_name = "O" Td = Symmetry.from_generators(T, _mirror_xy) Td.name = "-43m" +Td._s_name = "Td" Oh = Symmetry.from_generators(O, Ci) Oh.name = "m-3m" +Oh._s_name = "Oh" -def get_point_groups(subset: str = "unique"): - """ - returns different subsets of the 32 crystallographic point groups. By - default, this returns all 32 in the order they appear in the - International Tables of Crystallography (ITOC). - - Parameters - ---------- - subset : str, optional - the point group list to return. The options are as follows: - "unique" (default): - All 32 point groups in the order they appear in space groups. - Thus, they are grouped by crystal system and laue class - "all": - All 32 points groups, plus common axis-specific permutations - for non-centrosymmetric groups (ie, C2 plus C2x and C2y) for - a total of 37 point group projections. These - are given in the same order as ITOC Table 10.2.2 - "all_repeated": - The 37 point group projections, plus the redundant Schonflies - and Herman-Mauguin group names. For example, both Ci and S2 - are included, as well as D3 =="32" and D3x == "321". NOTE: - this means several of the entries symmetrically identical. - "proper": - The 11 proper point groups given in the same order as ITOC - table 10.2.2. - same order as "unique", which in turn aligns with Table 3.1 - of ITOC - "proper_all": - The 11 proper point groups, plus axis-specific permutations. - "laue": - The point groups corresponding to the 11 Laue groups, using - the same ordering and definitions as Table 3.1 of ITOC. These - are equivalent to adding an inversion symmetry to each op - the 11 proper point groups - "procedural": - The 32 point groups, but presented in the procedural ordering - described in "Structure of Materials" and other books, where - point groups are created from successive applications of - symmetry elements to the Cyclic (C_n) and Dihedral (D_n) - groups. - - Returns - ------- - point groups: [Symmetry,] - a list of point group symmetries - - Notes - ------- - For convenience, the following table shows the 38 point groups contained - in "all", of which the other lists are subsets of. - - - +-------------+--------------+---------------+------------+------------+ - | Schoenflies | System | HM | Laue Class | Proper PG | - +=============+==============+===============+============+============+ - | C1 | Triclinic | 1 | -1 | 1 | - +-------------+--------------+---------------+------------+------------+ - | Ci | Triclinic | -1 | -1 | 1 | - +-------------+--------------+---------------+------------+------------+ - | C2 | Monoclinic | 2 or 112 | 2/m | 112 | - +-------------+--------------+---------------+------------+------------+ - | C2x | Monoclinic | 211 | 2/m | 211 | - +-------------+--------------+---------------+------------+------------+ - | C2y | Monoclinic | 121 | 2/m | 121 | - +-------------+--------------+---------------+------------+------------+ - | Cs | Monoclinic | 11m or m | 2/m | 1 | - +-------------+--------------+---------------+------------+------------+ - | Csx | Monoclinic | m11 | 2/m | 1 | - +-------------+--------------+---------------+------------+------------+ - | Csy | Monoclinic | 1m1 | 2/m | 1 | - +-------------+--------------+---------------+------------+------------+ - | C2h | Monoclinic | 2/m | 2/m | 112 | - +-------------+--------------+---------------+------------+------------+ - | D2 | Orthorhombic | 222 | mmm | 222 | - +-------------+--------------+---------------+------------+------------+ - | C2v | Orthorhombic | mm2 | mmm | 211 | - +-------------+--------------+---------------+------------+------------+ - | D2h | Orthorhombic | mmm | mmm | 222 | - +-------------+--------------+---------------+------------+------------+ - | C4 | Tetragonal | 4 | 4/m | 4 | - +-------------+--------------+---------------+------------+------------+ - | S4 | Tetragonal | -4 | 4/m | 112 | - +-------------+--------------+---------------+------------+------------+ - | C4h | Tetragonal | 4/m | 4/m | 4 | - +-------------+--------------+---------------+------------+------------+ - | D4 | Tetragonal | 422 | 4/mmm | 422 | - +-------------+--------------+---------------+------------+------------+ - | C4v | Tetragonal | 4mm | 4/mmm | 4 | - +-------------+--------------+---------------+------------+------------+ - | D2d | Tetragonal | -42m | 4/mmm | 222 | - +-------------+--------------+---------------+------------+------------+ - | D4h | Tetragonal | 4/mmm | 4/mmm | 422 | - +-------------+--------------+---------------+------------+------------+ - | C3 | Trigonal | 3 | -3 | 3 | - +-------------+--------------+---------------+------------+------------+ - | C3i | Trigonal | -3 | -3 | 3 | - +-------------+--------------+---------------+------------+------------+ - | D3 | Trigonal | 32 or 321 | -3m | 32 | - +-------------+--------------+---------------+------------+------------+ - | D3y | Trigonal | 312 | -3m | 312 | - +-------------+--------------+---------------+------------+------------+ - | C3v | Trigonal | 3m | -3m | 3 | - +-------------+--------------+---------------+------------+------------+ - | D3d | Trigonal | -3m | -3m | 32 | - +-------------+--------------+---------------+------------+------------+ - | C6 | Hexagonal | 6 | 6/m | 6 | - +-------------+--------------+---------------+------------+------------+ - | C3h | Hexagonal | -6 | 6/m | 6 | - +-------------+--------------+---------------+------------+------------+ - | C6h | Hexagonal | 6/m | 6/m | 622 | - +-------------+--------------+---------------+------------+------------+ - | D6 | Hexagonal | 622 | 6/mmm | 622 | - +-------------+--------------+---------------+------------+------------+ - | C6v | Hexagonal | 6mm | 6/mmm | 6 | - +-------------+--------------+---------------+------------+------------+ - | D3h | Hexagonal | -6m2 | 6/mmm | 312 | - +-------------+--------------+---------------+------------+------------+ - | D6h | Hexagonal | 6/mmm | 6/mmm | 622 | - +-------------+--------------+---------------+------------+------------+ - | T | Cubic | 23 | m-3 | 23 | - +-------------+--------------+---------------+------------+------------+ - | Th | Cubic | m-3 | m-3 | 23 | - +-------------+--------------+---------------+------------+------------+ - | O | Cubic | 432 | m-3m | 432 | - +-------------+--------------+---------------+------------+------------+ - | Td | Cubic | -43m | m-3m | 23 | - +-------------+--------------+---------------+------------+------------+ - | Oh | Cubic | m-3m | m-3m | 432 | - +-------------+--------------+---------------+------------+------------+ - - """ - subset = str(subset).lower() - if subset == "all_repeated": - return [ +class PointGroups(list): + # make a lookup table of common subsets of Point Groups + _pg_sets = { + "all_repeated": [ # Triclinic C1, Ci, @@ -978,9 +1023,8 @@ def get_point_groups(subset: str = "unique"): O, Td, Oh, - ] - elif subset == "all": - return [ + ], + "all": [ # Triclinic C1, Ci, @@ -1025,9 +1069,8 @@ def get_point_groups(subset: str = "unique"): O, Td, Oh, - ] - elif subset == "unique": - return [ + ], + "unique": [ # Triclinic C1, Ci, @@ -1067,9 +1110,8 @@ def get_point_groups(subset: str = "unique"): O, Td, Oh, - ] - elif subset == "proper": - return [ + ], + "proper": [ # Triclinic C1, # Monoclinic @@ -1088,9 +1130,8 @@ def get_point_groups(subset: str = "unique"): # cubic T, O, - ] - elif subset == "laue": - return [ + ], + "laue": [ # Triclinic Ci, # Monoclinic @@ -1109,9 +1150,8 @@ def get_point_groups(subset: str = "unique"): # cubic Th, Oh, - ] - elif subset == "proper_all": - return [ + ], + "proper_all": [ # Triclinic C1, # Monoclinic @@ -1133,9 +1173,8 @@ def get_point_groups(subset: str = "unique"): # cubic T, O, - ] - elif subset == "procedural": - return [ + ], + "procedural": [ # Cyclic C1, C2, @@ -1177,9 +1216,260 @@ def get_point_groups(subset: str = "unique"): Th, Td, Oh, - ] - else: - raise ValueError("{} is not a valid subset option".format(subset)) + ], + } + _subset_names = _pg_sets.keys() + _point_group_names = dict([(x.name, x) for x in _pg_sets["all_repeated"]]).keys() + + _spacegroup2pointgroup_dict = { + "PG1": {"proper": C1, "improper": C1}, + "PG1bar": {"proper": C1, "improper": Ci}, + "PG2": {"proper": C2, "improper": C2}, + "PGm": {"proper": C2, "improper": Cs}, + "PG2/m": {"proper": C2, "improper": C2h}, + "PG222": {"proper": D2, "improper": D2}, + "PGmm2": {"proper": C2, "improper": C2v}, + "PGmmm": {"proper": D2, "improper": D2h}, + "PG4": {"proper": C4, "improper": C4}, + "PG4bar": {"proper": C4, "improper": S4}, + "PG4/m": {"proper": C4, "improper": C4h}, + "PG422": {"proper": D4, "improper": D4}, + "PG4mm": {"proper": C4, "improper": C4v}, + "PG4bar2m": {"proper": D4, "improper": D2d}, + "PG4barm2": {"proper": D4, "improper": D2d}, + "PG4/mmm": {"proper": D4, "improper": D4h}, + "PG3": {"proper": C3, "improper": C3}, + "PG3bar": {"proper": C3, "improper": S6}, # Improper also known as C3i + "PG312": {"proper": D3, "improper": D3}, + "PG321": {"proper": D3, "improper": D3}, + "PG3m1": {"proper": C3, "improper": C3v}, + "PG31m": {"proper": C3, "improper": C3v}, + "PG3m": {"proper": C3, "improper": C3v}, + "PG3bar1m": {"proper": D3, "improper": D3d}, + "PG3barm1": {"proper": D3, "improper": D3d}, + "PG3barm": {"proper": D3, "improper": D3d}, + "PG6": {"proper": C6, "improper": C6}, + "PG6bar": {"proper": C6, "improper": C3h}, + "PG6/m": {"proper": C6, "improper": C6h}, + "PG622": {"proper": D6, "improper": D6}, + "PG6mm": {"proper": C6, "improper": C6v}, + "PG6barm2": {"proper": D6, "improper": D3h}, + "PG6bar2m": {"proper": D6, "improper": D3h}, + "PG6/mmm": {"proper": D6, "improper": D6h}, + "PG23": {"proper": T, "improper": T}, + "PGm3bar": {"proper": T, "improper": Th}, + "PG432": {"proper": O, "improper": O}, + "PG4bar3m": {"proper": T, "improper": Td}, + "PGm3barm": {"proper": O, "improper": Oh}, + } + + def __init__(self, symmetry_list: list = [C1]): + """ + A list of symmetry operators with convenence functions for parsing entries + and displaying information. + + This class is primarily intended to be called using PointGroups.subset(), + or to return a single Symmetry object using PointGroups.get(). + + Parameters + ---------- + symmetry_list + A list of orix.quaternion.symmetry.Symmetry objects, each representing + a crystallographic class. + """ + if not isinstance(symmetry_list, list): + ValueError("'symmetry_list' must be a list of Symmetry objects") + if not np.all([isinstance(x, Symmetry) for x in symmetry_list]): + ValueError("'symmetry_list' must be a list of Symmetry objects") + super().__init__() + self.extend(symmetry_list) + + def __repr__(self): + str_data = ( + "| Name | System | HM | Laue | Proper |n" + + "=" * 47 + + "\n" + + "\n".join( + [ + "| " + + "| ".join( + [ + x._s_name.ljust(6), + x.system.ljust(12), + x.name.ljust(6), + x.laue.name.ljust(6), + x.proper_subgroup.name.ljust(7), + ] + ) + + "|" + for x in self + ] + ) + ) + + return str_data + + def get(name: Literal[PointGroups._point_group_names]): + """ + Given a string or integer representation, this function will attempt to + return an associated Symmetry object. + + This is done by first checking the labels defined in orix, which includes + Herman-Mauguin ('m3m' or '2', etc.) and Shoenflies ('C6' or D3h', etc.). + + If it cannot find a match in either list, it will attempt to look up the + space group name using diffpy's GetSpaceGroup, and relate that back + to a point group. this is equivalent to PointGroups.from_space_group(name) + + Parameters + ---------- + name : string in PointGroups._point_group_names + either the Herman-Maugin or Shoenflies name for a crystallographic + point gorup. + + Returns + ------- + point_group + an object of Class `Symmetry` representing the requested + crystallographic point group. + """ + # check the 'unique' list first, then 'all', then 'all_repeated' first. + print(vars().keys()) + for subset in ["unique", "all", "all_repeated"]: + pgs = PointGroups._pg_sets[subset] + pg_dict = dict([(x.name, x) for x in pgs]) + if name.lower() in pg_dict.keys(): + return pg_dict[name.lower()] + # repeat check with Shoenflies notation + pg_dict_s = dict([(x._s_name.lower(), x) for x in pgs]) + if name.lower() in pg_dict_s.keys(): + return pg_dict_s[name.lower()] + # If the name doesn't exist in orix, try diffpy + try: + PointGroups.from_space_group(name) + # If the name still cannot be found, return a ValueError + except ValueError: + raise ValueError( + f"'name' must be one of {", ".join(map(str, pg_dict.keys()))}," + + f" {", ".join(map(str, pg_dict_s.keys()))}, or must be a string or " + + "integer recognized by diffpy.structure.spacegroups.GetSpaceGroup" + + f". name = '{name}' is not a valid value." + ) + + def from_space_group( + space_group_number: Union(int, str), proper: bool = False + ) -> Symmetry: + """ + Maps a space group number or name to a crystallographic point group. + + Parameters + ---------- + space_group_number: int between 1-231, or str + If is an int(n) or str(int(n)) where n is between 1 and 231, it will + return the point group of the nth space group, as defined by the + International Tables of Crystallogrphy. Otherwise, it will be passed + to diffpy's dictionary of space group names for interpretation. + + Thus, 222 and '222' will both return symmetry.Oh (ie "432", the point + group of SG#222==Pn-3n), but 'P222' will return symmetry.D2 + (ie "222", the proper point group of SG#16=='P222'). + + proper: bool + Whether to return the point group with proper rotations only + (``True``), or the full point group (``False``). Default is + ``False``. + + Returns + ------- + point_group + One of the 11 proper or 32 point groups. + + Notes: + ---------- + This function uses diffpy.structure.spacegroups to convert names to + space group IDs, and has some allowances for spelling and spacing + differences. Thus, variations like "Pm-3m", 221, "PM3m", and "Pn3n" all + map to symmetry.Oh == 'm-3m'. To see a full list of all name options + avaiable, use the following snippet: + + >>> import diffpy.structure.spacegroups as sg + >>> sg._buildSGLookupTable() + >>> sg._sg_lookup_table.keys() + + Examples + -------- + >>> from orix.quaternion.symmetry import get_point_group + >>> pgOh = get_point_group(225) + >>> pgOh.name + 'm-3m' + >>> pgO = get_point_group(225, proper=True) + >>> pgO.name + '432' + """ + spg = GetSpaceGroup(space_group_number) + pgn = spg.point_group_name + if proper: + return PointGroups._spacegroup2pointgroup_dict[pgn]["proper"] + else: + return PointGroups._spacegroup2pointgroup_dict[pgn]["improper"] + + @classmethod + def get_set(self, name: Literal[PointGroups._subset_names] = "unique"): + """ + returns different subsets of the 32 crystallographic point groups. By + default, this returns all 32 in the order they appear in the + International Tables of Crystallography (ITOC). + + Parameters + ---------- + subset : str, optional + the point group list to return. The options are as follows: + "unique" (default): + All 32 point groups in the order they appear in ITOC's space groups. + Thus, they are grouped by crystal system and Laue class + "all": + All 32 points groups, plus common axis-specific permutations + for non-centrosymmetric groups (ie, C2 plus C2x and C2y) for + a total of 37 point group projections. These + are given in the same order as ITOC Table 10.2.2 + "all_repeated": + The 37 point group projections, plus the redundant Schonflies + and Herman-Mauguin group names. For example, both Ci and S2 + are included, as well as D3 =="32" and D3x == "321". NOTE: + this means several of the entries symmetrically identical. + "proper": + The 11 proper point groups given in the same order as ITOC + table 10.2.2. + same order as "unique", which in turn aligns with Table 3.1 + of ITOC + "proper_all": + The 11 proper point groups, plus axis-specific permutations. + "laue": + The point groups corresponding to the 11 Laue groups, using + the same ordering and definitions as Table 3.1 of ITOC. These + are equivalent to adding an inversion symmetry to each op + the 11 proper point groups + "procedural": + The 32 point groups, but presented in the procedural ordering + described in "Structure of Materials" and other books, where + point groups are created from successive applications of + symmetry elements to the Cyclic (C_n) and Dihedral (D_n) + groups. + + Returns + ------- + point groups: PointGroups + A PointGroup class containing the requested symmetries + """ + pg_opts = self._pg_sets.keys() + if name in pg_opts: + return PointGroups(self._pg_sets[name]) + elif name.lower() in pg_opts: + return PointGroups(self._pg_sets[name.lower()]) + # if the name doesn't exist, return a ValueError + raise ValueError( + f"'name' must be one of {", ".join(map(str, pg_opts))}, not '{name}'" + ) def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: @@ -1202,118 +1492,16 @@ def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: return distinguished_points[distinguished_points.angle > 0] -spacegroup2pointgroup_dict = { - "PG1": {"proper": C1, "improper": C1}, - "PG1bar": {"proper": C1, "improper": Ci}, - "PG2": {"proper": C2, "improper": C2}, - "PGm": {"proper": C2, "improper": Cs}, - "PG2/m": {"proper": C2, "improper": C2h}, - "PG222": {"proper": D2, "improper": D2}, - "PGmm2": {"proper": C2, "improper": C2v}, - "PGmmm": {"proper": D2, "improper": D2h}, - "PG4": {"proper": C4, "improper": C4}, - "PG4bar": {"proper": C4, "improper": S4}, - "PG4/m": {"proper": C4, "improper": C4h}, - "PG422": {"proper": D4, "improper": D4}, - "PG4mm": {"proper": C4, "improper": C4v}, - "PG4bar2m": {"proper": D4, "improper": D2d}, - "PG4barm2": {"proper": D4, "improper": D2d}, - "PG4/mmm": {"proper": D4, "improper": D4h}, - "PG3": {"proper": C3, "improper": C3}, - "PG3bar": {"proper": C3, "improper": S6}, # Improper also known as C3i - "PG312": {"proper": D3, "improper": D3}, - "PG321": {"proper": D3, "improper": D3}, - "PG3m1": {"proper": C3, "improper": C3v}, - "PG31m": {"proper": C3, "improper": C3v}, - "PG3m": {"proper": C3, "improper": C3v}, - "PG3bar1m": {"proper": D3, "improper": D3d}, - "PG3barm1": {"proper": D3, "improper": D3d}, - "PG3barm": {"proper": D3, "improper": D3d}, - "PG6": {"proper": C6, "improper": C6}, - "PG6bar": {"proper": C6, "improper": C3h}, - "PG6/m": {"proper": C6, "improper": C6h}, - "PG622": {"proper": D6, "improper": D6}, - "PG6mm": {"proper": C6, "improper": C6v}, - "PG6barm2": {"proper": D6, "improper": D3h}, - "PG6bar2m": {"proper": D6, "improper": D3h}, - "PG6/mmm": {"proper": D6, "improper": D6h}, - "PG23": {"proper": T, "improper": T}, - "PGm3bar": {"proper": T, "improper": Th}, - "PG432": {"proper": O, "improper": O}, - "PG4bar3m": {"proper": T, "improper": Td}, - "PGm3barm": {"proper": O, "improper": Oh}, -} - - @deprecated( since="0.14", removal="0.15", - alternative="symmetry.get_point_group_from_space_group", + alternative="PointGroup.get_from_space_group", ) def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: r""" - This function has been renamed to - `orix.quaternion.symmetry.get_point_group_from_space_group`. - for clairity""" - return get_point_group_from_space_group(space_group_number, proper) - - -def get_point_group_from_space_group( - space_group_number: Union(int, str), proper: bool = False -) -> Symmetry: - """ - Maps a space group number or name to a crystallographic point group. - - Parameters - ---------- - space_group_number: int between 1-231, or str - If is an int(n) or str(int(n)) where n is between 1 and 231, it will - return the point group of the nth space group, as defined by the - International Tables of Crystallogrphy. Otherwise, it will be passed - to diffpy's dictionary of space group names for interpretation. - - Thus, 222 and '222' will both return symmetry.Oh (ie "432", the point - group of SG#222==Pn-3n), but 'P222' will return symmetry.D2 - (ie "222", the proper point group of SG#16=='P222'). - - proper: bool - Whether to return the point group with proper rotations only - (``True``), or the full point group (``False``). Default is - ``False``. - - Returns - ------- - point_group - One of the 11 proper or 32 point groups. - - Notes: - ---------- - This function uses diffpy.structure.spacegroups to convert names to - space group IDs, and has some allowances for spelling and spacing - differences. Thus, variations like "Pm-3m", 221, "PM3m", and "Pn3n" all - map to symmetry.Oh == 'm-3m'. To see a full list of all name options - avaiable, use the following snippet: - - >>> import diffpy.structure.spacegroups as sg - >>> sg._buildSGLookupTable() - >>> sg._sg_lookup_table.keys() - - Examples - -------- - >>> from orix.quaternion.symmetry import get_point_group - >>> pgOh = get_point_group(225) - >>> pgOh.name - 'm-3m' - >>> pgO = get_point_group(225, proper=True) - >>> pgO.name - '432' + This function has been moved to the PointGroups class """ - spg = GetSpaceGroup(space_group_number) - pgn = spg.point_group_name - if proper: - return spacegroup2pointgroup_dict[pgn]["proper"] - else: - return spacegroup2pointgroup_dict[pgn]["improper"] + return PointGroups.get_from_space_group(space_group_number, proper) # Point group alias mapping. This is needed because in EDAX TSL OIM @@ -1335,16 +1523,16 @@ def _get_laue_group_name(name: str) -> str | None: # search through all the point groups defined in orix for one with a # matching name. valid_name = False - for g in get_point_groups("all_repeated"): + for g in PointGroups._pg_sets["all_repeated"]: if g.name == name: valid_name = True break if valid_name == False: - raise ValueError("{} is not a valid point group name") + raise ValueError(f"{name} is not a valid point group name") # add an inversion to get the laue group. g_laue = _get_unique_symmetry_elements(g, Ci) # find a laue group with matching operators - for laue in get_point_groups("laue"): + for laue in PointGroups._pg_sets["laue"]: # first check for length if g_laue.shape != laue.shape: continue @@ -1352,6 +1540,7 @@ def _get_laue_group_name(name: str) -> str | None: if np.min(g_laue.outer(laue).angle ** 2, 1).max() < 1e-4: if np.min(g_laue.outer(laue).angle ** 2, 0).max() < 1e-4: return laue.name + return "" def _get_unique_symmetry_elements( From c35bc06f971b15b4b0ae2fa5b3c9c6e12cae47d6 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Tue, 8 Jul 2025 20:32:01 -0600 Subject: [PATCH 28/40] formatting --- .../stereographic_projection/plot_symmetry_operations.py | 8 +++++++- orix/crystal_map/phase_list.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index a27442033..31d9b866d 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -9,13 +9,19 @@ The ordering follows the one given in section 9.2 of "Structures of Materials" (DeGraef et.al, 2nd edition, 2012), starting with the cyclic groups, then the dihedral groups, then those same groups plus inversion centers, then the successive -application of mirror planes and secondary rotational symmetries. +application of mirror planes and secondary rotational symmetries until all 32 +groups are made. The plots themselves as well as their labels follow the standards given in Table 10.2.2 of the "International Tables of Crystallography, Volume A" (ITOC). Both the nomenclature and marker styles thus differ slightly from some textbooks, as there are some arbitrary convention choices in both Schoenflies notation and marker styles. + +Orix uses Schoenflies Notation (left label above each plot) for variable names since +they are short and always begin with a letter, but both Schoenflies and +Hermann-Mauguin (right label above each plot) names can be used to look up symmetry +groups using `PointGroups.get()` """ import matplotlib.pyplot as plt diff --git a/orix/crystal_map/phase_list.py b/orix/crystal_map/phase_list.py index 9f3179796..7a22c6822 100644 --- a/orix/crystal_map/phase_list.py +++ b/orix/crystal_map/phase_list.py @@ -33,7 +33,7 @@ import matplotlib.colors as mcolors import numpy as np -from orix.quaternion.symmetry import _EDAX_POINT_GROUP_ALIASES, Symmetry, PointGroups +from orix.quaternion.symmetry import _EDAX_POINT_GROUP_ALIASES, PointGroups, Symmetry from orix.vector import Miller, Vector3d # All named Matplotlib colors (tableau and xkcd already lower case hex) From f472f1164b2421ed99ce64ef4df5f0ae212b8a18 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:46:43 -0600 Subject: [PATCH 29/40] finishing symmetry.plot and adding to PointGroups --- .../plot_symmetry_operations.py | 6 +- orix/crystal_map/phase_list.py | 2 +- orix/quaternion/symmetry.py | 352 ++++++++++-------- orix/tests/plot/test_stereographic_plot.py | 10 +- orix/tests/quaternion/test_orientation.py | 6 +- orix/tests/quaternion/test_symmetry.py | 120 +++--- 6 files changed, 282 insertions(+), 214 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 31d9b866d..22806345e 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -27,15 +27,19 @@ import matplotlib.pyplot as plt import orix.plot from orix.quaternion.symmetry import PointGroups +from orix.vector import Vector3d # create a list of the 32 crystallographic point groups point_groups = PointGroups.get_set("procedural") +# prepare the plots fig, ax = plt.subplots( 4, 8, subplot_kw={"projection": "stereographic"}, figsize=[14, 10] ) ax = ax.flatten() +# create a vector to mirror over axes +v = Vector3d.from_polar(65, 80, degrees=True) # Iterate through the 32 Point groups for i, pg in enumerate(point_groups): - pg.plot_elements(plt_axis=ax[i], itoc_style=True) + pg.plot(asymetric_vector=v, plt_axis=ax[i], itoc_style=True) diff --git a/orix/crystal_map/phase_list.py b/orix/crystal_map/phase_list.py index 7a22c6822..d76d269c9 100644 --- a/orix/crystal_map/phase_list.py +++ b/orix/crystal_map/phase_list.py @@ -236,7 +236,7 @@ def point_group(self) -> Symmetry | None: @point_group.setter def point_group(self, value: int | str | Symmetry | None) -> None: """Set the point group.""" - groups = PointGroups._pg_sets["all_repeated"] + groups = PointGroups._pg_sets["permutations_repeated"] if isinstance(value, int): value = str(value) if isinstance(value, str): diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index be593a43f..e877a21f0 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -77,7 +77,7 @@ def is_proper(self) -> bool: @property def subgroups(self) -> list[Symmetry]: """Return the list groups that are subgroups of this group.""" - groups = PointGroups._pg_sets["all"] + groups = PointGroups._pg_sets["permutations"] return [g for g in groups if g._tuples <= self._tuples] @property @@ -420,8 +420,7 @@ def from_generators(cls, *generators: Rotation) -> Symmetry: # --------------------- Other public methods --------------------- # def _get_symmetry_elements(self) -> list: - """ - Returns all the crystallographically unique axes and their associated + """Returns all the crystallographically unique axes and their associated symmetry elements (mirrors, rotations, rotoinversions, etc). Returns @@ -521,22 +520,11 @@ def _get_symmetry_elements(self) -> list: elements.append( (copy(axis), m_flag, 1, "none"), ) - # Finally, 3-fold rotations around the 111 create <110> mirrors, and - # inversion combined with mirror planes can create 2-fold symmetries - # not on the primary axis. These we can add by hand if they are missing. + # Finally, 3-fold rotations around the 111 create <110> mirrors + # not on the primary axes. These we can add by hand. if np.any(np.abs(Vector3d([1, 1, 1]).angle_with(self.axis)) < 1e-4): - catch = False v = Vector3d([0, 1, 1]).in_fundamental_sector(self) - for i, e in enumerate(elements): - if np.abs(v.data - e[0].data).sum() < 1e-4: - elements[i][1] = True - catch = True - break - if catch is False: - elements.append( - (v, True, 1, "none"), - ) - + elements.append((v, True, 1, "none")) # split the list of lists into 4 variables. axes = [x[0] for x in elements] is_mirror = [x[1] for x in elements] @@ -545,6 +533,7 @@ def _get_symmetry_elements(self) -> list: return axes, is_mirror, s_type, folds def get_axis_orders(self) -> dict[Vector3d, int]: + """Return a dictionary of every rotation axis and it's order (ie, folds)""" s = self[self.angle > 0] if s.size == 0: return {} @@ -554,6 +543,7 @@ def get_axis_orders(self) -> dict[Vector3d, int]: } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: + """Return the highest order rotational axis and it's order (ie, folds)""" axis_orders = self.get_axis_orders() if len(axis_orders) == 0: return Vector3d.zvector(), np.inf @@ -597,22 +587,97 @@ def fundamental_zone(self) -> Vector3d: sr = SphericalRegion(n.unique()) return sr - def plot_elements( + def plot( self, - color_dict: dict = {}, - symmetry_color: str | None = None, - mirror_color: str | None = None, - s: float = 160, - mirror_lw: float = 1, - plt_axis: plt.Axes | None = None, - return_figure: bool = False, - itoc_style: bool = True, + asymetric_vector: Vector3d | None = None, show_name: bool = True, - ): + plt_axis: plt.Axes | None = None, + return_figure: bool = True, + marker_dict: dict = {}, + marker_size: float = 150.0, + mirror_width: float = 2.0, + asymetric_vector_dict: dict = {}, + asymmetric_vector_size: float = 10.0, + itoc_style=True, + ) -> plt.Figure | None: + """Creates a stereographic projection of symmetry operations in the group. + Can also plot symmetrically equivalent variations of orientations or vectors + to demonstrate the effect of symmetry operations. + + Parameters + ---------- + asymetric_vector + A marker will be added at the stereographic projection of this vector, + along with all it's symmetrically equivalent rotations. By default, no + vector will be plotted, and only rotation and mirror markers will be added + to the plot. + show_name + If True, add both the Schoenflies and Hermann-Mauguin names of the point + group to the title. + plt_axis + The matplotlib.Axis object into which to add the stereographic plot. + If None is passed, a new figure and axis will be generated. + return_figure + If True, return the figure containing the plotting axis. + marker_dict + A dictionary of arguments to modify how the symmetry markers are + generated. The following options are the overwritable defaults: + 1: 'black' <-- 1-fold marker color + 2: 'green' <-- 2-fold marker color + 3: 'red' <-- 3-fold marker color + 4: 'purple' <-- 4-fold marker color + 6: 'magenta' <-- 6-fold marker color + 'm': 'blue' <-- 1-fold marker color + marker_size + The size of the rotational makers to be added to the plot. This is + equivalent to the argument "s" in matplotlib.scatter + mirror_width + The width of the line used to draw the mirror planes. This is + equivalent to the argument "linewidth" in matplotlib.plot + asymetric_vector_dict + A dictionary of arguments to modify the asymetric_vector markers. + The following options are the overwritable defaults: + 'upper_color': 'black' < -- Upper hemisphere marker color + 'lower_color': 'grey' < -- Lower hemisphere marker color + 'upper_marker': '+' < -- Upper hemisphere marker shape + 'lower_marker': 'o' < -- Lower hemisphere marker shape + asymmetric_vector_size + size of the markers used to plot the asymetric vector markers. + itoc_style + If True, the plot will follow the ITOC convention of not placing redundant + inversion symbols on rotation markers perpendicular to the viewing + direction. + + Returns + ------- + fig + The created figure, returned if ``return_figure=True`` is + passed as a keyword argument. + + Notes + ----- + + If users wish to have more control over their plots, this function can be used + to modify an existing plot, like so: + + >>> import matplotlib.pyplot as plt + >>> pg_Oh = PointGroups.get('m-3m') + >>> v = Vector3d.random(10) + >>> v_symm = pg_Oh.outer(v).flatten() + >>> fig, ax = plt.subplots(1, 1, subplot_kw={"projection": "stereographic"}) + >>> pg_Oh.plot(plt_axis=ax, show_name = False) + >>> ax.set_title("my cool custom title") + >>> ax.scatter(v_symm) + + In this way, keword arguments related to the plot, the title, the scattered + vector markers, and/or the symmetry markers can be individually altered as + desired. + + """ # import orix.plot so matplotlib knows what the stereographic projection is. import orix.plot - # dictionary of default colors + # dictionary of default colors for the symmetry markers. colors = { 1: "black", 2: "green", @@ -621,23 +686,17 @@ def plot_elements( 6: "magenta", "m": "blue", } - # if a symmetry or mirror color was passed in, reset default values. - if symmetry_color is not None: - for i in [1, 2, 3, 4, 6]: - colors[i] = symmetry_color - if mirror_color is not None: - colors["m"] = mirror_color # after resetting defaults, update color choices passed in via color_dict - colors.update(color_dict) + colors.update(marker_dict) # if the user did not pass in an axis, generate one if plt_axis is None: fig, plt_axis = plt.subplots(subplot_kw={"projection": "stereographic"}) else: fig = plt_axis.get_figure() - # add titles and labels if requested + # add a default title if requested if show_name: - plt_axis.set_title(self._s_name + " | " + self.name) + plt_axis.set_title(self._s_name + " ( " + self.name + " )") # determine the symnmetry elements and plot them. elements = self._get_symmetry_elements() @@ -646,7 +705,7 @@ def plot_elements( if m: for mv in (self * v).unique(): m_circ = mv.get_circle() - plt_axis.plot(m_circ, color=colors["m"], linewidth=mirror_lw) + plt_axis.plot(m_circ, color=colors["m"], linewidth=mirror_width) # plot each symmetrically equivalent rotation element only once c = colors[f] if f > 1: @@ -658,96 +717,54 @@ def plot_elements( is_perp = np.abs(z_ang - (np.pi / 2)) < 1e-4 if itoc_style and is_perp: plt_axis.symmetry_marker( - sv, folds=f, s=s, color=c, modifier=None + sv, folds=f, s=marker_size, color=c, modifier=None ) else: - plt_axis.symmetry_marker(sv, folds=f, s=s, color=c, modifier=t) + plt_axis.symmetry_marker( + sv, folds=f, s=marker_size, color=c, modifier=t + ) # if this is the primary axis and there is no rotation but an inversion # (ie, this is symmetry.Ci, the `-1` PG), add the appropriate marker. elif f == 1 and np.abs(v.angle_with(Vector3d.zvector())) < 1e-4: if t != "inversion": continue for sv in (self * v).unique(): - plt_axis.symmetry_marker(sv, folds=f, s=s, color=c, modifier=t) - # return the figure if requested - if return_figure: - return fig - - def plot( - self, - orientation: "Orientation | None" = None, - reproject_scatter_kwargs: dict | None = None, - **kwargs, - ) -> plt.Figure | None: - """Stereographic projection of symmetry operations. - - The upper hemisphere of the stereographic projection is shown. - Vectors on the lower hemisphere are shown after reprojection - onto the upper hemisphere. - - Parameters - ---------- - orientation - The symmetry operations are applied to this orientation - before plotting. The default value uses an orientation - optimized to show symmetry elements. - reproject_scatter_kwargs - Dictionary of keyword arguments for the reprojected scatter - points which is passed to - :meth:`~orix.plot.StereographicPlot.scatter`, which passes - these on to :meth:`matplotlib.axes.Axes.scatter`. The - default marker style for reprojected vectors is "+". Values - used for vector(s) on the visible hemisphere are used unless - another value is passed here. - **kwargs - Keyword arguments passed to - :meth:`~orix.plot.StereographicPlot.scatter`, which passes - these on to :meth:`matplotlib.axes.Axes.scatter`. + plt_axis.symmetry_marker( + sv, folds=f, s=marker_size, color=c, modifier=t + ) - Returns - ------- - fig - The created figure, returned if ``return_figure=True`` is - passed as a keyword argument. - """ - if orientation is None: - # orientation chosen to mimic stereographic projections as - # shown: http://xrayweb.chem.ou.edu/notes/symmetry.html - orientation = Rotation.from_axes_angles((-1, 8, 1), np.deg2rad(65)) - if not isinstance(orientation, Rotation): - raise TypeError("Orientation must be a Rotation instance.") - orientation = self.outer(orientation) - - kwargs.setdefault("return_figure", False) - return_figure = kwargs.pop("return_figure") - - if reproject_scatter_kwargs is None: - reproject_scatter_kwargs = {} - reproject_scatter_kwargs.setdefault("marker", "+") - reproject_scatter_kwargs.setdefault("label", "lower") - - v = orientation * Vector3d.zvector() - - figure = v.scatter( - return_figure=True, - axes_labels=[r"$e_1$", r"$e_2$", None], - label="upper", - reproject=True, - reproject_scatter_kwargs=reproject_scatter_kwargs, - **kwargs, - ) - # add symmetry name to figure title - figure.suptitle(f"${self.name}$") + # plot asymmetric markers if requested. + if asymetric_vector is not None: + v_symm = self.outer(asymetric_vector).flatten() + vdict = { + "upper_color": "black", + "lower_color": "grey", + "upper_marker": "+", + "lower_marker": "o", + } + vdict.update(asymetric_vector_dict) + mask = v_symm.z >= 0 + plt_axis.scatter( + -1 * v_symm[~mask], + marker=vdict["lower_marker"], + c=vdict["lower_color"], + ) + plt_axis.scatter( + v_symm[mask], + marker=vdict["upper_marker"], + c=vdict["upper_color"], + ) + # return the figure if requested if return_figure: - return figure + return fig # ---------------- Proceedural definitions of Point Groups ---------------- # # NOTE: ORIX uses Schoenflies symbols to define point groups. This is partly # because the notation is short and always starts with a letter (ie, they # make convenient python variables), and partly because it helps limit -# accidental misinterpretation of Herman-Mauguin symbols as space group +# accidental misinterpretation of Hermann-Mauguin symbols as space group # numbers. For example. "222" could be interpreted as SG#222 == Pn-3n, or # as PG'222'== D3. there are similar examples with 2, 3, 4, 32, etc. @@ -971,7 +988,7 @@ def plot( class PointGroups(list): # make a lookup table of common subsets of Point Groups _pg_sets = { - "all_repeated": [ + "permutations_repeated": [ # Triclinic C1, Ci, @@ -1024,7 +1041,7 @@ class PointGroups(list): Td, Oh, ], - "all": [ + "permutations": [ # Triclinic C1, Ci, @@ -1070,7 +1087,7 @@ class PointGroups(list): Td, Oh, ], - "unique": [ + "groups": [ # Triclinic C1, Ci, @@ -1111,7 +1128,7 @@ class PointGroups(list): Td, Oh, ], - "proper": [ + "proper_groups": [ # Triclinic C1, # Monoclinic @@ -1131,27 +1148,7 @@ class PointGroups(list): T, O, ], - "laue": [ - # Triclinic - Ci, - # Monoclinic - C2h, - # Orthorhombic - D2h, - D4h, - # Tetragonal - C4h, - # Trigonal - C3i, - D3d, - # Hexagonal - C6h, - D6h, - # cubic - Th, - Oh, - ], - "proper_all": [ + "proper_permutations": [ # Triclinic C1, # Monoclinic @@ -1174,6 +1171,26 @@ class PointGroups(list): T, O, ], + "laue": [ + # Triclinic + Ci, + # Monoclinic + C2h, + # Orthorhombic + D2h, + D4h, + # Tetragonal + C4h, + # Trigonal + C3i, + D3d, + # Hexagonal + C6h, + D6h, + # cubic + Th, + Oh, + ], "procedural": [ # Cyclic C1, @@ -1219,7 +1236,9 @@ class PointGroups(list): ], } _subset_names = _pg_sets.keys() - _point_group_names = dict([(x.name, x) for x in _pg_sets["all_repeated"]]).keys() + _point_group_names = dict( + [(x.name, x) for x in _pg_sets["permutations_repeated"]] + ).keys() _spacegroup2pointgroup_dict = { "PG1": {"proper": C1, "improper": C1}, @@ -1278,16 +1297,16 @@ def __init__(self, symmetry_list: list = [C1]): a crystallographic class. """ if not isinstance(symmetry_list, list): - ValueError("'symmetry_list' must be a list of Symmetry objects") - if not np.all([isinstance(x, Symmetry) for x in symmetry_list]): - ValueError("'symmetry_list' must be a list of Symmetry objects") - super().__init__() - self.extend(symmetry_list) + raise ValueError("'symmetry_list' must be a list of Symmetry objects") + elif not np.all([isinstance(x, Symmetry) for x in symmetry_list]): + raise ValueError("'symmetry_list' must be a list of Symmetry objects") + else: + self.extend(symmetry_list) def __repr__(self): str_data = ( - "| Name | System | HM | Laue | Proper |n" - + "=" * 47 + "| Name | System | HM | Laue | Proper |\n" + + "=" * 48 + "\n" + "\n".join( [ @@ -1295,7 +1314,7 @@ def __repr__(self): + "| ".join( [ x._s_name.ljust(6), - x.system.ljust(12), + x.system.ljust(13), x.name.ljust(6), x.laue.name.ljust(6), x.proper_subgroup.name.ljust(7), @@ -1315,7 +1334,7 @@ def get(name: Literal[PointGroups._point_group_names]): return an associated Symmetry object. This is done by first checking the labels defined in orix, which includes - Herman-Mauguin ('m3m' or '2', etc.) and Shoenflies ('C6' or D3h', etc.). + Hermann-Mauguin ('m3m' or '2', etc.) and Shoenflies ('C6' or D3h', etc.). If it cannot find a match in either list, it will attempt to look up the space group name using diffpy's GetSpaceGroup, and relate that back @@ -1324,7 +1343,7 @@ def get(name: Literal[PointGroups._point_group_names]): Parameters ---------- name : string in PointGroups._point_group_names - either the Herman-Maugin or Shoenflies name for a crystallographic + either the Hermann-Maugin or Shoenflies name for a crystallographic point gorup. Returns @@ -1333,20 +1352,21 @@ def get(name: Literal[PointGroups._point_group_names]): an object of Class `Symmetry` representing the requested crystallographic point group. """ - # check the 'unique' list first, then 'all', then 'all_repeated' first. + # check the 'groups' list first, then 'permutations', + # then 'permutations_repeated'. print(vars().keys()) - for subset in ["unique", "all", "all_repeated"]: + for subset in ["groups", "permutations", "permutations_repeated"]: pgs = PointGroups._pg_sets[subset] pg_dict = dict([(x.name, x) for x in pgs]) - if name.lower() in pg_dict.keys(): + if str(name).lower() in pg_dict.keys(): return pg_dict[name.lower()] # repeat check with Shoenflies notation pg_dict_s = dict([(x._s_name.lower(), x) for x in pgs]) - if name.lower() in pg_dict_s.keys(): + if str(name).lower() in pg_dict_s.keys(): return pg_dict_s[name.lower()] # If the name doesn't exist in orix, try diffpy try: - PointGroups.from_space_group(name) + return PointGroups.from_space_group(name) # If the name still cannot be found, return a ValueError except ValueError: raise ValueError( @@ -1414,7 +1434,7 @@ def from_space_group( return PointGroups._spacegroup2pointgroup_dict[pgn]["improper"] @classmethod - def get_set(self, name: Literal[PointGroups._subset_names] = "unique"): + def get_set(self, name: Literal[PointGroups._subset_names] = "groups"): """ returns different subsets of the 32 crystallographic point groups. By default, this returns all 32 in the order they appear in the @@ -1424,17 +1444,17 @@ def get_set(self, name: Literal[PointGroups._subset_names] = "unique"): ---------- subset : str, optional the point group list to return. The options are as follows: - "unique" (default): + "groups" (default): All 32 point groups in the order they appear in ITOC's space groups. Thus, they are grouped by crystal system and Laue class - "all": + "permutations": All 32 points groups, plus common axis-specific permutations for non-centrosymmetric groups (ie, C2 plus C2x and C2y) for a total of 37 point group projections. These are given in the same order as ITOC Table 10.2.2 - "all_repeated": + "permutations_repeated": The 37 point group projections, plus the redundant Schonflies - and Herman-Mauguin group names. For example, both Ci and S2 + and Hermann-Mauguin group names. For example, both Ci and S2 are included, as well as D3 =="32" and D3x == "321". NOTE: this means several of the entries symmetrically identical. "proper": @@ -1495,13 +1515,13 @@ def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: @deprecated( since="0.14", removal="0.15", - alternative="PointGroup.get_from_space_group", + alternative="PointGroups.from_space_group", ) def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: - r""" + """ This function has been moved to the PointGroups class """ - return PointGroups.get_from_space_group(space_group_number, proper) + return PointGroups.from_space_group(space_group_number, proper) # Point group alias mapping. This is needed because in EDAX TSL OIM @@ -1523,12 +1543,19 @@ def _get_laue_group_name(name: str) -> str | None: # search through all the point groups defined in orix for one with a # matching name. valid_name = False - for g in PointGroups._pg_sets["all_repeated"]: + for g in PointGroups._pg_sets["permutations_repeated"]: if g.name == name: valid_name = True break - if valid_name == False: + if valid_name is False: raise ValueError(f"{name} is not a valid point group name") + # if the matching point group as a Schoenflies name that ends in an x,y, or z, + # it's a permutation of a point group. trade it for an unpermutated one. + if np.isin(g._s_name[-1], ["x", "y", "z"]): + s_name = g._s_name[:-1] + for g in PointGroups._pg_sets["permutations_repeated"]: + if g._s_name == s_name: + break # add an inversion to get the laue group. g_laue = _get_unique_symmetry_elements(g, Ci) # find a laue group with matching operators @@ -1540,7 +1567,6 @@ def _get_laue_group_name(name: str) -> str | None: if np.min(g_laue.outer(laue).angle ** 2, 1).max() < 1e-4: if np.min(g_laue.outer(laue).angle ** 2, 0).max() < 1e-4: return laue.name - return "" def _get_unique_symmetry_elements( diff --git a/orix/tests/plot/test_stereographic_plot.py b/orix/tests/plot/test_stereographic_plot.py index cea15cfa3..cfb170331 100644 --- a/orix/tests/plot/test_stereographic_plot.py +++ b/orix/tests/plot/test_stereographic_plot.py @@ -289,7 +289,7 @@ def test_size_parameter(self): class TestSymmetryMarker: @pytest.mark.parametrize("v_data", [[0, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]) @pytest.mark.parametrize("folds", [1, 2, 3, 4, 6]) - @pytest.mark.parametrize("modifier", ["none", "roto", "inv"]) + @pytest.mark.parametrize("modifier", [None, "none", "rotoinversion", "inversion"]) def test_main_properties(self, v_data, folds, modifier): v = Vector3d(v_data) marker = _SymmetryMarker(v, folds=folds, modifier=modifier) @@ -311,10 +311,12 @@ def test_plot_symmetry_marker(self): v = Vector3d([[1, 0, 0], [0, 1, 1]]) for i in [1, 2, 3, 4, 6]: ax.symmetry_marker(v[0], folds=i, s=marker_size, color="k") - ax.symmetry_marker(v[1], folds=i, modifier="inv", s=marker_size) - ax.symmetry_marker(v, folds=1, modifier="inv", color="C1", s=marker_size) + ax.symmetry_marker(v[1], folds=i, modifier="inversion", s=marker_size) + ax.symmetry_marker( + v, folds=1, modifier="inversion", color="C1", s=marker_size + ) for i in [4, 6]: - ax.symmetry_marker(v, folds=i, modifier="roto", s=marker_size) + ax.symmetry_marker(v, folds=i, modifier="rotoinversion", s=marker_size) markers = ax.collections assert len(markers) == 43 diff --git a/orix/tests/quaternion/test_orientation.py b/orix/tests/quaternion/test_orientation.py index a0e4df286..d8b72f124 100644 --- a/orix/tests/quaternion/test_orientation.py +++ b/orix/tests/quaternion/test_orientation.py @@ -37,14 +37,14 @@ T, O, Oh, - get_point_groups, + PointGroups, ) from orix.vector import Miller, Vector3d # isort: on # fmt: on -groups = get_point_groups("all") -proper_groups = get_point_groups("proper") +groups = PointGroups.get_set("permutations_repeated") +proper_groups = PointGroups.get_set("proper_groups") @pytest.fixture diff --git a/orix/tests/quaternion/test_symmetry.py b/orix/tests/quaternion/test_symmetry.py index 67dff03de..a26c3d63b 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -34,15 +34,16 @@ C3, S6, D3x, D3y, D3, C3v, D3d, # trigonal C6, C3h, C6h, D6, C6v, D3h, D6h, # hexagonal T, Th, O, Td, Oh, # cubic - spacegroup2pointgroup_dict, - get_point_groups, + PointGroups, _get_unique_symmetry_elements ) # isort: on # fmt: on from orix.vector import Vector3d -_groups = get_point_groups("all") +# fmt: onfrom orix.vector import Vector3d + +_groups = PointGroups.get_set("permutations") @pytest.fixture(params=[(1, 2, 3)]) @@ -58,13 +59,13 @@ def all_symmetries(request): @pytest.mark.parametrize( "symmetry, vector, expected", [ - (Ci, (1, 2, 3), [(1, 2, 3), (-1, -2, -3)]), - (Csx, (1, 2, 3), [(1, 2, 3), (-1, 2, 3)]), - (Csy, (1, 2, 3), [(1, 2, 3), (1, -2, 3)]), - (Csz, (1, 2, 3), [(1, 2, 3), (1, 2, -3)]), - (C2, (1, 2, 3), [(1, 2, 3), (-1, -2, 3)]), + (PointGroups.get("Ci"), (1, 2, 3), [(1, 2, 3), (-1, -2, -3)]), + (PointGroups.get("Csx"), (1, 2, 3), [(1, 2, 3), (-1, 2, 3)]), + (PointGroups.get("Csy"), (1, 2, 3), [(1, 2, 3), (1, -2, 3)]), + (PointGroups.get("Csz"), (1, 2, 3), [(1, 2, 3), (1, 2, -3)]), + (PointGroups.get("C2"), (1, 2, 3), [(1, 2, 3), (-1, -2, 3)]), ( - C2v, + PointGroups.get("C2v"), (1, 2, 3), [ (1, 2, 3), @@ -74,7 +75,7 @@ def all_symmetries(request): ], ), ( - C4v, + PointGroups.get("C4v"), (1, 2, 3), [ (1, 2, 3), @@ -88,7 +89,7 @@ def all_symmetries(request): ], ), ( - D4, + PointGroups.get("D4"), (1, 2, 3), [ (1, 2, 3), @@ -102,7 +103,7 @@ def all_symmetries(request): ], ), ( - C6, + PointGroups.get("C6"), (1, 2, 3), [ (1, 2, 3), @@ -114,7 +115,7 @@ def all_symmetries(request): ], ), ( - Td, + PointGroups.get("Td"), (1, 2, 3), [ (1, 2, 3), @@ -144,7 +145,7 @@ def all_symmetries(request): ], ), ( - Oh, + PointGroups.get("Oh"), (1, 2, 3), [ (1, 2, 3), @@ -442,14 +443,15 @@ def test_no_symm_fundamental_zone(): def test_get_point_group(): """Makes sure all the ints from 1 to 230 give answers.""" + sg2pg = PointGroups._spacegroup2pointgroup_dict for sg_number in np.arange(1, 231): proper_pg = get_point_group(sg_number, proper=True) assert proper_pg in [C1, C2, C3, C4, C6, D2, D3, D4, D6, O, T] sg = GetSpaceGroup(sg_number) pg = get_point_group(sg_number, proper=False) - assert proper_pg == spacegroup2pointgroup_dict[sg.point_group_name]["proper"] - assert pg == spacegroup2pointgroup_dict[sg.point_group_name]["improper"] + assert proper_pg == sg2pg[sg.point_group_name]["proper"] + assert pg == sg2pg[sg.point_group_name]["improper"] def test_unique_symmetry_elements_subgroups(all_symmetries): @@ -516,33 +518,67 @@ def test_hash_persistence(): assert all(h1a == h2a for h1a, h2a in zip(h1, h2)) -@pytest.mark.parametrize("pg", [C1, C4, Oh]) -def test_symmetry_plot(pg): +@pytest.mark.parametrize( + "pg, n_elements", + [ + (C1, 2), + (Ci, 4), + (S4, 4), + (S6, 4), + (D3, 16), + (T, 30), + (Th, 20), + (O, 52), + (Td, 20), + (Oh, 36), + ], +) +def test_symmetry_plot(pg, n_elements): + plt.close("all") fig = pg.plot(return_figure=True) assert isinstance(fig, plt.Figure) assert len(fig.axes) == 1 ax = fig.axes[0] + assert len(ax.collections) == n_elements + + fig, plt_axis = plt.subplots(subplot_kw={"projection": "stereographic"}) + v = Vector3d.from_polar(0.5, 0.3) + v_symm = pg.outer(v) + pg.plot(v, plt_axis=plt_axis) + c1 = plt_axis.collections[-1] + c2 = plt_axis.collections[-2] + assert len(c1.get_offsets()) == np.sum(v_symm.z >= 0) + if pg.size > 1: + assert len(c2.get_offsets()) == np.sum(v_symm.z < 0) + plt.close("all") - c0 = ax.collections[0] - assert len(c0.get_offsets()) == np.count_nonzero(~pg.improper) - assert c0.get_label().lower() == "upper" - if not pg.is_proper: - c1 = ax.collections[1] - assert len(c1.get_offsets()) == np.count_nonzero(pg.improper) - assert c1.get_label().lower() == "lower" - - assert len(ax.texts) == 2 - assert ax.texts[0].get_text() == "$e_1$" - assert ax.texts[1].get_text() == "$e_2$" - plt.close("all") +class TestPointGroups: + def test_repr(self): + pg_list = PointGroups.get_set("permutations_repeated") + assert len(pg_list) == 44 + pg_list = PointGroups.get_set("GROUPS") + assert len(pg_list) == 32 + docs = pg_list.__repr__() + lines = docs.split("\n") + assert len(lines) == 34 + for l in lines[2:]: + assert len(l.split()) == 11 + def test_init_checks(self): + with pytest.raises(ValueError): + x = PointGroups("banana") + with pytest.raises(ValueError): + x = PointGroups(["banana"]) -@pytest.mark.parametrize("symmetry", [C1, C4, Oh]) -def test_symmetry_plot_raises(symmetry): - with pytest.raises(TypeError, match="Orientation must be a Rotation instance"): - _ = symmetry.plot(return_figure=True, orientation="test") + def test_get(self): + assert PointGroups.get("c1").laue.name == "-1" + assert PointGroups.get("C1").laue.name == "-1" + assert PointGroups.get("32").laue.name == "-3m" + assert PointGroups.get(230).laue.name == "m-3m" + with pytest.raises(ValueError): + x = PointGroups.get("banana") class TestFundamentalSectorFromSymmetry: @@ -893,24 +929,24 @@ def test_equality(symmetry): @pytest.mark.parametrize( ["subset", "length", "proper_count"], [ - ["unique", 32, 11], - ["all", 37, 14], - ["all_repeated", 44, 17], - ["proper", 11, 11], - ["proper_all", 14, 14], + ["groups", 32, 11], + ["permutations", 37, 14], + ["permutations_repeated", 44, 17], + ["proper_groups", 11, 11], + ["proper_permutations", 14, 14], ["laue", 11, 0], ["procedural", 32, 11], ], ) def test_get_point_groups(subset, length, proper_count): # check that we get the expected number of proper and total point groups. - group = get_point_groups(subset) + group = PointGroups.get_set(subset) assert len(group) == length assert np.sum([x.is_proper for x in group]) == proper_count def test_get_point_groups_unique(): - group = get_point_groups() + group = PointGroups.get_set("groups") # this is just a check to see if each element is unique, and if there are # 32 of them. assert np.all( @@ -929,7 +965,7 @@ def test_get_point_groups_unique(): ) # additional test that nonsense returns nonsense with pytest.raises(ValueError): - get_point_groups("banana") + PointGroups.get_set("banana") class TestLaueGroup: From 1c381b3a48d23365f4f310ed6dee49b189cd5571 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Wed, 9 Jul 2025 17:55:52 -0600 Subject: [PATCH 30/40] typos --- examples/stereographic_projection/plot_symmetry_operations.py | 2 +- orix/quaternion/symmetry.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 22806345e..af843573e 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -4,7 +4,7 @@ ======================== This example shows how stereographic projections with symmetry operators can be -automatically generated using orix for the 32 crystallographic point groups. +automatically generated for the 32 crystallographic point groups. The ordering follows the one given in section 9.2 of "Structures of Materials" (DeGraef et.al, 2nd edition, 2012), starting with the cyclic groups, then the diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index e877a21f0..23aa352aa 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1370,8 +1370,8 @@ def get(name: Literal[PointGroups._point_group_names]): # If the name still cannot be found, return a ValueError except ValueError: raise ValueError( - f"'name' must be one of {", ".join(map(str, pg_dict.keys()))}," - + f" {", ".join(map(str, pg_dict_s.keys()))}, or must be a string or " + f"'name' must be one of {', '.join(map(str, pg_dict.keys()))}," + + f" {', '.join(map(str, pg_dict_s.keys()))}, or must be a string or " + "integer recognized by diffpy.structure.spacegroups.GetSpaceGroup" + f". name = '{name}' is not a valid value." ) From c541b3194046315b88fd36e337cb44d1ac510bfb Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Wed, 9 Jul 2025 18:20:34 -0600 Subject: [PATCH 31/40] formatting --- examples/stereographic_projection/plot_symmetry_operations.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index af843573e..5fe6b6029 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -25,6 +25,7 @@ """ import matplotlib.pyplot as plt + import orix.plot from orix.quaternion.symmetry import PointGroups from orix.vector import Vector3d From 8e38b67e5d1d071819bb5d40accc4762a54a0127 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Wed, 9 Jul 2025 18:26:30 -0600 Subject: [PATCH 32/40] more formatting --- orix/quaternion/symmetry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 23aa352aa..a3a939a13 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1488,7 +1488,7 @@ def get_set(self, name: Literal[PointGroups._subset_names] = "groups"): return PointGroups(self._pg_sets[name.lower()]) # if the name doesn't exist, return a ValueError raise ValueError( - f"'name' must be one of {", ".join(map(str, pg_opts))}, not '{name}'" + f"'name' must be one of {', '.join(map(str, pg_opts))}, not '{name}'" ) From 1460020cbc408b2a9cfab12ee9da01339d02d21d Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 17 Jul 2025 17:04:12 -0600 Subject: [PATCH 33/40] Documentation improvements --- orix/plot/stereographic_plot.py | 35 ++-- orix/quaternion/symmetry.py | 350 +++++++++++++++++--------------- 2 files changed, 203 insertions(+), 182 deletions(-) diff --git a/orix/plot/stereographic_plot.py b/orix/plot/stereographic_plot.py index 3926d07ef..a709dc3f1 100644 --- a/orix/plot/stereographic_plot.py +++ b/orix/plot/stereographic_plot.py @@ -978,8 +978,8 @@ def _order_in_hemisphere(polar: np.ndarray, pole: int) -> Union[np.ndarray, None class _SymmetryMarker: - """A class for creating Symmetry element markers. Intended for making - stereographic plots of the crystallographic point groups. + """A class for creating Symmetry element markers. Intended for + making stereographic plots of the crystallographic point groups. Intended to be used indirectly in :func:`~orix.plot.StereographicPlot.symmetry_marker`. @@ -987,18 +987,22 @@ class _SymmetryMarker: Parameters ---------- v - Vector(s) giving the positions of markers in a stereographic plot + Vector(s) giving marker positions in the stereographic plot. size - Value(s) passed to matplotlib to determine relative marker size + Value(s) passed to matplotlib to determine relative marker + size. folds - The rotational symmetry (typically 1, 2, 3, 4, or 6) that determines the - symmetry marker's shape(circle, elliplse, triangle, square, or hex)'. + The rotational symmetry (typically 1, 2, 3, 4, or 6) that + determines the symmetry marker's shape + (circle, elliplse, triangle, square, or hex). modifier - Determines what alterations, if any, should be added to the marker. "none" or - None will add nothing. "rotoinversion" will add a white rotoinversion symbol - inside the marker, which for even-fold rotations is a polygon with half as - many corners, and for an odd-fold rotation is a white dot. "inversion" will - add an inversion symbol, which is a white dot. The default is "none". + Determines what alterations, if any, should be added to + the marker. None (the default) or "rotation" will add + nothing. "rotoinversion" will add a white rotoinversion + symbol inside the marker, which for even-fold rotations is a + polygon with half as many corners as the marker, and for an + odd-fold rotation is a white dot. "inversion" will add an + inversion symbol, which is a white dot. The default is None. """ def __init__( @@ -1006,9 +1010,7 @@ def __init__( v: Vector3d | np.ndarray | list | tuple, size: int = 1, folds: Literal[1, 2, 3, 4, 6] = 2, - modifier: Literal[ - None, "none", "rotation", "rotoinversion", "inversion" - ] = "none", + modifier: Literal[None, "rotation", "rotoinversion", "inversion"] = None, ): fold_opt = [1, 2, 3, 4, 6] if folds not in fold_opt: @@ -1022,7 +1024,8 @@ def __init__( mod_opt = [None, "none", "rotation", "rotoinversion", "inversion"] if modifier not in mod_opt: raise ValueError( - f"Modifier must be one of {', '.join(map(str, mod_opt))}, not {modifier}" + f"Modifier must be one of {', '.join(map(str, mod_opt))}," + + "not {modifier}" ) self._vector = Vector3d(v) self._size = size @@ -1045,7 +1048,7 @@ def n(self) -> int: """Number of symmetry markers.""" return self._vector.size - def __iter__(self): + def __iter__(self) -> [Vector3d, mpath.Path, np.float64]: """Dunder function for iterating through multiple markers defined within a single _SymmetryMarker Class. diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index a3a939a13..2fb77d276 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -60,7 +60,7 @@ class Symmetry(Rotation): """ name = "" - _s_name = "" + _schoenflies = "" # -------------------------- Properties -------------------------- # @@ -173,7 +173,6 @@ def system(self) -> str | None: system ``None`` is returned if the symmetry name is not recognized. """ - # fmt: off name = self.name if name in ["1", "-1"]: return "triclinic" @@ -191,7 +190,6 @@ def system(self) -> str | None: return "cubic" else: return None - # fmt: on @property def _tuples(self) -> set: @@ -419,119 +417,6 @@ def from_generators(cls, *generators: Rotation) -> Symmetry: # --------------------- Other public methods --------------------- # - def _get_symmetry_elements(self) -> list: - """Returns all the crystallographically unique axes and their associated - symmetry elements (mirrors, rotations, rotoinversions, etc). - - Returns - ------- - axes Vector3d - Vector(s) that are each either parallel to an axis of rotation or - perpendicular to a mirror plane, or both. - is_mirror bool - Indicates whether each primary_axis is or is not perpendicular to a - mirror plane - s_type str ("inversion", "rotoinversion", "rotation", or"none") - Indicates whether the rotational symmetry associated with the axis - contains an inversion, a rotoinversion, purely rotational, or just an - 1-fold axis perpendicular to a mirror. - folds - Indicats the order of the rotational symmetry (1,2,3,4, or 6) - - Notes - ------- - This function does not return ALL the axes and angles, - (that function would be `Symmetry.to_axes_angles`), nor does it return - the minimum generating elements. Instead, it returns all the primary axes - plus information about the rotations, inversions, and/or mirrors associated - with each. - """ - # create masks to sort out which elements are what. - # proper elements - p_mask = ~self.improper - # mirror planes - m_mask = (np.abs(np.abs(self.angle) - np.pi) < 1e-4) * self.improper - # rotations (both proper and improper) - r_mask = np.abs(self.angle) > 1e-4 - # rotoinversions - roto_mask = r_mask * ~p_mask * ~m_mask - # the inversion symmetry - i_mask = (~r_mask) * self.improper - if np.sum(i_mask) > 0: - has_inversion = True - else: - has_inversion = False - - elements = [] - # Find the unique axis familes. - axis_families = (self.axis.in_fundamental_sector(self)).unique() - - # iterate through each primary axis and find what elements to add - for axis in axis_families: - # mask out just axes elements in the fundamental sector to avoid repeats - axis_mask = ( - np.sum(np.abs((self.axis.in_fundamental_sector(self) - axis).data), 1) - < 1e-4 - ) - m_flag = False - folds = 0 - s_type = "empty" - # check for mirrors - if np.any(m_mask * axis_mask): - m_flag = True - # check to see if there are only proper rotations - if np.all(p_mask[axis_mask]): - # This might just be the identity. - if not np.any(r_mask * axis_mask): - elements.append( - (copy(axis), m_flag, 1, "none"), - ) - continue - min_ang = np.abs(self[r_mask * axis_mask].angle).min() - folds = np.around(2 * np.pi / min_ang).astype(int) - elements.append( - (copy(axis), m_flag, folds, "rotation"), - ) - continue - # Check if there is a rotation with an inversion - elif has_inversion: - # this might just be the 1-fold inversion center - if not np.any(r_mask * axis_mask): - elements.append( - (copy(axis), m_flag, 1, "inversion"), - ) - continue - min_ang = np.abs(self[r_mask * axis_mask].angle).min() - folds = np.around(2 * np.pi / min_ang).astype(int) - elements.append( - (copy(axis), m_flag, folds, "inversion"), - ) - continue - # the only other important option is a rotoinversion - elif np.any(roto_mask[axis_mask]): - min_ang = np.abs(self[roto_mask * axis_mask].angle).min() - folds = np.around(2 * np.pi / min_ang).astype(int) - elements.append( - (copy(axis), m_flag, folds, "rotoinversion"), - ) - continue - # if it it not a rotational symmetry of any type, it's a mirror - else: - elements.append( - (copy(axis), m_flag, 1, "none"), - ) - # Finally, 3-fold rotations around the 111 create <110> mirrors - # not on the primary axes. These we can add by hand. - if np.any(np.abs(Vector3d([1, 1, 1]).angle_with(self.axis)) < 1e-4): - v = Vector3d([0, 1, 1]).in_fundamental_sector(self) - elements.append((v, True, 1, "none")) - # split the list of lists into 4 variables. - axes = [x[0] for x in elements] - is_mirror = [x[1] for x in elements] - s_type = [x[3] for x in elements] - folds = [x[2] for x in elements] - return axes, is_mirror, s_type, folds - def get_axis_orders(self) -> dict[Vector3d, int]: """Return a dictionary of every rotation axis and it's order (ie, folds)""" s = self[self.angle > 0] @@ -696,7 +581,7 @@ def plot( # add a default title if requested if show_name: - plt_axis.set_title(self._s_name + " ( " + self.name + " )") + plt_axis.set_title(self._schoenflies + " ( " + self.name + " )") # determine the symnmetry elements and plot them. elements = self._get_symmetry_elements() @@ -759,6 +644,139 @@ def plot( if return_figure: return fig + # ------------------------ private functions ------------------------- # + + def _get_symmetry_elements(self) -> (Vector3d, bool, str, int): + """Return all the crystallographically unique axes and their + associated symmetry elements (mirrors, rotations, + rotoinversions, etc). + + Returns + ------- + axes + Vector(s) that are parallel to an axis of rotation or + perpendicular to a mirror plane, or both. + is_mirror + Whether each axis is perpendicular to a mirror plane. + s_type + The type of rotational symmetry assoicated with the axis. + Options are "rotation", "rotoinversion", or "inversion". + folds + The order of the rotationinal symmetry, either 1,2,3,4, or + 6. 1 indicates an axis with no rotational symmetry, meaning + it has a mirror associated with it. + + Notes + ------- + This function does not return ALL the axes and angles, + (that function would be `Symmetry.to_axes_angles`), nor does it + return the minimum generating elements. Instead, it returns all + the primary axes plus information about the rotations, + inversions, and/or mirrors associated with each. + + The algorithm works by finding the crystallographically unique + rotation axes in a symmetry group, and for each one, searching + every symmetry operator sharing that axis. Based on each + subset, it determines if a mirror and/or inversion is present, + and if there is a rotation or rotoinversion. Based on the + choice of rotation or rotoinversion, the order of the + symmetry element (ie, the number of 'folds') is also found. + """ + # grab the absolute value of the angular component as a quick way + # to find mirrors, inversion, and identity elements + abs_angle = np.abs(self.angle) + # create True/False arrays to help sort out which elements are what. + # proper elements + is_proper = ~self.improper + # mirror planes + is_mirror = (np.abs(abs_angle - np.pi) < 1e-4) * self.improper + # rotations (both proper and improper) + is_rotation = abs_angle > 1e-4 + # rotoinversions + is_rotoinversion = is_rotation * self.improper * ~is_mirror + # the inversion symmetry + is_inversion = (~is_rotation) * self.improper + if np.sum(is_inversion) > 0: + has_inversion = True + else: + has_inversion = False + + # Find the symmetrically unique axes, and record which axes correspond + # to each unique representation. + unique_axes, unique_idx = (self.axis.in_fundamental_sector(self)).unique( + return_inverse=True + ) + + # iterate through each unique axis and determine the associated + # symmetry elements. + elements = [] + for i, axis in enumerate(unique_axes): + # mask out just axes elements in the fundamental sector to avoid repeats + is_axis = unique_idx == i + # track if a mirror is found, as it's the easiest way to tell the + # difference between a rotoinversion and a rotation plus inversion. + m_flag = False + # set 'folds' and 's_type' to illegal parameters. This way, if an + # edge case appears where the following if/then search does not + # overwrite their values, it will be obvious in the final results. + folds = 0 + s_type = "empty" + # check for mirrors + if np.any(is_mirror * is_axis): + m_flag = True + # check to see if there are only proper rotations + if np.all(is_proper[is_axis]): + # This might just be the identity. + if not np.any(is_rotation * is_axis): + elements.append( + (copy(axis), m_flag, None, 1), + ) + continue + min_ang = np.abs(self[is_rotation * is_axis].angle).min() + folds = np.around(2 * np.pi / min_ang).astype(int) + elements.append( + (copy(axis), m_flag, "rotation", folds), + ) + continue + # Check if there is a rotation with an inversion + elif has_inversion: + # this might just be the 1-fold inversion center + if not np.any(is_rotation * is_axis): + elements.append( + (copy(axis), m_flag, "inversion", 1), + ) + continue + min_ang = np.abs(self[is_rotation * is_axis].angle).min() + folds = np.around(2 * np.pi / min_ang).astype(int) + elements.append( + (copy(axis), m_flag, "inversion", folds), + ) + continue + # the only other important option is a rotoinversion + elif np.any(is_rotoinversion[is_axis]): + min_ang = np.abs(self[is_rotoinversion * is_axis].angle).min() + folds = np.around(2 * np.pi / min_ang).astype(int) + elements.append( + (copy(axis), m_flag, "rotoinversion", folds), + ) + continue + # if it it not a rotational symmetry of any type, it's a mirror + else: + elements.append( + (copy(axis), m_flag, None, 1), + ) + # Finally, 3-fold rotations around the 111 create <110> mirrors + # not on the primary axes. These we can add by hand. + if np.any(np.abs(Vector3d([1, 1, 1]).angle_with(self.axis)) < 1e-4): + v = Vector3d([0, 1, 1]).in_fundamental_sector(self) + elements.append((v, True, None, 1)) + # split the list of lists into 4 variables. + axes = [x[0] for x in elements] + is_mirror = [x[1] for x in elements] + s_type = [x[2] for x in elements] + folds = [x[3] for x in elements] + return axes, is_mirror, s_type, folds + # ---------------- Proceedural definitions of Point Groups ---------------- # # NOTE: ORIX uses Schoenflies symbols to define point groups. This is partly @@ -790,16 +808,16 @@ def plot( # Triclinic C1 = Symmetry((1, 0, 0, 0)) C1.name = "1" -C1._s_name = "C1" +C1._schoenflies = "C1" Ci = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) Ci.improper = [0, 1] Ci.name = "-1" -Ci._s_name = "Ci" +Ci._schoenflies = "Ci" # include redundant point group S2 == Ci S2 = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) S2.improper = [0, 1] S2.name = "-1" -S2._s_name = "S2" +S2._schoenflies = "S2" # Special generators _mirror_xy = Symmetry([(1, 0, 0, 0), (0, 0.75**0.5, -(0.75**0.5), 0)]) @@ -809,53 +827,53 @@ def plot( # 2-fold rotations C2x = Symmetry([(1, 0, 0, 0), (0, 1, 0, 0)]) C2x.name = "211" -C2x._s_name = "C2x" +C2x._schoenflies = "C2x" C2y = Symmetry([(1, 0, 0, 0), (0, 0, 1, 0)]) C2y.name = "121" -C2y._s_name = "C2y" +C2y._schoenflies = "C2y" C2z = Symmetry([(1, 0, 0, 0), (0, 0, 0, 1)]) C2z.name = "112" -C2z._s_name = "C2z" +C2z._schoenflies = "C2z" C2 = Symmetry(C2z) C2.name = "2" -C2._s_name = "C2" +C2._schoenflies = "C2" # included redundant point group D1 == C2 D1 = Symmetry(C2z) D1.name = "2" -D1._s_name = "D1" +D1._schoenflies = "D1" # Mirrors Csx = Symmetry([(1, 0, 0, 0), (0, 1, 0, 0)]) Csx.improper = [0, 1] Csx.name = "m11" -Csx._s_name = "Csx" +Csx._schoenflies = "Csx" Csy = Symmetry([(1, 0, 0, 0), (0, 0, 1, 0)]) Csy.improper = [0, 1] Csy.name = "1m1" -Csy._s_name = "Csy" +Csy._schoenflies = "Csy" Csz = Symmetry([(1, 0, 0, 0), (0, 0, 0, 1)]) Csz.improper = [0, 1] Csz.name = "11m" -Csz._s_name = "Csz" +Csz._schoenflies = "Csz" Cs = Symmetry(Csz) Cs.name = "m" -Cs._s_name = "Cs" +Cs._schoenflies = "Cs" # Monoclinic C2h = Symmetry.from_generators(C2, Cs) C2h.name = "2/m" -C2h._s_name = "C2h" +C2h._schoenflies = "C2h" # Orthorhombic D2 = Symmetry.from_generators(C2z, C2x, C2y) D2.name = "222" -D2._s_name = "D2" +D2._schoenflies = "D2" C2v = Symmetry.from_generators(C2z, Csx) C2v.name = "mm2" -C2v._s_name = "C2v" +C2v._schoenflies = "C2v" D2h = Symmetry.from_generators(Csz, Csx, Csy) D2h.name = "mmm" -D2h._s_name = "D2h" +D2h._schoenflies = "D2h" # 4-fold rotations C4x = Symmetry( @@ -884,33 +902,33 @@ def plot( ) C4 = Symmetry(C4z) C4.name = "4" -C4._s_name = "C4" +C4._schoenflies = "C4" # Tetragonal S4 = Symmetry(C4) S4.improper = [0, 1, 0, 1] S4.name = "-4" -S4._s_name = "S4" +S4._schoenflies = "S4" # include redundant point group C4i == S4 C4i = Symmetry(C4) C4i.improper = [0, 1, 0, 1] C4i.name = "-4" -C4i._s_name = "C4i" +C4i._schoenflies = "C4i" C4h = Symmetry.from_generators(C4, Cs) C4h.name = "4/m" -C4h._s_name = "C4h" +C4h._schoenflies = "C4h" D4 = Symmetry.from_generators(C4, C2x, C2y) D4.name = "422" -D4._s_name = "D4" +D4._schoenflies = "D4" C4v = Symmetry.from_generators(C4, Csx) C4v.name = "4mm" -C4v._s_name = "C4v" +C4v._schoenflies = "C4v" D2d = Symmetry.from_generators(D2, _mirror_xy) D2d.name = "-42m" -D2d._s_name = "D2d" +D2d._schoenflies = "D2d" D4h = Symmetry.from_generators(C4h, Csx, Csy) D4h.name = "4/mmm" -D4h._s_name = "D4h" +D4h._schoenflies = "D4h" # 3-fold rotations C3x = Symmetry([(1, 0, 0, 0), (0.5, 0.75**0.5, 0, 0), (0.5, -(0.75**0.5), 0, 0)]) @@ -918,71 +936,71 @@ def plot( C3z = Symmetry([(1, 0, 0, 0), (0.5, 0, 0, 0.75**0.5), (0.5, 0, 0, -(0.75**0.5))]) C3 = Symmetry(C3z) C3.name = "3" -C3._s_name = "C3" +C3._schoenflies = "C3" # Trigonal C3i = Symmetry.from_generators(C3, Ci) C3i.name = "-3" -C3i._s_name = "C3i" +C3i._schoenflies = "C3i" # include redundant point group S6==C3i S6 = Symmetry.from_generators(C3, Ci) S6.name = "-3" -S6._s_name = "S6" +S6._schoenflies = "S6" D3x = Symmetry.from_generators(C3, C2x) D3x.name = "321" -D3x._s_name = "D3x" +D3x._schoenflies = "D3x" D3y = Symmetry.from_generators(C3, C2y) D3y.name = "312" -D3y._s_name = "D3y" +D3y._schoenflies = "D3y" D3 = Symmetry(D3x) D3.name = "32" -D3._s_name = "D3" +D3._schoenflies = "D3" C3v = Symmetry.from_generators(C3, Csx) C3v.name = "3m" -C3v._s_name = "C3v" +C3v._schoenflies = "C3v" D3d = Symmetry.from_generators(S6, Csx) D3d.name = "-3m" -D3d._s_name = "D3d" +D3d._schoenflies = "D3d" # Hexagonal C6 = Symmetry.from_generators(C3, C2) C6.name = "6" -C6._s_name = "C6" +C6._schoenflies = "C6" C3h = Symmetry.from_generators(C3, Cs) C3h.name = "-6" -C3h._s_name = "C3h" +C3h._schoenflies = "C3h" C6h = Symmetry.from_generators(C6, Cs) C6h.name = "6/m" -C6h._s_name = "C6h" +C6h._schoenflies = "C6h" D6 = Symmetry.from_generators(C6, C2x, C2y) D6.name = "622" -D6._s_name = "D6" +D6._schoenflies = "D6" C6v = Symmetry.from_generators(C6, Csx) C6v.name = "6mm" -C6v._s_name = "C6v" +C6v._schoenflies = "C6v" D3h = Symmetry.from_generators(C3, C2y, Csz) D3h.name = "-6m2" -D3h._s_name = "-D3h" +D3h._schoenflies = "-D3h" D6h = Symmetry.from_generators(D6, Csz) D6h.name = "6/mmm" -D6h._s_name = "D6h" +D6h._schoenflies = "D6h" # Cubic T = Symmetry.from_generators(C2, _cubic) T.name = "23" -T._s_name = "T" +T._schoenflies = "T" Th = Symmetry.from_generators(T, Ci) Th.name = "m-3" -Th._s_name = "Th" +Th._schoenflies = "Th" O = Symmetry.from_generators(C4, _cubic, C2x) O.name = "432" -O._s_name = "O" +O._schoenflies = "O" Td = Symmetry.from_generators(T, _mirror_xy) Td.name = "-43m" -Td._s_name = "Td" +Td._schoenflies = "Td" Oh = Symmetry.from_generators(O, Ci) Oh.name = "m-3m" -Oh._s_name = "Oh" +Oh._schoenflies = "Oh" class PointGroups(list): @@ -1313,7 +1331,7 @@ def __repr__(self): "| " + "| ".join( [ - x._s_name.ljust(6), + x._schoenflies.ljust(6), x.system.ljust(13), x.name.ljust(6), x.laue.name.ljust(6), @@ -1361,7 +1379,7 @@ def get(name: Literal[PointGroups._point_group_names]): if str(name).lower() in pg_dict.keys(): return pg_dict[name.lower()] # repeat check with Shoenflies notation - pg_dict_s = dict([(x._s_name.lower(), x) for x in pgs]) + pg_dict_s = dict([(x._schoenflies.lower(), x) for x in pgs]) if str(name).lower() in pg_dict_s.keys(): return pg_dict_s[name.lower()] # If the name doesn't exist in orix, try diffpy @@ -1551,10 +1569,10 @@ def _get_laue_group_name(name: str) -> str | None: raise ValueError(f"{name} is not a valid point group name") # if the matching point group as a Schoenflies name that ends in an x,y, or z, # it's a permutation of a point group. trade it for an unpermutated one. - if np.isin(g._s_name[-1], ["x", "y", "z"]): - s_name = g._s_name[:-1] + if np.isin(g._schoenflies[-1], ["x", "y", "z"]): + s_name = g._schoenflies[:-1] for g in PointGroups._pg_sets["permutations_repeated"]: - if g._s_name == s_name: + if g._schoenflies == s_name: break # add an inversion to get the laue group. g_laue = _get_unique_symmetry_elements(g, Ci) From ae62fcf0fb1ddc617dabd3320e98e442fa3c2610 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:48:07 -0600 Subject: [PATCH 34/40] Addressing feedback on #563 --- .../plot_symmetry_operations.py | 7 +- orix/crystal_map/phase_list.py | 9 +- orix/quaternion/symmetry.py | 1017 ++++++++--------- orix/tests/quaternion/test_symmetry.py | 31 +- 4 files changed, 518 insertions(+), 546 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 5fe6b6029..df7ad9efe 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -13,7 +13,7 @@ groups are made. The plots themselves as well as their labels follow the standards given in -Table 10.2.2 of the "International Tables of Crystallography, Volume A" (ITOC). +Table 10.2.2 of the "International Tables for Crystallography, Volume A" (ITC). Both the nomenclature and marker styles thus differ slightly from some textbooks, as there are some arbitrary convention choices in both Schoenflies notation and marker styles. @@ -33,6 +33,9 @@ # create a list of the 32 crystallographic point groups point_groups = PointGroups.get_set("procedural") +# show the table of symmetry information +print(point_groups) + # prepare the plots fig, ax = plt.subplots( 4, 8, subplot_kw={"projection": "stereographic"}, figsize=[14, 10] @@ -43,4 +46,4 @@ v = Vector3d.from_polar(65, 80, degrees=True) # Iterate through the 32 Point groups for i, pg in enumerate(point_groups): - pg.plot(asymetric_vector=v, plt_axis=ax[i], itoc_style=True) + pg.plot(asymmetric_vector=v, ax=ax[i]) diff --git a/orix/crystal_map/phase_list.py b/orix/crystal_map/phase_list.py index d76d269c9..1519946f8 100644 --- a/orix/crystal_map/phase_list.py +++ b/orix/crystal_map/phase_list.py @@ -33,7 +33,12 @@ import matplotlib.colors as mcolors import numpy as np -from orix.quaternion.symmetry import _EDAX_POINT_GROUP_ALIASES, PointGroups, Symmetry +from orix.quaternion.symmetry import ( + _EDAX_POINT_GROUP_ALIASES, + PointGroups, + Symmetry, + _point_groups_dictionary, +) from orix.vector import Miller, Vector3d # All named Matplotlib colors (tableau and xkcd already lower case hex) @@ -236,7 +241,7 @@ def point_group(self) -> Symmetry | None: @point_group.setter def point_group(self, value: int | str | Symmetry | None) -> None: """Set the point group.""" - groups = PointGroups._pg_sets["permutations_repeated"] + groups = _point_groups_dictionary["permutations_repeated"] if isinstance(value, int): value = str(value) if isinstance(value, str): diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index b2375fb83..b94d5076e 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -19,14 +19,13 @@ from __future__ import annotations -from copy import copy -from typing import TYPE_CHECKING, Literal, Union +from typing import TYPE_CHECKING, Generator, Literal, Union from diffpy.structure.spacegroups import GetSpaceGroup import matplotlib.pyplot as plt import numpy as np -from orix._util import deprecated +from orix._util import deprecated, deprecated_argument from orix.quaternion.rotation import Rotation from orix.vector.vector3d import Vector3d @@ -77,7 +76,7 @@ def is_proper(self) -> bool: @property def subgroups(self) -> list[Symmetry]: """Return the list groups that are subgroups of this group.""" - groups = PointGroups._pg_sets["permutations"] + groups = _point_groups_dictionary["permutations"] return [g for g in groups if g._tuples <= self._tuples] @property @@ -419,121 +418,9 @@ def from_generators(cls, *generators: Rotation) -> Symmetry: # --------------------- Other public methods --------------------- # - def _get_symmetry_elements(self) -> list: - """Returns all the crystallographically unique axes and their associated - symmetry elements (mirrors, rotations, rotoinversions, etc). - - Returns - ------- - axes Vector3d - Vector(s) that are each either parallel to an axis of rotation or - perpendicular to a mirror plane, or both. - is_mirror bool - Indicates whether each primary_axis is or is not perpendicular to a - mirror plane - s_type str ("inversion", "rotoinversion", "rotation", or"none") - Indicates whether the rotational symmetry associated with the axis - contains an inversion, a rotoinversion, purely rotational, or just an - 1-fold axis perpendicular to a mirror. - folds - Indicats the order of the rotational symmetry (1,2,3,4, or 6) - - Notes - ------- - This function does not return ALL the axes and angles, - (that function would be `Symmetry.to_axes_angles`), nor does it return - the minimum generating elements. Instead, it returns all the primary axes - plus information about the rotations, inversions, and/or mirrors associated - with each. - """ - # create masks to sort out which elements are what. - # proper elements - p_mask = ~self.improper - # mirror planes - m_mask = (np.abs(np.abs(self.angle) - np.pi) < 1e-4) * self.improper - # rotations (both proper and improper) - r_mask = np.abs(self.angle) > 1e-4 - # rotoinversions - roto_mask = r_mask * ~p_mask * ~m_mask - # the inversion symmetry - i_mask = (~r_mask) * self.improper - if np.sum(i_mask) > 0: - has_inversion = True - else: - has_inversion = False - - elements = [] - # Find the unique axis familes. - axis_families = (self.axis.in_fundamental_sector(self)).unique() - - # iterate through each primary axis and find what elements to add - for axis in axis_families: - # mask out just axes elements in the fundamental sector to avoid repeats - axis_mask = ( - np.sum(np.abs((self.axis.in_fundamental_sector(self) - axis).data), 1) - < 1e-4 - ) - m_flag = False - folds = 0 - s_type = "empty" - # check for mirrors - if np.any(m_mask * axis_mask): - m_flag = True - # check to see if there are only proper rotations - if np.all(p_mask[axis_mask]): - # This might just be the identity. - if not np.any(r_mask * axis_mask): - elements.append( - (copy(axis), m_flag, 1, "none"), - ) - continue - min_ang = np.abs(self[r_mask * axis_mask].angle).min() - folds = np.around(2 * np.pi / min_ang).astype(int) - elements.append( - (copy(axis), m_flag, folds, "rotation"), - ) - continue - # Check if there is a rotation with an inversion - elif has_inversion: - # this might just be the 1-fold inversion center - if not np.any(r_mask * axis_mask): - elements.append( - (copy(axis), m_flag, 1, "inversion"), - ) - continue - min_ang = np.abs(self[r_mask * axis_mask].angle).min() - folds = np.around(2 * np.pi / min_ang).astype(int) - elements.append( - (copy(axis), m_flag, folds, "inversion"), - ) - continue - # the only other important option is a rotoinversion - elif np.any(roto_mask[axis_mask]): - min_ang = np.abs(self[roto_mask * axis_mask].angle).min() - folds = np.around(2 * np.pi / min_ang).astype(int) - elements.append( - (copy(axis), m_flag, folds, "rotoinversion"), - ) - continue - # if it it not a rotational symmetry of any type, it's a mirror - else: - elements.append( - (copy(axis), m_flag, 1, "none"), - ) - # Finally, 3-fold rotations around the 111 create <110> mirrors - # not on the primary axes. These we can add by hand. - if np.any(np.abs(Vector3d([1, 1, 1]).angle_with(self.axis)) < 1e-4): - v = Vector3d([0, 1, 1]).in_fundamental_sector(self) - elements.append((v, True, 1, "none")) - # split the list of lists into 4 variables. - axes = [x[0] for x in elements] - is_mirror = [x[1] for x in elements] - s_type = [x[3] for x in elements] - folds = [x[2] for x in elements] - return axes, is_mirror, s_type, folds - def get_axis_orders(self) -> dict[Vector3d, int]: - """Return a dictionary of every rotation axis and it's order (ie, folds)""" + """Return a dictionary of every rotation axis and it's order + (ie, folds)""" s = self[self.angle > 0] if s.size == 0: return {} @@ -543,7 +430,8 @@ def get_axis_orders(self) -> dict[Vector3d, int]: } def get_highest_order_axis(self) -> tuple[Vector3d, np.ndarray]: - """Return the highest order rotational axis and it's order (ie, folds)""" + """Return the highest order rotational axis and it's order + (ie, folds)""" axis_orders = self.get_axis_orders() if len(axis_orders) == 0: return Vector3d.zvector(), np.inf @@ -587,41 +475,47 @@ def fundamental_zone(self) -> Vector3d: sr = SphericalRegion(n.unique()) return sr + @deprecated_argument(name="orientation", since="1.4", removal="1.5") + @deprecated_argument(name="reproject_scatter_kwargs", since="1.4", removal="1.5") def plot( self, - asymetric_vector: Vector3d | None = None, + asymmetric_vector: Vector3d | None = None, + orientation: "Orientation | None" = None, show_name: bool = True, - plt_axis: plt.Axes | None = None, + ax: plt.Axes | None = None, return_figure: bool = True, marker_dict: dict = {}, + reproject_scatter_kwargs: dict | None = None, marker_size: float = 150.0, mirror_width: float = 2.0, asymetric_vector_dict: dict = {}, - asymmetric_vector_size: float = 10.0, - itoc_style=True, + asymmetric_vector_size: float = 50.0, ) -> plt.Figure | None: - """Creates a stereographic projection of symmetry operations in the group. - Can also plot symmetrically equivalent variations of orientations or vectors - to demonstrate the effect of symmetry operations. + """Creates a stereographic projection of symmetry operations + in the group. Can also plot symmetrically equivalent variations + of orientations or vectors to demonstrate the effect of + symmetry operations. Parameters ---------- - asymetric_vector - A marker will be added at the stereographic projection of this vector, - along with all it's symmetrically equivalent rotations. By default, no - vector will be plotted, and only rotation and mirror markers will be added - to the plot. + asymmetric_vector + A marker will be added at the stereographic projection of + this vector, along with all it's symmetrically equivalent + rotations. By default, no vector will be plotted, and only + rotation and mirror markers will be added to the plot. show_name - If True, add both the Schoenflies and Hermann-Mauguin names of the point - group to the title. - plt_axis - The matplotlib.Axis object into which to add the stereographic plot. - If None is passed, a new figure and axis will be generated. + If True, add both the Schoenflies and Hermann-Mauguin names + of the point group to the title. + ax + The matplotlib.Axis object into which to add the + stereographic plot. If None is passed, a new figure and + axis will be generated. return_figure If True, return the figure containing the plotting axis. marker_dict - A dictionary of arguments to modify how the symmetry markers are - generated. The following options are the overwritable defaults: + A dictionary of arguments to modify how the symmetry markers + are generated. The following options are the overwritable + defaults: 1: 'black' <-- 1-fold marker color 2: 'green' <-- 2-fold marker color 3: 'red' <-- 3-fold marker color @@ -629,24 +523,21 @@ def plot( 6: 'magenta' <-- 6-fold marker color 'm': 'blue' <-- 1-fold marker color marker_size - The size of the rotational makers to be added to the plot. This is - equivalent to the argument "s" in matplotlib.scatter + The size of the rotational makers to be added to the plot. + This is equivalent to the argument "s" in matplotlib.scatter mirror_width The width of the line used to draw the mirror planes. This is equivalent to the argument "linewidth" in matplotlib.plot asymetric_vector_dict - A dictionary of arguments to modify the asymetric_vector markers. - The following options are the overwritable defaults: + A dictionary of arguments to modify the asymetric_vector + markers. The following options are the overwritable defaults: 'upper_color': 'black' < -- Upper hemisphere marker color 'lower_color': 'grey' < -- Lower hemisphere marker color 'upper_marker': '+' < -- Upper hemisphere marker shape 'lower_marker': 'o' < -- Lower hemisphere marker shape asymmetric_vector_size - size of the markers used to plot the asymetric vector markers. - itoc_style - If True, the plot will follow the ITOC convention of not placing redundant - inversion symbols on rotation markers perpendicular to the viewing - direction. + Size of the markers used to plot the asymetric vector markers. + Default is 50. Returns ------- @@ -654,26 +545,48 @@ def plot( The created figure, returned if ``return_figure=True`` is passed as a keyword argument. - Notes - ----- + Examples + -------- - If users wish to have more control over their plots, this function can be used - to modify an existing plot, like so: + If users wish to have more control over their plots, this + function can be used to modify an existing plot, like so: >>> import matplotlib.pyplot as plt + >>> from orix.quaternion.symmetry import PointGroups + >>> from orix.vector import Vector3d + >>> import orix.plot + >>> >>> pg_Oh = PointGroups.get('m-3m') >>> v = Vector3d.random(10) >>> v_symm = pg_Oh.outer(v).flatten() >>> fig, ax = plt.subplots(1, 1, subplot_kw={"projection": "stereographic"}) - >>> pg_Oh.plot(plt_axis=ax, show_name = False) + >>> pg_Oh.plot(ax=ax, show_name=False) >>> ax.set_title("my cool custom title") >>> ax.scatter(v_symm) - In this way, keword arguments related to the plot, the title, the scattered - vector markers, and/or the symmetry markers can be individually altered as - desired. + In this way, keword arguments related to the plot, the title, + the scattered vector markers, and/or the symmetry markers can + be individually altered as desired. + + Notes + ----- + This function was designed to produce figures matching those in + International Tables for Crystallography Volume A, Table 10.2.2. + ITC includes certain design decisions not adhered to by other + sources and textbooks, such as excluding inversion markers from + axes in the same plane as the plot, or the rotational + orientation of the rotoinversion markers. """ + # depreciated input arguments. remove after 0.15 + if orientation is not None: # pragma: no cover + # 'orientation' was replaced with "asymmetric_vector", so if that + # input is not None, ignore 'orientation'. + if asymmetric_vector is None: + asymmetric_vector = orientation.axis + if reproject_scatter_kwargs is not None: # pragma: no cover + marker_dict.update(reproject_scatter_kwargs) + # import orix.plot so matplotlib knows what the stereographic projection is. import orix.plot @@ -689,14 +602,14 @@ def plot( # after resetting defaults, update color choices passed in via color_dict colors.update(marker_dict) # if the user did not pass in an axis, generate one - if plt_axis is None: - fig, plt_axis = plt.subplots(subplot_kw={"projection": "stereographic"}) + if ax is None: + fig, ax = plt.subplots(subplot_kw={"projection": "stereographic"}) else: - fig = plt_axis.get_figure() + fig = ax.get_figure() # add a default title if requested if show_name: - plt_axis.set_title(self._schoenflies + " ( " + self.name + " )") + ax.set_title(self._schoenflies + " ( " + self.name + " )") # determine the symnmetry elements and plot them. elements = self._get_symmetry_elements() @@ -705,22 +618,21 @@ def plot( if m: for mv in (self * v).unique(): m_circ = mv.get_circle() - plt_axis.plot(m_circ, color=colors["m"], linewidth=mirror_width) + ax.plot(m_circ, color=colors["m"], linewidth=mirror_width) # plot each symmetrically equivalent rotation element only once c = colors[f] if f > 1: for sv in (self * v).unique(): - # ITOC doesn't plot inversion or rotoinversion markers for + # ITC doesn't plot inversion or rotoinversion markers for # symmetry elements with axes perpendicular to the out-of plane # direction, as the information is redundant. z_ang = np.abs(sv.angle_with(Vector3d.zvector())) - is_perp = np.abs(z_ang - (np.pi / 2)) < 1e-4 - if itoc_style and is_perp: - plt_axis.symmetry_marker( + if np.abs(z_ang - (np.pi / 2)) < 1e-4: + ax.symmetry_marker( sv, folds=f, s=marker_size, color=c, modifier=None ) else: - plt_axis.symmetry_marker( + ax.symmetry_marker( sv, folds=f, s=marker_size, color=c, modifier=t ) # if this is the primary axis and there is no rotation but an inversion @@ -729,13 +641,11 @@ def plot( if t != "inversion": continue for sv in (self * v).unique(): - plt_axis.symmetry_marker( - sv, folds=f, s=marker_size, color=c, modifier=t - ) + ax.symmetry_marker(sv, folds=f, s=marker_size, color=c, modifier=t) # plot asymmetric markers if requested. - if asymetric_vector is not None: - v_symm = self.outer(asymetric_vector).flatten() + if asymmetric_vector is not None: + v_symm = self.outer(asymmetric_vector).flatten() vdict = { "upper_color": "black", "lower_color": "grey", @@ -744,12 +654,12 @@ def plot( } vdict.update(asymetric_vector_dict) mask = v_symm.z >= 0 - plt_axis.scatter( + ax.scatter( -1 * v_symm[~mask], marker=vdict["lower_marker"], c=vdict["lower_color"], ) - plt_axis.scatter( + ax.scatter( v_symm[mask], marker=vdict["upper_marker"], c=vdict["upper_color"], @@ -828,29 +738,25 @@ def _get_symmetry_elements(self) -> (Vector3d, bool, str, int): for i, axis in enumerate(unique_axes): # mask out just axes elements in the fundamental sector to avoid repeats is_axis = unique_idx == i - # track if a mirror is found, as it's the easiest way to tell the - # difference between a rotoinversion and a rotation plus inversion. - m_flag = False + # check for mirrors + m_flag = np.any(is_mirror * is_axis) # set 'folds' and 's_type' to illegal parameters. This way, if an # edge case appears where the following if/then search does not # overwrite their values, it will be obvious in the final results. folds = 0 s_type = "empty" - # check for mirrors - if np.any(is_mirror * is_axis): - m_flag = True # check to see if there are only proper rotations if np.all(is_proper[is_axis]): # This might just be the identity. if not np.any(is_rotation * is_axis): elements.append( - (copy(axis), m_flag, None, 1), + (axis, m_flag, None, 1), ) continue min_ang = np.abs(self[is_rotation * is_axis].angle).min() folds = np.around(2 * np.pi / min_ang).astype(int) elements.append( - (copy(axis), m_flag, "rotation", folds), + (axis, m_flag, "rotation", folds), ) continue # Check if there is a rotation with an inversion @@ -858,13 +764,13 @@ def _get_symmetry_elements(self) -> (Vector3d, bool, str, int): # this might just be the 1-fold inversion center if not np.any(is_rotation * is_axis): elements.append( - (copy(axis), m_flag, "inversion", 1), + (axis, m_flag, "inversion", 1), ) continue min_ang = np.abs(self[is_rotation * is_axis].angle).min() folds = np.around(2 * np.pi / min_ang).astype(int) elements.append( - (copy(axis), m_flag, "inversion", folds), + (axis, m_flag, "inversion", folds), ) continue # the only other important option is a rotoinversion @@ -872,13 +778,13 @@ def _get_symmetry_elements(self) -> (Vector3d, bool, str, int): min_ang = np.abs(self[is_rotoinversion * is_axis].angle).min() folds = np.around(2 * np.pi / min_ang).astype(int) elements.append( - (copy(axis), m_flag, "rotoinversion", folds), + (axis, m_flag, "rotoinversion", folds), ) continue # if it it not a rotational symmetry of any type, it's a mirror else: elements.append( - (copy(axis), m_flag, None, 1), + (axis, m_flag, None, 1), ) # Finally, 3-fold rotations around the 111 create <110> mirrors # not on the primary axes. These we can add by hand. @@ -904,20 +810,20 @@ def _get_symmetry_elements(self) -> (Vector3d, bool, str, int): # Additionally, there are 43 crystallographically valid Schonflies group # notations, but only 32 unique ones, meaning certain point groups have # redundant representations in Schonflies notation(S4==C4i, Ci==S2, S6==C3i, -# and C2==D1, for example). The International Tables of Crystallography, +# and C2==D1, for example). The International Tables for Crystallography (ITC), # Volume A, Section 12.1 defines the 32 standard representations, but a few of # the commonly used redundant ones are given below for convenience. -# Finally, while there are 32 Point groups, ITOC names several additional +# Finally, while there are 32 Point groups, ITC names several additional # projections for the non-centrosymmetric groups (ie, using x and/or y as the # rotation axis instead of z). These are included below as well, following -# the ITOC naming convention (for example, a 2-fold cyclic rotation around +# the ITC naming convention (for example, a 2-fold cyclic rotation around # the x axis instead of the z axis is called C2x.) # For more details on how point groups can be generated, the following three # resources lay out three different but equally valid approaches: # 1)"Structure of Materials", De Graef et al, Section 9.2 -# 2)"International Tables of Crystallography: Volume A" Section 12.1 +# 2)"International Tables for Crystallography: Volume A" Section 12.1 # 3)"Crystallogrpahic Texture and Group Representations", Chi-Sing Man,Ch2 # ---------------- Proceedural definitions of Point Groups ---------------- # @@ -931,20 +837,20 @@ def _get_symmetry_elements(self) -> (Vector3d, bool, str, int): # Additionally, there are 43 crystallographically valid Schonflies group # notations, but only 32 unique ones, meaning certain point groups have # redundant representations in Schonflies notation(S4==C4i, Ci==S2, S6==C3i, -# and C2==D1, for example). The International Tables of Crystallography, +# and C2==D1, for example). The International Tables for Crystallography, # Volume A, Section 12.1 defines the 32 standard representations, but a few of # the commonly used redundant ones are given below for convenience. -# Finally, while there are 32 Point groups, ITOC names several additional +# Finally, while there are 32 Point groups, ITC names several additional # projections for the non-centrosymmetric groups (ie, using x and/or y as the # rotation axis instead of z). These are included below as well, following -# the ITOC naming convention (for example, a 2-fold cyclic rotation around +# the ITC naming convention (for example, a 2-fold cyclic rotation around # the x axis instead of the z axis is called C2x.) # For more details on how point groups can be generated, the following three # resources lay out three different but equally valid approaches: # 1)"Structure of Materials", De Graef et al, Section 9.2 -# 2)"International Tables of Crystallography: Volume A" Section 12.1 +# 2)"International Tables for Crystallography: Volume A" Section 12.1 # 3)"Crystallogrpahic Texture and Group Representations", Chi-Sing Man,Ch2 # Triclinic @@ -1144,324 +1050,347 @@ def _get_symmetry_elements(self) -> (Vector3d, bool, str, int): Oh.name = "m-3m" Oh._schoenflies = "Oh" +# a dictionary of several common point group sets. This is used by +# PointGroups to create default subsets, as well as by Symmetry to +# determine the Laue and Proper groups/subgroups of classes. +_point_groups_dictionary = { + "permutations_repeated": [ + # Triclinic + C1, + Ci, + S2, # redundant + # Monoclinic + C2, + D1, # redundant + C2x, + C2y, + C2z, # redundant + Cs, + Csx, + Csy, + Csz, # redundant + C2h, + # Orthorhombic + D2, + C2v, + D2h, + # Tetragonal + C4, + S4, + C4i, # redundant + C4h, + D4, + C4v, + D2d, + D4h, + # Trigonal + C3, + C3i, + S6, # redundant + D3, + D3x, + D3y, + C3v, + D3d, + # Hexagonal + C6, + C3h, + C6h, + D6, + C6v, + D3h, + D6h, + # cubic + T, + Th, + O, + Td, + Oh, + ], + "permutations": [ + # Triclinic + C1, + Ci, + # Monoclinic + C2, + C2x, + C2y, + Cs, + Csx, + Csy, + C2h, + # Orthorhombic + D2, + C2v, + D2h, + # Tetragonal + C4, + S4, + C4h, + D4, + C4v, + D2d, + D4h, + # Trigonal + C3, + C3i, + D3, + D3y, + C3v, + D3d, + # Hexagonal + C6, + C3h, + C6h, + D6, + C6v, + D3h, + D6h, + # cubic + T, + Th, + O, + Td, + Oh, + ], + "groups": [ + # Triclinic + C1, + Ci, + # Monoclinic + C2, + Cs, + C2h, + # Orthorhombic + D2, + C2v, + D2h, + # Tetragonal + C4, + S4, + C4h, + D4, + C4v, + D2d, + D4h, + # Trigonal + C3, + C3i, + D3, + C3v, + D3d, + # Hexagonal + C6, + C3h, + C6h, + D6, + C6v, + D3h, + D6h, + # cubic + T, + Th, + O, + Td, + Oh, + ], + "proper_groups": [ + # Triclinic + C1, + # Monoclinic + C2, + # Orthorhombic + D2, + D4, + # Tetragonal + C4, + # Trigonal + C3, + D3, + # Hexagonal + C6, + D6, + # cubic + T, + O, + ], + "proper_permutations": [ + # Triclinic + C1, + # Monoclinic + C2, + C2x, + C2y, + # Orthorhombic + D2, + # Tetragonal + C4, + # Trigonal + C3, + D3, + D3x, + D3y, + # Hexagonal + C6, + D6, + # cubic + T, + O, + ], + "laue": [ + # Triclinic + Ci, + # Monoclinic + C2h, + # Orthorhombic + D2h, + D4h, + # Tetragonal + C4h, + # Trigonal + C3i, + D3d, + # Hexagonal + C6h, + D6h, + # cubic + Th, + Oh, + ], + "procedural": [ + # Cyclic + C1, + C2, + C3, + C4, + C6, + # Dihedral + D2, + D3, + D4, + D6, + # Cyclic plus inversion (\ba{n}) + Ci, + Cs, + C3i, + S4, + C3h, + # Cyclic plus perpendicular mirrors (n/m) + C2h, + C4h, + C6h, + # Cyclic plus vertical mirrors (nm) + C2v, + C3v, + C4v, + C6v, + # Dihedral plus diagonal mirrors (\bar{n} m) + D3d, + D2d, + D3h, + # Dihedral with vertical and perpendicular mirros (n/m m) + D2h, + D4h, + D6h, + # Combining cyclic (n1 n2) + T, + O, + # combining cyclic and mirrors + Th, + Td, + Oh, + ], +} + +# Dictionary used to convert diffpy.structure space group names to their +# equivalent orix.symmetry.Symmetry objects. +_spacegroup2pointgroup_dict = { + "PG1": {"proper": C1, "improper": C1}, + "PG1bar": {"proper": C1, "improper": Ci}, + "PG2": {"proper": C2, "improper": C2}, + "PGm": {"proper": C2, "improper": Cs}, + "PG2/m": {"proper": C2, "improper": C2h}, + "PG222": {"proper": D2, "improper": D2}, + "PGmm2": {"proper": C2, "improper": C2v}, + "PGmmm": {"proper": D2, "improper": D2h}, + "PG4": {"proper": C4, "improper": C4}, + "PG4bar": {"proper": C4, "improper": S4}, + "PG4/m": {"proper": C4, "improper": C4h}, + "PG422": {"proper": D4, "improper": D4}, + "PG4mm": {"proper": C4, "improper": C4v}, + "PG4bar2m": {"proper": D4, "improper": D2d}, + "PG4barm2": {"proper": D4, "improper": D2d}, + "PG4/mmm": {"proper": D4, "improper": D4h}, + "PG3": {"proper": C3, "improper": C3}, + "PG3bar": {"proper": C3, "improper": S6}, # Improper also known as C3i + "PG312": {"proper": D3, "improper": D3}, + "PG321": {"proper": D3, "improper": D3}, + "PG3m1": {"proper": C3, "improper": C3v}, + "PG31m": {"proper": C3, "improper": C3v}, + "PG3m": {"proper": C3, "improper": C3v}, + "PG3bar1m": {"proper": D3, "improper": D3d}, + "PG3barm1": {"proper": D3, "improper": D3d}, + "PG3barm": {"proper": D3, "improper": D3d}, + "PG6": {"proper": C6, "improper": C6}, + "PG6bar": {"proper": C6, "improper": C3h}, + "PG6/m": {"proper": C6, "improper": C6h}, + "PG622": {"proper": D6, "improper": D6}, + "PG6mm": {"proper": C6, "improper": C6v}, + "PG6barm2": {"proper": D6, "improper": D3h}, + "PG6bar2m": {"proper": D6, "improper": D3h}, + "PG6/mmm": {"proper": D6, "improper": D6h}, + "PG23": {"proper": T, "improper": T}, + "PGm3bar": {"proper": T, "improper": Th}, + "PG432": {"proper": O, "improper": O}, + "PG4bar3m": {"proper": T, "improper": Td}, + "PGm3barm": {"proper": O, "improper": Oh}, +} + class PointGroups(list): # make a lookup table of common subsets of Point Groups - _pg_sets = { - "permutations_repeated": [ - # Triclinic - C1, - Ci, - S2, # redundant - # Monoclinic - C2, - D1, # redundant - C2x, - C2y, - C2z, # redundant - Cs, - Csx, - Csy, - Csz, # redundant - C2h, - # Orthorhombic - D2, - C2v, - D2h, - # Tetragonal - C4, - S4, - C4i, # redundant - C4h, - D4, - C4v, - D2d, - D4h, - # Trigonal - C3, - C3i, - S6, # redundant - D3, - D3x, - D3y, - C3v, - D3d, - # Hexagonal - C6, - C3h, - C6h, - D6, - C6v, - D3h, - D6h, - # cubic - T, - Th, - O, - Td, - Oh, - ], - "permutations": [ - # Triclinic - C1, - Ci, - # Monoclinic - C2, - C2x, - C2y, - Cs, - Csx, - Csy, - C2h, - # Orthorhombic - D2, - C2v, - D2h, - # Tetragonal - C4, - S4, - C4h, - D4, - C4v, - D2d, - D4h, - # Trigonal - C3, - C3i, - D3, - D3y, - C3v, - D3d, - # Hexagonal - C6, - C3h, - C6h, - D6, - C6v, - D3h, - D6h, - # cubic - T, - Th, - O, - Td, - Oh, - ], - "groups": [ - # Triclinic - C1, - Ci, - # Monoclinic - C2, - Cs, - C2h, - # Orthorhombic - D2, - C2v, - D2h, - # Tetragonal - C4, - S4, - C4h, - D4, - C4v, - D2d, - D4h, - # Trigonal - C3, - C3i, - D3, - C3v, - D3d, - # Hexagonal - C6, - C3h, - C6h, - D6, - C6v, - D3h, - D6h, - # cubic - T, - Th, - O, - Td, - Oh, - ], - "proper_groups": [ - # Triclinic - C1, - # Monoclinic - C2, - # Orthorhombic - D2, - D4, - # Tetragonal - C4, - # Trigonal - C3, - D3, - # Hexagonal - C6, - D6, - # cubic - T, - O, - ], - "proper_permutations": [ - # Triclinic - C1, - # Monoclinic - C2, - C2x, - C2y, - # Orthorhombic - D2, - # Tetragonal - C4, - # Trigonal - C3, - D3, - D3x, - D3y, - # Hexagonal - C6, - D6, - # cubic - T, - O, - ], - "laue": [ - # Triclinic - Ci, - # Monoclinic - C2h, - # Orthorhombic - D2h, - D4h, - # Tetragonal - C4h, - # Trigonal - C3i, - D3d, - # Hexagonal - C6h, - D6h, - # cubic - Th, - Oh, - ], - "procedural": [ - # Cyclic - C1, - C2, - C3, - C4, - C6, - # Dihedral - D2, - D3, - D4, - D6, - # Cyclic plus inversion (\ba{n}) - Ci, - Cs, - C3i, - S4, - C3h, - # Cyclic plus perpendicular mirrors (n/m) - C2h, - C4h, - C6h, - # Cyclic plus vertical mirrors (nm) - C2v, - C3v, - C4v, - C6v, - # Dihedral plus diagonal mirrors (\bar{n} m) - D3d, - D2d, - D3h, - # Dihedral with vertical and perpendicular mirros (n/m m) - D2h, - D4h, - D6h, - # Combining cyclic (n1 n2) - T, - O, - # combining cyclic and mirrors - Th, - Td, - Oh, - ], - } - _subset_names = _pg_sets.keys() + subset_names = _point_groups_dictionary.keys() _point_group_names = dict( - [(x.name, x) for x in _pg_sets["permutations_repeated"]] + [(x.name, x) for x in _point_groups_dictionary["permutations_repeated"]] ).keys() - _spacegroup2pointgroup_dict = { - "PG1": {"proper": C1, "improper": C1}, - "PG1bar": {"proper": C1, "improper": Ci}, - "PG2": {"proper": C2, "improper": C2}, - "PGm": {"proper": C2, "improper": Cs}, - "PG2/m": {"proper": C2, "improper": C2h}, - "PG222": {"proper": D2, "improper": D2}, - "PGmm2": {"proper": C2, "improper": C2v}, - "PGmmm": {"proper": D2, "improper": D2h}, - "PG4": {"proper": C4, "improper": C4}, - "PG4bar": {"proper": C4, "improper": S4}, - "PG4/m": {"proper": C4, "improper": C4h}, - "PG422": {"proper": D4, "improper": D4}, - "PG4mm": {"proper": C4, "improper": C4v}, - "PG4bar2m": {"proper": D4, "improper": D2d}, - "PG4barm2": {"proper": D4, "improper": D2d}, - "PG4/mmm": {"proper": D4, "improper": D4h}, - "PG3": {"proper": C3, "improper": C3}, - "PG3bar": {"proper": C3, "improper": S6}, # Improper also known as C3i - "PG312": {"proper": D3, "improper": D3}, - "PG321": {"proper": D3, "improper": D3}, - "PG3m1": {"proper": C3, "improper": C3v}, - "PG31m": {"proper": C3, "improper": C3v}, - "PG3m": {"proper": C3, "improper": C3v}, - "PG3bar1m": {"proper": D3, "improper": D3d}, - "PG3barm1": {"proper": D3, "improper": D3d}, - "PG3barm": {"proper": D3, "improper": D3d}, - "PG6": {"proper": C6, "improper": C6}, - "PG6bar": {"proper": C6, "improper": C3h}, - "PG6/m": {"proper": C6, "improper": C6h}, - "PG622": {"proper": D6, "improper": D6}, - "PG6mm": {"proper": C6, "improper": C6v}, - "PG6barm2": {"proper": D6, "improper": D3h}, - "PG6bar2m": {"proper": D6, "improper": D3h}, - "PG6/mmm": {"proper": D6, "improper": D6h}, - "PG23": {"proper": T, "improper": T}, - "PGm3bar": {"proper": T, "improper": Th}, - "PG432": {"proper": O, "improper": O}, - "PG4bar3m": {"proper": T, "improper": Td}, - "PGm3barm": {"proper": O, "improper": Oh}, - } - - def __init__(self, symmetry_list: list = [C1]): - """ - A list of symmetry operators with convenence functions for parsing entries - and displaying information. + def __init__(self, symmetry_list: list | Symmetry | str = "groups"): + """A group of symmetry operators with convenence functions + for parsing entries and displaying information. - This class is primarily intended to be called using PointGroups.subset(), - or to return a single Symmetry object using PointGroups.get(). + This class is primarily intended to be called using + PointGroups.subset(), or to return a single Symmetry + object using PointGroups.get(). However, a list of Symmetry + objects can also be passed in to create a custom PointGroup. Parameters ---------- symmetry_list - A list of orix.quaternion.symmetry.Symmetry objects, each representing - a crystallographic class. + Either a string matching one of the keys in + `PointGroups.subset_names`, or a list of + orix.quaternion.symmetry.Symmetry objects. Default is + 'groups', which returns the 32 crystalographic point + groups in the order given in the International Tables + for Crystallography, Chapter 10. """ - if not isinstance(symmetry_list, list): - raise ValueError("'symmetry_list' must be a list of Symmetry objects") - elif not np.all([isinstance(x, Symmetry) for x in symmetry_list]): - raise ValueError("'symmetry_list' must be a list of Symmetry objects") + if isinstance(symmetry_list, str): + pgs = self.get_set(symmetry_list) + self.__init__(pgs.symms) + elif isinstance(symmetry_list, Symmetry): + self.symms = [symmetry_list] + elif isinstance(symmetry_list, list): + if np.all([isinstance(y, Symmetry) for y in symmetry_list]): + self.symms = symmetry_list + else: + raise ValueError( + "All entries in 'symmetry_list' must be Symmetry objects" + ) else: - self.extend(symmetry_list) + raise ValueError( + "symmetry list must either be one of" + + f"{', '.join(map(str, PointGroups.subset_names))}, or a list of" + + f"symmetry operators, not '{symmetry_list}'" + ) def __repr__(self): str_data = ( @@ -1473,21 +1402,42 @@ def __repr__(self): "| " + "| ".join( [ - x._schoenflies.ljust(6), - x.system.ljust(13), - x.name.ljust(6), - x.laue.name.ljust(6), - x.proper_subgroup.name.ljust(7), + str(x._schoenflies).ljust(6), + str(x.system).ljust(13), + str(x.name).ljust(6), + str(x.laue.name).ljust(6), + str(x.proper_subgroup.name).ljust(7), ] ) + "|" - for x in self + for x in self.symms ] ) ) return str_data + def __iter__(self) -> Generator[Symmetry]: + return iter(self.symms) + + def __getitem__(self, index) -> PointGroups: + pg_subset = PointGroups(self.symms[index]) + return pg_subset + + def __len__(self): + return len(self.symms) + + def to_list(self): + """Return the symmetry operators as a list. + + Returns + ------- + symmetry_list + returns the symmetry operators in this :class:PointGroup + object as a list of :class:Symmetry instances. + """ + return self.symms + def get(name: Literal[PointGroups._point_group_names]): """ Given a string or integer representation, this function will attempt to @@ -1516,7 +1466,7 @@ def get(name: Literal[PointGroups._point_group_names]): # then 'permutations_repeated'. print(vars().keys()) for subset in ["groups", "permutations", "permutations_repeated"]: - pgs = PointGroups._pg_sets[subset] + pgs = _point_groups_dictionary[subset] pg_dict = dict([(x.name, x) for x in pgs]) if str(name).lower() in pg_dict.keys(): return pg_dict[name.lower()] @@ -1547,7 +1497,7 @@ def from_space_group( space_group_number: int between 1-231, or str If is an int(n) or str(int(n)) where n is between 1 and 231, it will return the point group of the nth space group, as defined by the - International Tables of Crystallogrphy. Otherwise, it will be passed + International Tables for Crystallogrphy. Otherwise, it will be passed to diffpy's dictionary of space group names for interpretation. Thus, 222 and '222' will both return symmetry.Oh (ie "432", the point @@ -1589,44 +1539,47 @@ def from_space_group( spg = GetSpaceGroup(space_group_number) pgn = spg.point_group_name if proper: - return PointGroups._spacegroup2pointgroup_dict[pgn]["proper"] + return _spacegroup2pointgroup_dict[pgn]["proper"] else: - return PointGroups._spacegroup2pointgroup_dict[pgn]["improper"] + return _spacegroup2pointgroup_dict[pgn]["improper"] @classmethod def get_set(self, name: Literal[PointGroups._subset_names] = "groups"): """ - returns different subsets of the 32 crystallographic point groups. By - default, this returns all 32 in the order they appear in the - International Tables of Crystallography (ITOC). + returns different subsets of the 32 crystallographic point groups. + By default, this returns all 32 in the order they appear in the + International Tables for Crystallography (ITC). Parameters ---------- subset : str, optional the point group list to return. The options are as follows: "groups" (default): - All 32 point groups in the order they appear in ITOC's space groups. - Thus, they are grouped by crystal system and Laue class + All 32 point groups in the order they appear in ITC's + space groups. As a result, they are grouped by + crystal system and Laue class "permutations": - All 32 points groups, plus common axis-specific permutations - for non-centrosymmetric groups (ie, C2 plus C2x and C2y) for - a total of 37 point group projections. These - are given in the same order as ITOC Table 10.2.2 + All 32 points groups, plus common axis-specific + permutations for non-centrosymmetric groups (ie, + C2 plus C2x and C2y) for a total of 37 point group + projections. These are given in the same order as + ITC Table 10.2.2 "permutations_repeated": - The 37 point group projections, plus the redundant Schonflies - and Hermann-Mauguin group names. For example, both Ci and S2 - are included, as well as D3 =="32" and D3x == "321". NOTE: - this means several of the entries symmetrically identical. + The 37 point group projections, plus the redundant + Schonflies and Hermann-Mauguin group names. For example, + both Ci and S2 are included, as well as D3 =="32" and + D3x == "321". NOTE: this means several entries are + symmetrically identical. "proper": - The 11 proper point groups given in the same order as ITOC + The 11 proper point groups given in the same order as ITC table 10.2.2. same order as "unique", which in turn aligns with Table 3.1 - of ITOC + of ITC "proper_all": The 11 proper point groups, plus axis-specific permutations. "laue": The point groups corresponding to the 11 Laue groups, using - the same ordering and definitions as Table 3.1 of ITOC. These + the same ordering and definitions as Table 3.1 of ITC. These are equivalent to adding an inversion symmetry to each op the 11 proper point groups "procedural": @@ -1641,14 +1594,14 @@ def get_set(self, name: Literal[PointGroups._subset_names] = "groups"): point groups: PointGroups A PointGroup class containing the requested symmetries """ - pg_opts = self._pg_sets.keys() - if name in pg_opts: - return PointGroups(self._pg_sets[name]) - elif name.lower() in pg_opts: - return PointGroups(self._pg_sets[name.lower()]) + if name in self.subset_names: + return PointGroups(_point_groups_dictionary[name]) + elif name.lower() in self.subset_names: + return PointGroups(_point_groups_dictionary[name.lower()]) # if the name doesn't exist, return a ValueError raise ValueError( - f"'name' must be one of {', '.join(map(str, pg_opts))}, not '{name}'" + "'name' must be one of " + + f"{', '.join(map(str, self.subset_names))}, not '{name}'" ) @@ -1703,7 +1656,7 @@ def _get_laue_group_name(name: str) -> str | None: # search through all the point groups defined in orix for one with a # matching name. valid_name = False - for g in PointGroups._pg_sets["permutations_repeated"]: + for g in _point_groups_dictionary["permutations_repeated"]: if g.name == name: valid_name = True break @@ -1713,13 +1666,13 @@ def _get_laue_group_name(name: str) -> str | None: # it's a permutation of a point group. trade it for an unpermutated one. if np.isin(g._schoenflies[-1], ["x", "y", "z"]): s_name = g._schoenflies[:-1] - for g in PointGroups._pg_sets["permutations_repeated"]: + for g in _point_groups_dictionary["permutations_repeated"]: if g._schoenflies == s_name: break # add an inversion to get the laue group. g_laue = _get_unique_symmetry_elements(g, Ci) # find a laue group with matching operators - for laue in PointGroups._pg_sets["laue"]: + for laue in _point_groups_dictionary["laue"]: # first check for length if g_laue.shape != laue.shape: continue diff --git a/orix/tests/quaternion/test_symmetry.py b/orix/tests/quaternion/test_symmetry.py index a26c3d63b..4e04b06a0 100644 --- a/orix/tests/quaternion/test_symmetry.py +++ b/orix/tests/quaternion/test_symmetry.py @@ -23,6 +23,7 @@ import pytest from orix.quaternion import Rotation, Symmetry, get_point_group +from orix.quaternion.symmetry import _spacegroup2pointgroup_dict # fmt: off # isort: off @@ -443,15 +444,14 @@ def test_no_symm_fundamental_zone(): def test_get_point_group(): """Makes sure all the ints from 1 to 230 give answers.""" - sg2pg = PointGroups._spacegroup2pointgroup_dict for sg_number in np.arange(1, 231): proper_pg = get_point_group(sg_number, proper=True) assert proper_pg in [C1, C2, C3, C4, C6, D2, D3, D4, D6, O, T] sg = GetSpaceGroup(sg_number) pg = get_point_group(sg_number, proper=False) - assert proper_pg == sg2pg[sg.point_group_name]["proper"] - assert pg == sg2pg[sg.point_group_name]["improper"] + assert proper_pg == _spacegroup2pointgroup_dict[sg.point_group_name]["proper"] + assert pg == _spacegroup2pointgroup_dict[sg.point_group_name]["improper"] def test_unique_symmetry_elements_subgroups(all_symmetries): @@ -545,7 +545,7 @@ def test_symmetry_plot(pg, n_elements): fig, plt_axis = plt.subplots(subplot_kw={"projection": "stereographic"}) v = Vector3d.from_polar(0.5, 0.3) v_symm = pg.outer(v) - pg.plot(v, plt_axis=plt_axis) + pg.plot(v, ax=plt_axis) c1 = plt_axis.collections[-1] c2 = plt_axis.collections[-2] assert len(c1.get_offsets()) == np.sum(v_symm.z >= 0) @@ -566,12 +566,6 @@ def test_repr(self): for l in lines[2:]: assert len(l.split()) == 11 - def test_init_checks(self): - with pytest.raises(ValueError): - x = PointGroups("banana") - with pytest.raises(ValueError): - x = PointGroups(["banana"]) - def test_get(self): assert PointGroups.get("c1").laue.name == "-1" assert PointGroups.get("C1").laue.name == "-1" @@ -580,6 +574,23 @@ def test_get(self): with pytest.raises(ValueError): x = PointGroups.get("banana") + def test_init(self): + for method in [Oh, [Oh], [D3, C6, O], "groups"]: + pgs = PointGroups(method) + assert isinstance(pgs, PointGroups) + with pytest.raises(ValueError, match="'name' must be one of"): + pgs = PointGroups("banana") + with pytest.raises(ValueError, match="All entries"): + pgs = PointGroups([Oh, "banana"]) + with pytest.raises(ValueError, match="All entries"): + pgs = PointGroups(["banana"]) + with pytest.raises(ValueError, match="symmetry list must"): + pgs = PointGroups(33) + + def test_other_functions(self): + pgs = PointGroups() + assert isinstance(pgs.to_list(), list) + class TestFundamentalSectorFromSymmetry: """Test the normals, vertices and centers of the fundamental sector From 3e9aa80926db92f7cbb1435c4b3362f95b2937a7 Mon Sep 17 00:00:00 2001 From: Austin Gerlt <83073845+argerlt@users.noreply.github.com> Date: Thu, 17 Jul 2025 19:57:25 -0600 Subject: [PATCH 35/40] fix broken reference to old class feature --- orix/quaternion/symmetry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index b94d5076e..a7c032c68 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1544,7 +1544,7 @@ def from_space_group( return _spacegroup2pointgroup_dict[pgn]["improper"] @classmethod - def get_set(self, name: Literal[PointGroups._subset_names] = "groups"): + def get_set(self, name: Literal[PointGroups.subset_names] = "groups"): """ returns different subsets of the 32 crystallographic point groups. By default, this returns all 32 in the order they appear in the From 396972c82058a3573a34832e0fbc613fafcec2d4 Mon Sep 17 00:00:00 2001 From: Austin Gerlt Date: Thu, 17 Jul 2025 22:09:42 -0600 Subject: [PATCH 36/40] fix D3h name and improve explination on plot_symmetry_operations.py --- .../plot_symmetry_operations.py | 44 +++++++++++-------- orix/quaternion/symmetry.py | 4 +- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index df7ad9efe..3564f1e23 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -3,25 +3,31 @@ Plot symmetry operations ======================== -This example shows how stereographic projections with symmetry operators can be -automatically generated for the 32 crystallographic point groups. - -The ordering follows the one given in section 9.2 of "Structures of Materials" -(DeGraef et.al, 2nd edition, 2012), starting with the cyclic groups, then the -dihedral groups, then those same groups plus inversion centers, then the successive -application of mirror planes and secondary rotational symmetries until all 32 -groups are made. - -The plots themselves as well as their labels follow the standards given in -Table 10.2.2 of the "International Tables for Crystallography, Volume A" (ITC). -Both the nomenclature and marker styles thus differ slightly from some textbooks, as -there are some arbitrary convention choices in both Schoenflies notation and marker -styles. - -Orix uses Schoenflies Notation (left label above each plot) for variable names since -they are short and always begin with a letter, but both Schoenflies and -Hermann-Mauguin (right label above each plot) names can be used to look up symmetry -groups using `PointGroups.get()` +This example shows how stereographic projections with symmetry operation +markers can be automatically generated for the 32 crystallographic point +groups. + +The ordering used here follows the one given in section 9.2 of "Structures +of Materials" (DeGraef et.al, 2nd edition, 2012). This ordering starts with +the 5 cyclic groups (C1, C2, C3, C4, and C6), followed by the 4 dihedral +groups (D2, D3, D4, and D6). Next are the same groups combined with inversion +centers (Ci, Cs, C3i, S4, and C3h), perpendicular mirror planes (C2h, C4h, +and C6h), vertical mirror planes (C2v, C3v, C4v, and C6v), and diagonal +mirror planes (D3d, D2d, and D3h). Next are groups formed from permutations +of cyclic and dihedral groups (D2h, D4h, and D6h), and finally the groups +with 3-fold rotations around the 111 axes (T, O, Th, Td, and Oh). + +The plots themselves as well as their labels follow the standards given +in Table 10.2.2 of the "International Tables for Crystallography, Volume +A" (ITC). Both the nomenclature and marker styles thus differ slightly from +many textbooks, including "Structure of Materials", as there are arbitrary +convention choices in ITC regarding both Schoenflies notation and marker +style. + +Orix uses Schoenflies Notation (left label above each plot) for the default +symmetry group names since they are short and always begin with a letter, +but both Schoenflies and Hermann-Mauguin (right label above each plot) names +can be used to look up symmetry groups using `PointGroups.get()` """ import matplotlib.pyplot as plt diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index a7c032c68..95a5754e7 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1028,7 +1028,7 @@ def _get_symmetry_elements(self) -> (Vector3d, bool, str, int): C6v._schoenflies = "C6v" D3h = Symmetry.from_generators(C3, C2y, Csz) D3h.name = "-6m2" -D3h._schoenflies = "-D3h" +D3h._schoenflies = "D3h" D6h = Symmetry.from_generators(D6, Csz) D6h.name = "6/mmm" D6h._schoenflies = "D6h" @@ -1395,7 +1395,7 @@ def __init__(self, symmetry_list: list | Symmetry | str = "groups"): def __repr__(self): str_data = ( "| Name | System | HM | Laue | Proper |\n" - + "=" * 48 + + "=" * 49 + "\n" + "\n".join( [ From ed1a829d721d804a0f30960a4332f31a973035e1 Mon Sep 17 00:00:00 2001 From: Austin Gerlt Date: Fri, 27 Feb 2026 18:51:41 -0700 Subject: [PATCH 37/40] fixing errors from 0.14 merge --- examples/advanced_symmetry_plotting.py | 45 ++ .../plot_symmetry_operations.py | 29 +- orix/crystal_map/_phase_list.py | 421 +----------------- orix/plot/stereographic_plot.py | 85 +++- orix/quaternion/symmetry.py | 60 +-- 5 files changed, 162 insertions(+), 478 deletions(-) create mode 100644 examples/advanced_symmetry_plotting.py diff --git a/examples/advanced_symmetry_plotting.py b/examples/advanced_symmetry_plotting.py new file mode 100644 index 000000000..0b724b369 --- /dev/null +++ b/examples/advanced_symmetry_plotting.py @@ -0,0 +1,45 @@ +# +# Copyright 2018-2026 the orix developers +# +# This file is part of orix. +# +# orix is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# orix is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with orix. If not, see . +# + +r""" +===================== +Plot crystal symmetry +===================== + +This example demonstrates some advanced methods for altering +stereographic plots of symmetries. For a more general example, see +:ref:`sphx_glr_plot_symmetry_operations.py`. For examples of how to make +symmetry objects, see :ref:`create_symmetry.py` +""" +import matplotlib.pyplot as plt +import orix.quaternion as oqu +import orix.vector as ove +import orix.plot as opl + +opl.register_projections() # Register our custom Matplotlib projections + +######################################################################################## +# TODO: finish this example +pg_Oh = oqu.symmetry.PointGroups.get("m-3m") +v = ove.Vector3d.random(10) +v_symm = pg_Oh.outer(v).flatten() +fig, ax = plt.subplots(1, 1, subplot_kw={"projection": "stereographic"}) +pg_Oh.plot(ax=ax, show_name=False) +ax.set_title("my cool custom title") +ax.scatter(v_symm) diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index 4323343a9..e065cec51 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -29,15 +29,15 @@ of Materials" (DeGraef et.al, 2nd edition, 2012). This ordering starts with the 5 cyclic groups (C1, C2, C3, C4, and C6), followed by the 4 dihedral groups (D2, D3, D4, and D6). Next are the same groups combined with inversion -centers (Ci, Cs, C3i, S4, and C3h), perpendicular mirror planes (C2h, C4h, +centers (Ci, Cs, C3i, S4, and C3h), perpendicular mirror planes (C2h, C4h, and C6h), vertical mirror planes (C2v, C3v, C4v, and C6v), and diagonal mirror planes (D3d, D2d, and D3h). Next are groups formed from permutations of cyclic and dihedral groups (D2h, D4h, and D6h), and finally the groups with 3-fold rotations around the 111 axes (T, O, Th, Td, and Oh). The plots themselves as well as their labels follow the standards given -in Table 10.2.2 of the "International Tables for Crystallography, Volume -A" (ITC). Both the nomenclature and marker styles thus differ slightly from +in Table 10.2.2 of the "International Tables for Crystallography, Volume +A" (ITC). Both the nomenclature and marker styles thus differ slightly from many textbooks, including "Structure of Materials", as there are arbitrary convention choices in ITC regarding both Schoenflies notation and marker style. @@ -50,23 +50,14 @@ import matplotlib.pyplot as plt -import orix.plot -from orix.quaternion.symmetry import PointGroups -from orix.vector import Vector3d +import orix.plot as opl +import orix.quaternion as oqu +import orix.vector as ove -# create a list of the 32 crystallographic point groups -point_groups = PointGroups.get_set("procedural") -from orix.plot import register_projections -from orix.vector import Vector3d - -register_projections() # Register our custom Matplotlib projections +opl.register_projections() # Register our custom Matplotlib projections -marker_size = 200 -fig, (ax0, ax1) = plt.subplots( - ncols=2, - subplot_kw={"projection": "stereographic"}, - layout="tight", -) +# create a list of the 32 crystallographic point groups +point_groups = oqu.symmetry.PointGroups.get_set("procedural") # show the table of symmetry information print(point_groups) @@ -78,7 +69,7 @@ ax = ax.flatten() # create a vector to mirror over axes -v = Vector3d.from_polar(65, 80, degrees=True) +v = ove.Vector3d.from_polar(65, 80, degrees=True) # Iterate through the 32 Point groups for i, pg in enumerate(point_groups): pg.plot(asymmetric_vector=v, ax=ax[i]) diff --git a/orix/crystal_map/_phase_list.py b/orix/crystal_map/_phase_list.py index 507d8e697..26278daef 100644 --- a/orix/crystal_map/_phase_list.py +++ b/orix/crystal_map/_phase_list.py @@ -30,400 +30,6 @@ from diffpy.structure.spacegroups import SpaceGroup import numpy as np -from orix.quaternion.symmetry import ( - _EDAX_POINT_GROUP_ALIASES, - PointGroups, - Symmetry, - _point_groups_dictionary, -) -from orix.vector import Miller, Vector3d - -# All named Matplotlib colors (tableau and xkcd already lower case hex) -ALL_COLORS = mcolors.TABLEAU_COLORS -for k, v in {**mcolors.BASE_COLORS, **mcolors.CSS4_COLORS}.items(): - ALL_COLORS[k] = mcolors.to_hex(v) -ALL_COLORS.update(mcolors.XKCD_COLORS) - -#TODO: merge issue here. Phase class belongs in _Phase.py, but there are -# Some bits here not in the new one. -class Phase: - """Name, symmetry, and color of a phase in a crystallographic map. - - If the first parameter is a :code:`Phase` instance, a copy is made - - Parameters - ---------- - name - Phase name. Overwrites the name in the ``structure`` object. - If this parameter is a :code:`Phase`, a copy is made. - space_group - Space group describing the symmetry operations resulting from - associating the point group with a Bravais lattice, according - to the International Tables of Crystallography. If not given, it - is set to ``None``. - point_group - Point group describing the symmetry operations of the phase's - crystal structure, according to the International Tables of - Crystallography. If not given and ``space_group`` is not given, - it set to ``None``. If ``None`` is passed but ``space_group`` - is not ``None``, it is derived from the space group. If both - ``point_group`` and ``space_group`` is not ``None``, the space - group needs to be derived from the point group. - structure - Unit cell with atoms and a lattice. If not given, a default - :class:`~diffpy.structure.structure.Structure` object is - created. - color - Phase color. If not given, it is set to ``"tab:blue"`` (first - among the default Matplotlib colors). - - Examples - -------- - >>> from diffpy.structure import Atom, Lattice, Structure - >>> from orix.crystal_map import Phase - >>> p = Phase( - ... name="al", - ... space_group=225, - ... structure=Structure( - ... atoms=[Atom("al", [0, 0, 0])], - ... lattice=Lattice(0.405, 0.405, 0.405, 90, 90, 90) - ... ) - ... ) - >>> p - - >>> p.structure - [al 0.000000 0.000000 0.000000 1.0000] - >>> p.structure.lattice - Lattice(a=0.405, b=0.405, c=0.405, alpha=90, beta=90, gamma=90) - """ - - def __init__( - self, - name: str | "Phase" | None = None, - space_group: int | SpaceGroup | None = None, - point_group: int | str | Symmetry | None = None, - structure: Structure | None = None, - color: str | None = None, - ) -> None: - if isinstance(name, Phase): - return Phase.__init__( - self, - name.name, - name.space_group, - name.point_group, - name.structure.copy(), - name.color, - ) - self.structure = structure if structure is not None else Structure() - if name is not None: - self.name = name - self.space_group = space_group # Needs to be set before point group - self.point_group = point_group - self.color = color if color is not None else "tab:blue" - - @property - def structure(self) -> Structure: - r"""Return or set the crystal structure containing a lattice - (:class:`~diffpy.structure.lattice.Lattice`) and possibly many - atoms (:class:`~diffpy.structure.atom.Atom`). - - Parameters - ---------- - value : ~diffpy.structure.Structure - Crystal structure. The cartesian reference frame of the - crystal lattice is assumed to align :math:`a` with - :math:`e_1` and :math:`c*` with :math:`e_3`. This alignment - is assumed when transforming direct, reciprocal and - cartesian vectors between these spaces. - """ - return self._structure - - @structure.setter - def structure(self, value: Structure) -> None: - """Set the crystal structure.""" - if isinstance(value, Structure): - # Ensure correct alignment - old_matrix = value.lattice.base - new_matrix = _new_structure_matrix_from_alignment(old_matrix, x="a", z="c*") - value = copy.deepcopy(value) - # Ensure atom positions are expressed in the new basis - value.placeInLattice(Lattice(base=new_matrix)) - # Store old lattice for expand_asymmetric_unit - self._diffpy_lattice = old_matrix - if value.title == "" and hasattr(self, "_structure"): - value.title = self.name - self._structure = value - else: - raise ValueError(f"{value} must be a diffpy.structure.Structure object.") - - @property - def name(self) -> str: - """Return or set the phase name. - - Parameters - ---------- - value : str - Phase name. - """ - return self.structure.title - - @name.setter - def name(self, value: str) -> None: - """Set the phase name.""" - self.structure.title = str(value) - - @property - def color(self) -> str: - """Return or set the name of phase color. - - Parameters - ---------- - value : str - A valid color identifier. See - :func:`matplotlib.colors.is_color_like`. - """ - return self._color - - @color.setter - def color(self, value: str) -> None: - """Set the phase color.""" - value_hex = mcolors.to_hex(value) - for name, color_hex in ALL_COLORS.items(): - if color_hex == value_hex: - self._color = name - break - - @property - def color_rgb(self) -> tuple: - """Return the phase color as RGB tuple.""" - return mcolors.to_rgb(self.color) - - @property - def space_group(self) -> SpaceGroup | None: - """Return or set the space group. - - Parameters - ---------- - value : int, SpaceGroup or None - Space group. If an integer is passed, it must be between - 1-230. - """ - return self._space_group - - @space_group.setter - def space_group(self, value: int | SpaceGroup | None) -> None: - """Set the space group.""" - if isinstance(value, int): - value = GetSpaceGroup(value) - if not isinstance(value, SpaceGroup) and value is not None: - raise ValueError( - f"'{value}' must be of type {SpaceGroup}, an integer 1-230, or None." - ) - # Overwrites any point group set before - self._space_group: SpaceGroup | None = value - - @property - def point_group(self) -> Symmetry | None: - """Return or set the point group. - - Parameters - ---------- - value : int, str, Symmetry or None - Point group. - """ - if self.space_group is not None: - return PointGroups.from_space_group(self.space_group.number) - else: - return self._point_group - - @point_group.setter - def point_group(self, value: int | str | Symmetry | None) -> None: - """Set the point group.""" - groups = _point_groups_dictionary["permutations_repeated"] - if isinstance(value, int): - value = str(value) - if isinstance(value, str): - for key, aliases in _EDAX_POINT_GROUP_ALIASES.items(): - if value in aliases: - value = key - break - for point_group in groups: - if value == point_group.name: - value = point_group - break - if not isinstance(value, Symmetry) and value is not None: - raise ValueError( - f"'{value}' must be of type {Symmetry}, the name of a valid point" - " group as a string, or None." - ) - else: - if self.space_group is not None and value is not None: - old_point_group_name = self.point_group.name - if old_point_group_name != value.name: - warnings.warn( - "Setting space group to 'None', as current space group " - f"'{self.space_group.short_name}' is derived from current point" - f" group '{old_point_group_name}'." - ) - self.space_group = None - self._point_group = value - - @property - def is_hexagonal(self) -> bool: - """Return whether the crystal structure is hexagonal/trigonal or - not. - """ - return np.allclose(self.structure.lattice.abcABG()[3:], [90, 90, 120]) - - @property - def a_axis(self) -> Miller: - """Return the direct lattice vector :math:`a` in the cartesian - reference frame of the crystal lattice :math:`e_i`. - """ - return Miller(uvw=(1, 0, 0), phase=self) - - @property - def b_axis(self) -> Miller: - """Return the direct lattice vector :math:`b` in the cartesian - reference frame of the crystal lattice :math:`e_i`. - """ - return Miller(uvw=(0, 1, 0), phase=self) - - @property - def c_axis(self) -> Miller: - """Return the direct lattice vector :math:`c` in the cartesian - reference frame of the crystal lattice :math:`e_i`. - """ - return Miller(uvw=(0, 0, 1), phase=self) - - @property - def ar_axis(self) -> Miller: - """Return the reciprocal lattice vector :math:`a^{*}` in the - cartesian reference frame of the crystal lattice :math:`e_i`. - """ - return Miller(hkl=(1, 0, 0), phase=self) - - @property - def br_axis(self) -> Miller: - """Return the reciprocal lattice vector :math:`b^{*}` in the - cartesian reference frame of the crystal lattice :math:`e_i`. - """ - return Miller(hkl=(0, 1, 0), phase=self) - - @property - def cr_axis(self) -> Miller: - """Return the reciprocal lattice vector :math:`c^{*}` in the - cartesian reference frame of the crystal lattice :math:`e_i`. - """ - return Miller(hkl=(0, 0, 1), phase=self) - - def __repr__(self) -> str: - if self.point_group is not None: - pg_name = self.point_group.name - ppg_name = self.point_group.proper_subgroup.name - else: - pg_name = self.point_group # Should be None - ppg_name = None - if self.space_group is not None: - sg_name = self.space_group.short_name - else: - sg_name = self.space_group # Should be None - return ( - f"" - ) - - @classmethod - def from_cif(cls, filename: str | Path) -> Phase: - """Return a new phase from a CIF file using - :mod:`diffpy.structure`'s CIF file parser. - - Parameters - ---------- - filename - Complete path to CIF file with ".cif" file ending. The phase - name is obtained from the file name. - - Returns - ------- - phase - New phase. - """ - path = Path(filename) - parser = p_cif.P_cif() - name = path.stem - structure = parser.parseFile(str(path)) - try: - space_group = parser.spacegroup.number - except AttributeError: # pragma: no cover - space_group = None - warnings.warn(f"Could not read space group from CIF file {path!r}") - return cls(name, space_group, structure=structure) - - def deepcopy(self) -> Phase: - """Return a deep copy using :py:func:`~copy.deepcopy` - function. - """ - return copy.deepcopy(self) - - def expand_asymmetric_unit(self) -> Phase: - """Return a new phase with all symmetrically equivalent atoms. - - Returns - ------- - expanded_phase - New phase with the a :attr:`structure` with the unit cell - filled with symmetrically equivalent atoms. - - Examples - -------- - >>> from diffpy.structure import Atom, Lattice, Structure - >>> import orix.crystal_map as ocm - >>> atoms = [Atom("Si", xyz=(0, 0, 1))] - >>> lattice = Lattice(4.04, 4.04, 4.04, 90, 90, 90) - >>> structure = Structure(atoms = atoms,lattice=lattice) - >>> phase = ocm.Phase(structure=structure, space_group=227) - >>> phase.structure - [Si 0.000000 0.000000 1.000000 1.0000] - >>> expanded_phase = phase.expand_asymmetric_unit() - >>> expanded_phase.structure - [Si 0.000000 0.000000 0.000000 1.0000, - Si 0.000000 0.500000 0.500000 1.0000, - Si 0.500000 0.500000 0.000000 1.0000, - Si 0.500000 0.000000 0.500000 1.0000, - Si 0.750000 0.250000 0.750000 1.0000, - Si 0.250000 0.250000 0.250000 1.0000, - Si 0.250000 0.750000 0.750000 1.0000, - Si 0.750000 0.750000 0.250000 1.0000] - """ - if self.space_group is None: - raise ValueError("Space group must be set") - - # Ensure atom positions are expressed in diffpy's convention - diffpy_structure = self.structure.copy() - diffpy_structure.placeInLattice(Lattice(base=self._diffpy_lattice)) - xyz = diffpy_structure.xyz - diffpy_structure.clear() - - eau = ExpandAsymmetricUnit(self.space_group, xyz) - for atom, new_positions in zip(self.structure, eau.expandedpos): - for pos in new_positions: - new_atom = copy.deepcopy(atom) - new_atom.xyz = pos - # Only add new atom if not already present - for present_atom in diffpy_structure: - if present_atom.element == new_atom.element and np.allclose( - present_atom.xyz, new_atom.xyz - ): - break - else: - diffpy_structure.append(new_atom) - - # This handles conversion back to correct alignment - expanded_phase = self.__class__(self) - expanded_phase.structure = diffpy_structure - - return expanded_phase from orix.crystal_map._phase import Phase from orix.plot._util.color import get_named_matplotlib_colors from orix.quaternion.symmetry import Symmetry @@ -515,10 +121,14 @@ class PhaseList: def __init__( self, - phases: Phase | list[Phase] | dict[int, Phase] | "PhaseList" | None = None, + phases: ( + Phase | list[Phase] | dict[int, Phase] | "PhaseList" | None + ) = None, names: str | list[str] | None = None, space_groups: int | SpaceGroup | list[int | SpaceGroup] | None = None, - point_groups: str | int | Symmetry | list[str | int | Symmetry] | None = None, + point_groups: ( + str | int | Symmetry | list[str | int | Symmetry] | None + ) = None, colors: str | list[str] | None = None, ids: int | list[int] | np.ndarray | None = None, structures: Structure | list[Structure] | None = None, @@ -589,7 +199,9 @@ def __init__( # Get first 2 * n entries in color list (for good measure) all_colors, _ = get_named_matplotlib_colors() - all_color_names = list(islice(all_colors.keys(), 2 * max_entries))[::-1] + all_color_names = list(islice(all_colors.keys(), 2 * max_entries))[ + ::-1 + ] # Create phase dictionary d = {} @@ -756,7 +368,9 @@ def __delitem__(self, key: int | str) -> None: matching_phase_id = phase_id break if matching_phase_id is None: - raise KeyError(f"{key} is not among the phase names {self.names}.") + raise KeyError( + f"{key} is not among the phase names {self.names}." + ) else: self._dict.pop(matching_phase_id) else: @@ -776,7 +390,8 @@ def __repr__(self) -> str: sg_names = ["None" if not i else i.short_name for i in self.space_groups] pg_names = ["None" if not i else i.name for i in self.point_groups] ppg_names = [ - "None" if not i else i.proper_subgroup.name for i in self.point_groups + "None" if not i else i.proper_subgroup.name + for i in self.point_groups ] # Determine column widths (allowing PhaseList to be empty) @@ -794,8 +409,12 @@ def __repr__(self) -> str: representation = ( "{:{align}{width}} ".format("Id", width=id_len, align=align) + "{:{align}{width}} ".format("Name", width=name_len, align=align) - + "{:{align}{width}} ".format("Space group", width=sg_len, align=align) - + "{:{align}{width}} ".format("Point group", width=pg_len, align=align) + + "{:{align}{width}} ".format( + "Space group", width=sg_len, align=align + ) + + "{:{align}{width}} ".format( + "Point group", width=pg_len, align=align + ) + "{:{align}{width}} ".format( "Proper point group", width=ppg_len, align=align ) diff --git a/orix/plot/stereographic_plot.py b/orix/plot/stereographic_plot.py index 26227c0d5..d37af1775 100644 --- a/orix/plot/stereographic_plot.py +++ b/orix/plot/stereographic_plot.py @@ -445,7 +445,10 @@ def draw_circle( if x.size == 0: return - if isinstance(opening_angle, np.ndarray) and opening_angle.size == visible.size: + if ( + isinstance(opening_angle, np.ndarray) + and opening_angle.size == visible.size + ): opening_angle = opening_angle[visible] # Get set of `steps` vectors delineating a circle per vector @@ -474,7 +477,10 @@ def draw_circle( self._hemisphere = other_hemisphere[self._hemisphere] for i, circle in enumerate(circles): self.plot( - circle.azimuth, circle.polar, color=c[i], **reproject_plot_kwargs + circle.azimuth, + circle.polar, + color=c[i], + **reproject_plot_kwargs, ) self._hemisphere = other_hemisphere[self._hemisphere] @@ -548,7 +554,10 @@ def restrict_to_sector( ) self.add_patch(patch) self.set_clip_path(patch) - labels = [self._get_label("azimuth_grid"), self._get_label("polar_grid")] + labels = [ + self._get_label("azimuth_grid"), + self._get_label("polar_grid"), + ] for c in self.collections: if c.get_label() in labels: c.set_clip_path(patch) @@ -699,7 +708,10 @@ def wulff_net( lat_resolution, lat_resolution_major, linewidth_ratio ) self._longitudinal_grid( - long_resolution, long_resolution_major, linewidth_ratio, wulff_net_cap + long_resolution, + long_resolution_major, + linewidth_ratio, + wulff_net_cap, ) self._wulff_net_grid = True elif show_grid in [None, False] and self._wulff_net_grid is True: @@ -712,7 +724,9 @@ def wulff_net( self._remove_collection(label) self._wulff_net_grid = False - def symmetry_marker(self, v: Vector3d, fold: int, **kwargs) -> None: + def symmetry_marker( + self, v: Vector3d, folds: int, modifier="none", **kwargs + ) -> None: """Plot 2-, 3- 4- or 6-fold symmetry marker(s). Parameters @@ -791,9 +805,13 @@ def _azimuth_grid(self, resolution: float | None = None) -> None: label = self._get_label("azimuth_grid") self._remove_collection(label) lines = np.stack(((x_start, x_end), (y_start, y_end))).T - lines_collection = mcollections.LineCollection(lines, label=label, **kwargs) + lines_collection = mcollections.LineCollection( + lines, label=label, **kwargs + ) # Clip to fundamental sector, if set - sector_patch = self._get_collection(self._get_label("sector"), self.patches) + sector_patch = self._get_collection( + self._get_label("sector"), self.patches + ) if sector_patch is not None: lines_collection.set_clip_path(sector_patch) @@ -847,7 +865,9 @@ def _polar_grid(self, resolution: float | None = None) -> None: alpha=kwargs["alpha"], ) self._remove_collection(label) - sector_patch = self._get_collection(self._get_label("sector"), self.patches) + sector_patch = self._get_collection( + self._get_label("sector"), self.patches + ) if sector_patch is not None: circles_collection.set_clip_path(sector_patch) self.add_collection(circles_collection) @@ -894,7 +914,9 @@ def res2latlines(res, rotation_pole): v_lats = Vector3d.xvector().rotate(angle=np.radians(ticks)) lat_deltas = np.linspace(0, np.pi, 100, True) v_lines = [v.rotate(rotation_pole, lat_deltas) for v in v_lats] - xy_lines = [np.stack(self._projection.vector2xy(x)).T for x in v_lines] + xy_lines = [ + np.stack(self._projection.vector2xy(x)).T for x in v_lines + ] return xy_lines rotation_pole = [0, self.pole, 0] @@ -915,7 +937,9 @@ def res2latlines(res, rotation_pole): # Clip the grid to the fundamental sector subsection, if one is # defined - sector_patch = self._get_collection(self._get_label("sector"), self.patches) + sector_patch = self._get_collection( + self._get_label("sector"), self.patches + ) if sector_patch is not None: lc_minor.set_clip_path(sector_patch) if lc_major is not None: @@ -967,17 +991,24 @@ def res2llonglines(res, cap, rotation_pole): left = np.arange(90, 0 - res, -res)[::-1] right = np.arange(90 + res, 180 + res, res) ticks = np.hstack([left, right]) - v_longs = Vector3d.xvector().rotate(rotation_pole, np.radians(-ticks)) + v_longs = Vector3d.xvector().rotate( + rotation_pole, np.radians(-ticks) + ) long_deltas = np.radians(np.linspace(90 - cap, -90 + cap, 100, True)) v_lines = [ - v.rotate(v.rotate([0, 1, 0], np.pi / 2), long_deltas) for v in v_longs + v.rotate(v.rotate([0, 1, 0], np.pi / 2), long_deltas) + for v in v_longs + ] + xy_lines = [ + np.stack(self._projection.vector2xy(x)).T for x in v_lines ] - xy_lines = [np.stack(self._projection.vector2xy(x)).T for x in v_lines] return xy_lines rotation_pole = [0, -self.pole, 0] lc_minor = mcollections.LineCollection( - res2llonglines(self._long_resolution, self._wulff_net_cap, rotation_pole), + res2llonglines( + self._long_resolution, self._wulff_net_cap, rotation_pole + ), label=self._get_label("longitudinal_grid"), **kwargs_minor, ) @@ -986,7 +1017,9 @@ def res2llonglines(res, cap, rotation_pole): if self._long_resolution_major > 0: lc_major = mcollections.LineCollection( res2llonglines( - self._long_resolution_major, self._wulff_net_cap, rotation_pole + self._long_resolution_major, + self._wulff_net_cap, + rotation_pole, ), label=self._get_label("longitudinal_grid_major"), **kwargs_major, @@ -994,7 +1027,9 @@ def res2llonglines(res, cap, rotation_pole): # Clip the grid to the fundamental sector subsection, if one is # defined - sector_patch = self._get_collection(self._get_label("sector"), self.patches) + sector_patch = self._get_collection( + self._get_label("sector"), self.patches + ) if sector_patch is not None: lc_minor.set_clip_path(sector_patch) if lc_major is not None: @@ -1012,7 +1047,9 @@ def _get_label(name: str) -> str: @overload def _get_collection( - self, label: str, collections: maxes.Axes.ArtistList[mcollections.Collection] + self, + label: str, + collections: maxes.Axes.ArtistList[mcollections.Collection], ) -> mcollections.Collection: ... # pragma: no cover @overload @@ -1423,7 +1460,9 @@ def __iter__(self) -> [Vector3d, mpath.Path, np.float64]: For example, if a _SymmetryMarker is created with 4 vertices, this allows for iteration over those vertices in a 'for' loop.""" - for v, marker, size in zip(self._vector, self._marker, self.multiplicity): + for v, marker, size in zip( + self._vector, self._marker, self.multiplicity + ): yield v, marker, size @property @@ -1485,6 +1524,8 @@ def _marker(self) -> mpath.Path: trans = [mtransforms.Affine2D().rotate(a + (np.pi / 2)) for a in azimuth] return [marker.deepcopy().transformed(i) for i in trans] + + def _get_kwargs_for_wulff_net_grids( linewidth_ratio: float, ) -> tuple[dict[str, Any], dict[str, Any]]: @@ -1503,7 +1544,9 @@ def _get_kwargs_for_wulff_net_grids( return kwargs_minor, kwargs_major -def _label_in_collection(label: str, collections: COLLECTION_TYPES) -> int | None: +def _label_in_collection( + label: str, collections: COLLECTION_TYPES +) -> int | None: labels = [c.get_label() for c in collections] for i in range(len(labels)): if label == labels[i]: @@ -1522,7 +1565,9 @@ def _get_color_from_dict(d: dict[str, Any]) -> COLORLIKE: return c -def _update_kwargs_for_fundamental_sector_patch(d: dict[str, Any]) -> dict[str, Any]: +def _update_kwargs_for_fundamental_sector_patch( + d: dict[str, Any], +) -> dict[str, Any]: for k, v in [("facecolor", "none"), ("edgecolor", "k"), ("linewidth", 1)]: d.setdefault(k, v) return d diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index e965cab37..6e8fd3f4c 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -25,7 +25,7 @@ import matplotlib.pyplot as plt import numpy as np -from orix._util import deprecated, deprecated_argument +from orix._utils.deprecation import deprecated, deprecated_argument from orix.quaternion.rotation import Rotation from orix.vector.vector3d import Vector3d @@ -178,7 +178,6 @@ def system(self) -> VALID_SYSTEMS | None: """Return which of the seven crystal systems this symmetry belongs to, or None if the symmetry name is not recognized. """ - # fmt: off name = self.name if name in ["1", "-1"]: return "triclinic" @@ -196,7 +195,6 @@ def system(self) -> VALID_SYSTEMS | None: return "cubic" else: return None - # fmt: on @property def _tuples(self) -> set: @@ -264,7 +262,9 @@ def fundamental_sector(self) -> "FundamentalSector": # Taken from MTEX center = Vector3d([0.707558, -0.000403, 0.706655]) elif name in ["m-3", "432"]: - n = Vector3d(np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data])) + n = Vector3d( + np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data]) + ) # Taken from MTEX center = Vector3d([0.349928, 0.348069, 0.869711]) elif name == "-43m": @@ -372,7 +372,9 @@ def __and__(self, other: Symmetry) -> Symmetry: return Symmetry.from_generators(*generators) def __hash__(self) -> int: - return hash(self.name.encode() + self.data.tobytes() + self.improper.tobytes()) + return hash( + self.name.encode() + self.data.tobytes() + self.improper.tobytes() + ) # ------------------------ Class methods ------------------------- # @@ -481,8 +483,10 @@ def fundamental_zone(self) -> Vector3d: sr = SphericalRegion(n.unique()) return sr - @deprecated_argument(name="orientation", since="1.4", removal="1.5") - @deprecated_argument(name="reproject_scatter_kwargs", since="1.4", removal="1.5") + @deprecated_argument(name="orientation", since="1.5", removal="1.6") + @deprecated_argument( + name="reproject_scatter_kwargs", since="1.5", removal="1.6" + ) def plot( self, asymmetric_vector: Vector3d | None = None, @@ -494,7 +498,7 @@ def plot( reproject_scatter_kwargs: dict | None = None, marker_size: float = 150.0, mirror_width: float = 2.0, - asymetric_vector_dict: dict = {}, + asymmetric_vector_dict: dict = {}, asymmetric_vector_size: float = 50.0, ) -> plt.Figure | None: """Creates a stereographic projection of symmetry operations @@ -551,29 +555,6 @@ def plot( The created figure, returned if ``return_figure=True`` is passed as a keyword argument. - Examples - -------- - - If users wish to have more control over their plots, this - function can be used to modify an existing plot, like so: - - >>> import matplotlib.pyplot as plt - >>> from orix.quaternion.symmetry import PointGroups - >>> from orix.vector import Vector3d - >>> import orix.plot - >>> - >>> pg_Oh = PointGroups.get('m-3m') - >>> v = Vector3d.random(10) - >>> v_symm = pg_Oh.outer(v).flatten() - >>> fig, ax = plt.subplots(1, 1, subplot_kw={"projection": "stereographic"}) - >>> pg_Oh.plot(ax=ax, show_name=False) - >>> ax.set_title("my cool custom title") - >>> ax.scatter(v_symm) - - In this way, keword arguments related to the plot, the title, - the scattered vector markers, and/or the symmetry markers can - be individually altered as desired. - Notes ----- @@ -584,7 +565,7 @@ def plot( axes in the same plane as the plot, or the rotational orientation of the rotoinversion markers. """ - # depreciated input arguments. remove after 0.15 + # depreciated input arguments. remove after 0.16 if orientation is not None: # pragma: no cover # 'orientation' was replaced with "asymmetric_vector", so if that # input is not None, ignore 'orientation'. @@ -647,7 +628,9 @@ def plot( if t != "inversion": continue for sv in (self * v).unique(): - ax.symmetry_marker(sv, folds=f, s=marker_size, color=c, modifier=t) + ax.symmetry_marker( + sv, folds=f, s=marker_size, color=c, modifier=t + ) # plot asymmetric markers if requested. if asymmetric_vector is not None: @@ -658,7 +641,7 @@ def plot( "upper_marker": "+", "lower_marker": "o", } - vdict.update(asymetric_vector_dict) + vdict.update(asymmetric_vector_dict) mask = v_symm.z >= 0 ax.scatter( -1 * v_symm[~mask], @@ -1353,7 +1336,8 @@ def _get_symmetry_elements(self) -> (Vector3d, bool, str, int): } -class PointGroups(list): +# TODO: there is no need for this to subclass list +class PointGroups: # make a lookup table of common subsets of Point Groups subset_names = _point_groups_dictionary.keys() _point_group_names = dict( @@ -1493,7 +1477,7 @@ def get(name: Literal[PointGroups._point_group_names]): ) def from_space_group( - space_group_number: Union(int, str), proper: bool = False + self, space_group_number: Union(int, str), proper: bool = False ) -> Symmetry: """ Maps a space group number or name to a crystallographic point group. @@ -1632,8 +1616,8 @@ def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: @deprecated( - since="0.14", - removal="0.15", + since="0.15", + removal="0.16", alternative="PointGroups.from_space_group", ) def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: From 56e5d48b5b58104e5ec2525185c2f97aca9601de Mon Sep 17 00:00:00 2001 From: Austin Gerlt Date: Fri, 27 Feb 2026 20:10:26 -0700 Subject: [PATCH 38/40] more merge fixes, add example, address review comments --- .../plot_symmetry_advanced.py} | 33 +++++++++++++++---- orix/crystal_map/_phase.py | 31 ++++++++--------- orix/quaternion/symmetry.py | 33 +++++++++++-------- 3 files changed, 60 insertions(+), 37 deletions(-) rename examples/{advanced_symmetry_plotting.py => stereographic_projection/plot_symmetry_advanced.py} (60%) diff --git a/examples/advanced_symmetry_plotting.py b/examples/stereographic_projection/plot_symmetry_advanced.py similarity index 60% rename from examples/advanced_symmetry_plotting.py rename to examples/stereographic_projection/plot_symmetry_advanced.py index 0b724b369..a9463e503 100644 --- a/examples/advanced_symmetry_plotting.py +++ b/examples/stereographic_projection/plot_symmetry_advanced.py @@ -18,28 +18,49 @@ # r""" -===================== -Plot crystal symmetry -===================== +=============================== +Altering Crystal Symmetry Plots +=============================== This example demonstrates some advanced methods for altering stereographic plots of symmetries. For a more general example, see :ref:`sphx_glr_plot_symmetry_operations.py`. For examples of how to make symmetry objects, see :ref:`create_symmetry.py` """ + import matplotlib.pyplot as plt +import numpy as np + import orix.quaternion as oqu import orix.vector as ove import orix.plot as opl -opl.register_projections() # Register our custom Matplotlib projections +# Set a random seed for reproducability +np.random.seed = 897897 +# Register our custom Matplotlib projections +opl.register_projections() +# Use the PointGroup Class to create a Symmetry from a common shorthand name +pg_Oh = oqu.symmetry.PointGroups.get("m-3m") ######################################################################################## -# TODO: finish this example -pg_Oh = oqu.symmetry.PointGroups.get("m-3m") +# Typically, plots can be made using the built-in plot function. By default this +# will create a matplotlib Figure and Axis object, plot a single asymmetric +# vector, and add the Point Group name as the title +pg_Oh.plot() + +######################################################################################## +# Alternatively though, by creating a figure beforehand and passing the Axis +# in, it's possible to modify this plot as desired, for example by adding +# additional plots, changing the title, or modifying elements. + v = ove.Vector3d.random(10) v_symm = pg_Oh.outer(v).flatten() fig, ax = plt.subplots(1, 1, subplot_kw={"projection": "stereographic"}) pg_Oh.plot(ax=ax, show_name=False) ax.set_title("my cool custom title") ax.scatter(v_symm) + +for i in range(len(ax.lines)): + ax.lines[i].set_c([0, 1, 1]) + +plt.show() diff --git a/orix/crystal_map/_phase.py b/orix/crystal_map/_phase.py index 7ac984ed8..4aaf6bae4 100644 --- a/orix/crystal_map/_phase.py +++ b/orix/crystal_map/_phase.py @@ -33,13 +33,7 @@ import numpy as np from orix.plot._util.color import get_matplotlib_color -from orix.quaternion.symmetry import ( - _EDAX_POINT_GROUP_ALIASES, - VALID_SYSTEMS, - Symmetry, - _groups, - get_point_group, -) +from orix.quaternion.symmetry import VALID_SYSTEMS, Symmetry, PointGroups from orix.vector.miller import Miller from orix.vector.vector3d import Vector3d @@ -138,7 +132,9 @@ def structure(self, value: Structure) -> None: # Ensure correct alignment old_matrix = value.lattice.base - new_matrix = new_structure_matrix_from_alignment(old_matrix, x="a", z="c*") + new_matrix = new_structure_matrix_from_alignment( + old_matrix, x="a", z="c*" + ) new_value = value.copy() # Ensure atom positions are expressed in the new basis @@ -225,7 +221,7 @@ def point_group(self) -> Symmetry | None: Point group. """ if self.space_group is not None: - return get_point_group(self.space_group.number) + return PointGroups.from_space_group(self.space_group.number) else: return self._point_group @@ -235,14 +231,7 @@ def point_group(self, value: int | str | Symmetry | None) -> None: if isinstance(value, int): value = str(value) if isinstance(value, str): - for key, aliases in _EDAX_POINT_GROUP_ALIASES.items(): - if value in aliases: - value = key - break - for point_group in _groups: - if value == point_group.name: - value = point_group - break + value = PointGroups.get(value) if not isinstance(value, Symmetry) and value is not None: raise ValueError( f"{value!r} must be of type {Symmetry}, the name of a valid point group" @@ -486,7 +475,13 @@ def new_structure_matrix_from_alignment( def default_lattice(system: VALID_SYSTEMS) -> Lattice: - if system in ["triclinic", "monoclinic", "orthorhombic", "tetragonal", "cubic"]: + if system in [ + "triclinic", + "monoclinic", + "orthorhombic", + "tetragonal", + "cubic", + ]: lat = Lattice() elif system in ["trigonal", "hexagonal"]: lat = Lattice(1, 1, 1, 90, 90, 120) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 6e8fd3f4c..e881edfa8 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1430,14 +1430,17 @@ def to_list(self): def get(name: Literal[PointGroups._point_group_names]): """ - Given a string or integer representation, this function will attempt to - return an associated Symmetry object. + Given a string or integer representation, this function will attempt + to return an associated Symmetry object. - This is done by first checking the labels defined in orix, which includes - Hermann-Mauguin ('m3m' or '2', etc.) and Shoenflies ('C6' or D3h', etc.). + This is done by first checking the labels defined in orix, which + includes Hermann-Mauguin ('m3m' or '2', etc.) and Shoenflies + ('C6' or D3h', etc.). - If it cannot find a match in either list, it will attempt to look up the - space group name using diffpy's GetSpaceGroup, and relate that back + Next, it will check through common point group Aliases used by EDAX. + + If none of the anove result in a match, it will attempt to look + up the space group name using diffpy's GetSpaceGroup, and relate that back to a point group. this is equivalent to PointGroups.from_space_group(name) Parameters @@ -1454,17 +1457,21 @@ def get(name: Literal[PointGroups._point_group_names]): """ # check the 'groups' list first, then 'permutations', # then 'permutations_repeated'. - print(vars().keys()) + str_name = str(name).lower() for subset in ["groups", "permutations", "permutations_repeated"]: pgs = _point_groups_dictionary[subset] pg_dict = dict([(x.name, x) for x in pgs]) - if str(name).lower() in pg_dict.keys(): - return pg_dict[name.lower()] + if str_name in pg_dict.keys(): + return pg_dict[str_name] # repeat check with Shoenflies notation pg_dict_s = dict([(x._schoenflies.lower(), x) for x in pgs]) - if str(name).lower() in pg_dict_s.keys(): - return pg_dict_s[name.lower()] - # If the name doesn't exist in orix, try diffpy + if str_name in pg_dict_s.keys(): + return pg_dict_s[str_name] + # If the name doesn't exist in orix, try EDAX + for key, aliases in _EDAX_POINT_GROUP_ALIASES.items(): + if str_name in aliases: + return pg_dict[key] + # Finally, try diffpy try: return PointGroups.from_space_group(name) # If the name still cannot be found, return a ValueError @@ -1477,7 +1484,7 @@ def get(name: Literal[PointGroups._point_group_names]): ) def from_space_group( - self, space_group_number: Union(int, str), proper: bool = False + space_group_number: Union(int, str), proper: bool = False ) -> Symmetry: """ Maps a space group number or name to a crystallographic point group. From 28ab862b582e831f0cefc21295c6cd9a457f8eb4 Mon Sep 17 00:00:00 2001 From: Austin Gerlt Date: Fri, 27 Feb 2026 20:29:37 -0700 Subject: [PATCH 39/40] passing tests. --- orix/quaternion/symmetry.py | 8 +- orix/tests/test_crystal_map/test_phase.py | 74 +++++++--- orix/tests/test_io/test_ang.py | 80 ++++++++--- .../test_plot/test_stereographic_plot.py | 129 +++++++++++------- 4 files changed, 201 insertions(+), 90 deletions(-) diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index e881edfa8..58a389544 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1477,10 +1477,10 @@ def get(name: Literal[PointGroups._point_group_names]): # If the name still cannot be found, return a ValueError except ValueError: raise ValueError( - f"'name' must be one of {', '.join(map(str, pg_dict.keys()))}," - + f" {', '.join(map(str, pg_dict_s.keys()))}, or must be a string or " - + "integer recognized by diffpy.structure.spacegroups.GetSpaceGroup" - + f". name = '{name}' is not a valid value." + f"'{name}' could not be interpreted as a point group name. Valid options " + + f"are {', '.join(map(str, pg_dict.keys()))}, " + + f"{', '.join(map(str, pg_dict_s.keys()))}, or a value recognizable by " + + "diffpy.structure.spacegroups.GetSpaceGroup." ) def from_space_group( diff --git a/orix/tests/test_crystal_map/test_phase.py b/orix/tests/test_crystal_map/test_phase.py index fa841b739..45a51f4db 100644 --- a/orix/tests/test_crystal_map/test_phase.py +++ b/orix/tests/test_crystal_map/test_phase.py @@ -22,7 +22,10 @@ import pytest from orix.crystal_map import Phase -from orix.crystal_map._phase import default_lattice, new_structure_matrix_from_alignment +from orix.crystal_map._phase import ( + default_lattice, + new_structure_matrix_from_alignment, +) from orix.quaternion.symmetry import O, Symmetry @@ -61,7 +64,14 @@ class TestPhase: ], ) def test_init_phase( - self, name, point_group, space_group, color, color_name, color_rgb, structure + self, + name, + point_group, + space_group, + color, + color_name, + color_rgb, + structure, ): p = Phase( name=name, @@ -153,14 +163,17 @@ def test_set_phase_color(self, color, color_alias, color_rgb, fails): def test_set_phase_point_group(self, point_group, point_group_name, fails): p = Phase() if fails: - with pytest.raises(ValueError, match=f"'{point_group}' must be of type"): + with pytest.raises( + ValueError, match=f"'{point_group}' could not be interpreted as" + ): p.point_group = point_group else: p.point_group = point_group assert p.point_group.name == point_group_name @pytest.mark.parametrize( - "structure", [Structure(), Structure(lattice=Lattice(1, 2, 3, 90, 120, 90))] + "structure", + [Structure(), Structure(lattice=Lattice(1, 2, 3, 90, 120, 90))], ) def test_set_structure(self, structure): p = Phase() @@ -177,7 +190,9 @@ def test_set_structure_phase_name(self): def test_set_structure_raises(self): p = Phase() - with pytest.raises(ValueError, match=".* must be a diffpy.structure.Structure"): + with pytest.raises( + ValueError, match=".* must be a diffpy.structure.Structure" + ): p.structure = [1, 2, 3, 90, 90, 90] @pytest.mark.parametrize( @@ -234,12 +249,21 @@ def test_shallow_copy_phase(self): assert p.__repr__() == p2.__repr__() def test_phase_init_non_matching_space_group_point_group(self): - with pytest.warns(UserWarning, match="Setting space group to 'None', as"): + with pytest.warns( + UserWarning, match="Setting space group to 'None', as" + ): _ = Phase(space_group=225, point_group="432") @pytest.mark.parametrize( "space_group_no, desired_point_group_name", - [(1, "1"), (50, "mmm"), (100, "4mm"), (150, "32"), (200, "m-3"), (225, "m-3m")], + [ + (1, "1"), + (50, "mmm"), + (100, "4mm"), + (150, "32"), + (200, "m-3"), + (225, "m-3m"), + ], ) def test_point_group_derived_from_space_group( self, space_group_no, desired_point_group_name @@ -249,7 +273,9 @@ def test_point_group_derived_from_space_group( def test_set_space_group_raises(self): space_group = "outer-space" - with pytest.raises(ValueError, match=f"'{space_group}' must be of type "): + with pytest.raises( + ValueError, match=f"'{space_group}' must be of type " + ): p = Phase() p.space_group = space_group @@ -268,7 +294,9 @@ def test_is_hexagonal(self): def test_structure_matrix(self): """Structure matrix is updated assuming e1 || a, e3 || c*.""" trigonal_lattice = Lattice(1.7, 1.7, 1.4, 90, 90, 120) - phase = Phase(point_group="321", structure=Structure(lattice=trigonal_lattice)) + phase = Phase( + point_group="321", structure=Structure(lattice=trigonal_lattice) + ) lattice = phase.structure.lattice # Lattice parameters are unchanged @@ -298,7 +326,9 @@ def test_structure_matrix(self): # Getting new structure matrix without passing enough parameters # raises an error - with pytest.raises(ValueError, match="At least two of x, y, z must be set."): + with pytest.raises( + ValueError, match="At least two of x, y, z must be set." + ): _ = new_structure_matrix_from_alignment(lattice.base, x="a") def test_triclinic_structure_matrix(self): @@ -338,7 +368,9 @@ def test_triclinic_structure_matrix(self): def test_lattice_vectors(self): """Correct direct and reciprocal lattice vectors.""" trigonal_lattice = Lattice(1.7, 1.7, 1.4, 90, 90, 120) - phase = Phase(point_group="321", structure=Structure(lattice=trigonal_lattice)) + phase = Phase( + point_group="321", structure=Structure(lattice=trigonal_lattice) + ) a, b, c = phase.a_axis, phase.b_axis, phase.c_axis ar, br, cr = phase.ar_axis, phase.br_axis, phase.cr_axis @@ -392,13 +424,17 @@ def test_atom_positions(self, lat, atoms): # however, Phase should (in many cases) change the basis. if np.allclose(structure.lattice.base, phase.structure.lattice.base): # In this branch we are in the same basis & all atoms should be the same - for atom_from_structure, atom_from_phase in zip(structure, phase.structure): + for atom_from_structure, atom_from_phase in zip( + structure, phase.structure + ): assert np.allclose(atom_from_structure.xyz, atom_from_phase.xyz) else: # Here we have differing basis, so xyz must disagree for at least some atoms disagreement_found = False - for atom_from_structure, atom_from_phase in zip(structure, phase.structure): + for atom_from_structure, atom_from_phase in zip( + structure, phase.structure + ): if not np.allclose(atom_from_structure.xyz, atom_from_phase.xyz): disagreement_found = True break @@ -414,14 +450,18 @@ def test_from_cif(self, cif_file): lattice = phase.structure.lattice assert np.allclose(lattice.abcABG(), [15.5, 4.05, 6.74, 90, 105.3, 90]) assert np.allclose( - lattice.base, [[15.5, 0, 0], [0, 4.05, 0], [-1.779, 0, 6.501]], atol=1e-3 + lattice.base, + [[15.5, 0, 0], [0, 4.05, 0], [-1.779, 0, 6.501]], + atol=1e-3, ) def test_from_cif_same_structure(self, cif_file): phase1 = Phase.from_cif(cif_file) structure = loadStructure(cif_file) phase2 = Phase(structure=structure) - assert np.allclose(phase1.structure.lattice.base, phase2.structure.lattice.base) + assert np.allclose( + phase1.structure.lattice.base, phase2.structure.lattice.base + ) assert np.allclose(phase1.structure.xyz, phase2.structure.xyz) @pytest.mark.parametrize( @@ -978,5 +1018,7 @@ def test_default_lattice(self): assert np.allclose([1, 1, 1, 90, 90, 120], lattice_parameters) def test_default_lattice_raises(self): - with pytest.raises(ValueError, match="Unknown crystal system 'rhombohedral'"): + with pytest.raises( + ValueError, match="Unknown crystal system 'rhombohedral'" + ): default_lattice("rhombohedral") diff --git a/orix/tests/test_io/test_ang.py b/orix/tests/test_io/test_ang.py index b7dbd48fe..a957343b3 100644 --- a/orix/tests/test_io/test_ang.py +++ b/orix/tests/test_io/test_ang.py @@ -49,7 +49,10 @@ class TestAngReader: np.zeros(5 * 3, dtype=int), # phase_id 5, # n_unknown_columns np.array( - [[1.59942, 2.37748, 4.53419], [1.59331, 2.37417, 4.53628]] + [ + [1.59942, 2.37748, 4.53419], + [1.59331, 2.37417, 4.53628], + ] ), # rotations as rows of Euler angle triplets ), (5, 3), @@ -67,7 +70,10 @@ class TestAngReader: np.zeros(8 * 4, dtype=int), # phase_id 1, # n_unknown_columns np.array( - [[5.81107, 2.34188, 4.47345], [6.16205, 0.79936, 1.31702]] + [ + [5.81107, 2.34188, 4.47345], + [6.16205, 0.79936, 1.31702], + ] ), # rotations as rows of Euler angle triplets ), (8, 4), @@ -82,7 +88,13 @@ class TestAngReader: indirect=["angfile_tsl"], ) def test_load_ang_tsl( - self, angfile_tsl, map_shape, step_sizes, phase_id, n_unknown_cols, example_rot + self, + angfile_tsl, + map_shape, + step_sizes, + phase_id, + n_unknown_cols, + example_rot, ): xmap = load(angfile_tsl) @@ -155,7 +167,10 @@ def test_load_ang_tsl( (4.5, 4.5), np.ones(9 * 3, dtype=int), np.array( - [[1.895079, 0.739496, 1.413542], [1.897871, 0.742638, 1.413717]] + [ + [1.895079, 0.739496, 1.413542], + [1.897871, 0.742638, 1.413717], + ] ), ), ( @@ -174,7 +189,10 @@ def test_load_ang_tsl( (10, 10), np.ones(11 * 13, dtype=int), np.array( - [[1.621760, 2.368935, 4.559324], [1.604481, 2.367539, 4.541870]] + [ + [1.621760, 2.368935, 4.559324], + [1.604481, 2.367539, 4.541870], + ] ), ), ], @@ -253,7 +271,10 @@ def test_load_ang_astar( ) ), np.array( - [[1.895079, 0.739496, 1.413542], [1.897871, 0.742638, 1.413717]] + [ + [1.895079, 0.739496, 1.413542], + [1.897871, 0.742638, 1.413717], + ] ), ), ( @@ -267,7 +288,10 @@ def test_load_ang_astar( ) ), # phase_id np.array( - [[1.62176, 2.36894, 1.72386], [1.60448, 2.36754, 1.72386]] + [ + [1.62176, 2.36894, 1.72386], + [1.60448, 2.36754, 1.72386], + ] ), ), (3, 6), @@ -278,7 +302,9 @@ def test_load_ang_astar( np.ones(int(np.floor((3 * 6) / 2))) * 2, ) ), - np.array([[1.62176, 2.36894, 1.72386], [1.60448, 2.36754, 1.72386]]), + np.array( + [[1.62176, 2.36894, 1.72386], [1.60448, 2.36754, 1.72386]] + ), ), ], indirect=["angfile_emsoft"], @@ -362,14 +388,24 @@ def test_get_header(self, temp_ang_file): ], ANGFILE_TSL_HEADER, ), - ("astar", ["ind", "rel", "phase_id", "relx100"], ANGFILE_ASTAR_HEADER), + ( + "astar", + ["ind", "rel", "phase_id", "relx100"], + ANGFILE_ASTAR_HEADER, + ), ("emsoft", ["iq", "dp", "phase_id"], ANGFILE_EMSOFT_HEADER), ], ) def test_get_vendor_columns( self, expected_vendor, expected_columns, vendor_header, temp_ang_file ): - expected_columns = ["euler1", "euler2", "euler3", "x", "y"] + expected_columns + expected_columns = [ + "euler1", + "euler2", + "euler3", + "x", + "y", + ] + expected_columns n_cols_file = len(expected_columns) temp_ang_file.write(vendor_header) @@ -387,7 +423,9 @@ def test_get_vendor_columns_unknown(self, temp_ang_file, n_cols_file): temp_ang_file.close() with open(temp_ang_file.name) as f: header = _get_header(f) - with pytest.warns(UserWarning, match=f"Number of columns, {n_cols_file}, "): + with pytest.warns( + UserWarning, match=f"Number of columns, {n_cols_file}, " + ): vendor, column_names = _get_vendor_columns(header, n_cols_file) assert vendor == "unknown" expected_columns = [ @@ -466,7 +504,9 @@ def test_get_phases_from_header( assert formulas == expected_formula assert phases["names"] == expected_names assert phases["point_groups"] == expected_point_groups - assert np.allclose(phases["lattice_constants"], expected_lattice_constants) + assert np.allclose( + phases["lattice_constants"], expected_lattice_constants + ) assert np.allclose(phases["ids"], expected_phase_id) def test_phasename_autogen(self): @@ -630,7 +670,7 @@ def test_extra_phases(self, crystal_map, tmp_path, extra_phase_names): del pl[-1] assert xmap_reload.phases.names == pl.names - @pytest.mark.parametrize("point_group", ["432", "121", "222", "321", "622"]) + @pytest.mark.parametrize("point_group", ["432", "121", "222", "622"]) def test_point_group_aliases(self, crystal_map, tmp_path, point_group): crystal_map.phases[0].point_group = point_group fname = tmp_path / "test_point_group_aliases.ang" @@ -654,7 +694,9 @@ def test_1d_map(self, crystal_map_input, tmp_path): xmap_reload = load(fname) assert xmap_reload.ndim == 1 - assert np.allclose(xmap.rotations.to_euler(), xmap_reload.rotations.to_euler()) + assert np.allclose( + xmap.rotations.to_euler(), xmap_reload.rotations.to_euler() + ) @pytest.mark.parametrize( "crystal_map_input, index", @@ -667,7 +709,9 @@ def test_1d_map(self, crystal_map_input, tmp_path): ) def test_write_data_index(self, crystal_map_input, tmp_path, index): xmap = CrystalMap(**crystal_map_input) - ci = np.arange(xmap.size * xmap.rotations_per_point).reshape(xmap.size, -1) + ci = np.arange(xmap.size * xmap.rotations_per_point).reshape( + xmap.size, -1 + ) xmap.prop["ci"] = ci xmap.prop["iq"] = np.arange(xmap.size) extra_prop = "iq_times_ci" @@ -698,9 +742,9 @@ def test_write_data_index_none(self, crystal_map_input, tmp_path): property arrays. """ xmap = CrystalMap(**crystal_map_input) - xmap.prop["ci"] = np.arange(xmap.size * xmap.rotations_per_point).reshape( - xmap.size, xmap.rotations_per_point - ) + xmap.prop["ci"] = np.arange( + xmap.size * xmap.rotations_per_point + ).reshape(xmap.size, xmap.rotations_per_point) fname = tmp_path / "test_write_data_index_none.ang" save(fname, xmap, extra_prop=["ci"]) diff --git a/orix/tests/test_plot/test_stereographic_plot.py b/orix/tests/test_plot/test_stereographic_plot.py index ae665a1ad..090b05fe7 100644 --- a/orix/tests/test_plot/test_stereographic_plot.py +++ b/orix/tests/test_plot/test_stereographic_plot.py @@ -25,10 +25,9 @@ import numpy as np import pytest -from orix import plot -from orix.plot.stereographic_plot import _SymmetryMarker -from orix.quaternion import symmetry -from orix.vector import Vector3d +import orix.plot as opl +import orix.quaternion as oqu +import orix.vector as ove plt.rcParams["axes.grid"] = True PROJ_NAME = "stereographic" @@ -44,7 +43,7 @@ def test_vector_scatter(self): assert ax.can_pan() assert ax.can_zoom() - v = Vector3d([[0, 0, 1], [2, 0, 2]]) + v = ove.Vector3d([[0, 0, 1], [2, 0, 2]]) ax.scatter(v[0]) ax.scatter(v[1].azimuth, v[1].polar) @@ -54,8 +53,8 @@ def test_vector_scatter(self): plt.close("all") def test_ori_scatter(self): - oris = Orientation.from_axes_angles( - [1, 2, 3], [5, 15, 70], degrees=True, symmetry=symmetry.D6 + oris = oqu.Orientation.from_axes_angles( + [1, 2, 3], [5, 15, 70], degrees=True, symmetry=oqu.symmetry.D6 ) fig0 = oris.scatter(return_figure=True) @@ -84,7 +83,7 @@ def test_ori_scatter(self): @pytest.mark.flaky(reruns=5) def test_scatter_coloring(self): # Marked as flaky since no vectors may be visible - v = Vector3d.random(10) + v = ove.Vector3d.random(10) visible = v.z >= 0 # Four ways to color vectors @@ -118,13 +117,17 @@ def test_scatter_coloring(self): assert np.allclose(c3, mcolors.to_rgba(c_color)) # Single color with single alpha - fig4 = v.scatter(c=c_color, alpha=0.5, return_figure=True, label="scatter") + fig4 = v.scatter( + c=c_color, alpha=0.5, return_figure=True, label="scatter" + ) fig4.canvas.draw() c4 = fig4.axes[0]._get_collection("scatter").get_facecolor() assert np.allclose(c4, mcolors.to_rgba(c_color, 0.5)) # Single color with varying alpha - fig5 = v.scatter(c=c_color, alpha=c_scalar, return_figure=True, label="scatter") + fig5 = v.scatter( + c=c_color, alpha=c_scalar, return_figure=True, label="scatter" + ) fig5.canvas.draw() c5 = fig5.axes[0]._get_collection("scatter").get_facecolor() rgba5 = np.full((v.size, 4), mcolors.to_rgba(c_color)) @@ -133,9 +136,9 @@ def test_scatter_coloring(self): def test_text(self): _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) - v = Vector3d([[0, 0, 1], [-1, 0, 1], [1, 1, 1]]) + v = ove.Vector3d([[0, 0, 1], [-1, 0, 1], [1, 1, 1]]) ax.scatter(v) - labels = plot.format_labels(v.data, ("[", "]"), use_latex=False) + labels = opl.format_labels(v.data, ("[", "]"), use_latex=False) for i in range(v.size): ax.text(v[i], s=labels[i]) @@ -148,9 +151,9 @@ def test_text(self): def test_text_offset(self): _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) - v = Vector3d([[0, 0, 1], [-1, 0, 1], [1, 1, 1]]) + v = ove.Vector3d([[0, 0, 1], [-1, 0, 1], [1, 1, 1]]) ax.scatter(v) - labels = plot.format_labels(v.data) + labels = opl.format_labels(v.data) offset = (-0.02, 0.05) for i in range(v.size): ax.text(v[i], s=labels[i], offset=offset) @@ -281,7 +284,7 @@ def test_show_hemisphere_label(self): label_xy = [-0.71, 0.71] - ax[0].scatter(Vector3d([0, 0, 1])) + ax[0].scatter(ove.Vector3d([0, 0, 1])) ax[0].show_hemisphere_label() label_up = ax[0].texts[0] assert label_up.get_text() == "upper" @@ -289,7 +292,7 @@ def test_show_hemisphere_label(self): assert np.allclose([label_up._x, label_up._y], label_xy) ax[1].hemisphere = "lower" - ax[1].scatter(Vector3d([0, 0, -1])) + ax[1].scatter(ove.Vector3d([0, 0, -1])) ax[1].show_hemisphere_label(color="r") label_low = ax[1].texts[0] assert label_low.get_text() == "lower" @@ -337,7 +340,7 @@ def test_format_coord(self): plt.close("all") def test_empty_scatter(self): - v = Vector3d([0, 0, 1]) + v = ove.Vector3d([0, 0, 1]) _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) ax.hemisphere = "lower" @@ -352,22 +355,26 @@ def test_empty_scatter(self): @pytest.mark.parametrize("shape", [(5, 10), (2, 3)]) def test_multidimensional_vector(self, shape): n = np.prod(shape) - v = Vector3d(np.random.normal(size=3 * n).reshape(*shape, 3)) + v = ove.Vector3d(np.random.normal(size=3 * n).reshape(*shape, 3)) v.scatter() v.draw_circle() plt.close("all") def test_order_in_hemisphere(self): - v = Vector3d.from_polar( + v = ove.Vector3d.from_polar( azimuth=np.radians([45, 90, 135, 180]), polar=np.radians([50, 45, 140, 135]), ) _, ax = plt.subplots(ncols=2, subplot_kw=dict(projection=PROJ_NAME)) ax[1].hemisphere = "lower" - x_upper, y_upper, visible_upper = ax[0]._pretransform_input((v,), sort=True) - x_lower, y_lower, visible_lower = ax[1]._pretransform_input((v,), sort=True) + x_upper, y_upper, visible_upper = ax[0]._pretransform_input( + (v,), sort=True + ) + x_lower, y_lower, visible_lower = ax[1]._pretransform_input( + (v,), sort=True + ) x_upper_desired, y_upper_desired = ax[0]._projection.vector2xy(v[:2]) assert np.allclose(x_upper, x_upper_desired) @@ -390,20 +397,24 @@ def test_order_in_hemisphere(self): def test_color_parameter(self): """Pass either ``color`` or ``c`` to color scatter points.""" - v = Vector3d([[1, 0, 0], [1, 1, 0], [1, 1, 1]]) + v = ove.Vector3d([[1, 0, 0], [1, 1, 0], [1, 1, 1]]) colors = [f"C{i}" for i in range(v.size)] colors_rgba = np.array([mcolors.to_rgba(c) for c in colors]) fig = v.scatter(color=colors, return_figure=True) - assert np.allclose(fig.axes[0].collections[0].get_facecolors(), colors_rgba) + assert np.allclose( + fig.axes[0].collections[0].get_facecolors(), colors_rgba + ) fig2 = v.scatter(c=colors, return_figure=True) - assert np.allclose(fig2.axes[0].collections[0].get_facecolors(), colors_rgba) + assert np.allclose( + fig2.axes[0].collections[0].get_facecolors(), colors_rgba + ) def test_size_parameter(self): """Pass either ``sizes`` or ``s`` to set scatter points sizes.""" - v = Vector3d([[1, 0, 0], [1, 1, 0], [1, 1, 1]]) + v = ove.Vector3d([[1, 0, 0], [1, 1, 0], [1, 1, 1]]) sizes = np.arange(v.size) fig = v.scatter(sizes=sizes, return_figure=True) @@ -414,36 +425,46 @@ def test_size_parameter(self): class TestSymmetryMarker: - @pytest.mark.parametrize("v_data", [[0, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]) + @pytest.mark.parametrize( + "v_data", [[0, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]] + ) @pytest.mark.parametrize("folds", [1, 2, 3, 4, 6]) - @pytest.mark.parametrize("modifier", [None, "none", "rotoinversion", "inversion"]) + @pytest.mark.parametrize( + "modifier", [None, "none", "rotoinversion", "inversion"] + ) def test_main_properties(self, v_data, folds, modifier): - v = Vector3d(v_data) - marker = _SymmetryMarker(v, folds=folds, modifier=modifier) + v = ove.Vector3d(v_data) + marker = opl.stereographic_plot._SymmetryMarker( + v, folds=folds, modifier=modifier + ) assert np.allclose(v.data, marker._vector.data) assert marker._folds == folds assert marker._inner_shape == modifier assert (marker.angle_deg - (np.rad2deg(v.azimuth) + 90)) ** 2 < 1e-4 # check errors with pytest.raises(ValueError, match="Folds must"): - _SymmetryMarker([0, 0, 1], folds=5) + opl.stereographic_plot._SymmetryMarker([0, 0, 1], folds=5) with pytest.raises(ValueError, match="Modifier must"): - _SymmetryMarker([0, 0, 1], modifier="banana") + opl.stereographic_plot._SymmetryMarker([0, 0, 1], modifier="banana") def test_plot_symmetry_marker(self): _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) ax.stereographic_grid(False) marker_size = 500 - v = Vector3d([[1, 0, 0], [0, 1, 1]]) + v = ove.Vector3d([[1, 0, 0], [0, 1, 1]]) for i in [1, 2, 3, 4, 6]: ax.symmetry_marker(v[0], folds=i, s=marker_size, color="k") - ax.symmetry_marker(v[1], folds=i, modifier="inversion", s=marker_size) + ax.symmetry_marker( + v[1], folds=i, modifier="inversion", s=marker_size + ) ax.symmetry_marker( v, folds=1, modifier="inversion", color="C1", s=marker_size ) for i in [4, 6]: - ax.symmetry_marker(v, folds=i, modifier="rotoinversion", s=marker_size) + ax.symmetry_marker( + v, folds=i, modifier="rotoinversion", s=marker_size + ) markers = ax.collections assert len(markers) == 43 @@ -469,12 +490,12 @@ class TestDrawCircle: ) def test_visible_in_hemisphere(self, pole, polar, desired_array): assert np.allclose( - plot.stereographic_plot._is_visible(polar, pole), desired_array + opl.stereographic_plot._is_visible(polar, pole), desired_array ) def test_draw_circle(self): - v1 = Vector3d([[0, 0, 1], [1, 0, 1], [1, 1, 1]]) - v2 = Vector3d(np.append(v1.data, -v1.data, axis=0)) + v1 = ove.Vector3d([[0, 0, 1], [1, 0, 1], [1, 1, 1]]) + v2 = ove.Vector3d(np.append(v1.data, -v1.data, axis=0)) upper_steps = 100 lower_steps = 150 @@ -504,7 +525,7 @@ def test_draw_circle(self): plt.close("all") def test_draw_circle_empty(self): - v1 = Vector3d([[0, 0, 1], [1, 0, 1], [1, 1, 1]]) + v1 = ove.Vector3d([[0, 0, 1], [1, 0, 1], [1, 1, 1]]) _, ax = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) ax.hemisphere = "lower" ax.draw_circle(v1) @@ -514,7 +535,7 @@ def test_draw_circle_empty(self): def test_draw_circle_opening_angle_array(self): """Passing an opening angle per vector as an array works.""" - v = Vector3d([(0, 0, 1), (0, 0, -1), (1, 0, 1)]) + v = ove.Vector3d([(0, 0, 1), (0, 0, -1), (1, 0, 1)]) fig = v.draw_circle( opening_angle=np.array([np.pi / 2, np.pi / 4, np.pi / 2]), return_figure=True, @@ -528,9 +549,11 @@ def test_draw_circle_opening_angle_array(self): plt.close("all") def test_pdf_args(self): - v = Vector3d.random(10) + v = ove.Vector3d.random(10) resolution = 5 - fig, ax = plt.subplots(ncols=2, subplot_kw=dict(projection="stereographic")) + fig, ax = plt.subplots( + ncols=2, subplot_kw=dict(projection="stereographic") + ) # vector arg ax[0].pole_density_function(v, resolution=resolution) qm0 = [isinstance(c, QuadMesh) for c in ax[0].collections] @@ -567,13 +590,13 @@ def test_restrict_to_fundamental_sector(self): # C1 has no fundamental sector, so the circle marking the # edge of the axis region should be unchanged _, ax2 = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) - ax2.restrict_to_sector(symmetry.C1.fundamental_sector) + ax2.restrict_to_sector(oqu.symmetry.C1.fundamental_sector) assert np.allclose(vertices, ax2.patches[0].get_verts()) # C6 fundamental sector is 1 / 6 of the unit sphere, with # half of it in the upper hemisphere _, ax3 = plt.subplots(ncols=2, subplot_kw=dict(projection=PROJ_NAME)) - ax3[0].restrict_to_sector(symmetry.C6.fundamental_sector) + ax3[0].restrict_to_sector(oqu.symmetry.C6.fundamental_sector) assert not np.allclose(vertices[:10], ax3[0].patches[0].get_verts()[:10]) assert ax3[0].patches[1].get_label() == "_stereographic_sector" @@ -585,7 +608,7 @@ def test_restrict_to_fundamental_sector(self): # Oh's fundamental sector is only in the upper hemisphere, # so the same as C1's sector applies for the lower hemisphere - fs = symmetry.Oh.fundamental_sector + fs = oqu.symmetry.Oh.fundamental_sector _, ax4 = plt.subplots(subplot_kw=dict(projection=PROJ_NAME)) ax4.restrict_to_sector(fs) upper_patches4 = ax4.patches @@ -611,18 +634,18 @@ def test_restrict_to_fundamental_sector(self): plt.close("all") def test_restrict_to_sector_pad(self): - v = Vector3d.zvector() + v = ove.Vector3d.zvector() fig = v.scatter(return_figure=True) ax = fig.axes[0] assert np.allclose(ax.get_xlim(), [-1.05, 1.05]) # No change since the sector is the equator - ax.restrict_to_sector(symmetry.C1.fundamental_sector) + ax.restrict_to_sector(oqu.symmetry.C1.fundamental_sector) assert np.allclose(ax.get_xlim(), [-1.05, 1.05]) # Default - fs_m3m = symmetry.Oh.fundamental_sector + fs_m3m = oqu.symmetry.Oh.fundamental_sector ax.restrict_to_sector(fs_m3m) assert np.allclose(ax.get_xlim(), [-0.0103, 0.4245], atol=1e-4) @@ -633,19 +656,21 @@ def test_restrict_to_sector_pad(self): plt.close("all") def test_restrict_to_sector_edges(self): - v = Vector3d.zvector() + v = ove.Vector3d.zvector() fig = v.scatter(return_figure=True) ax = fig.axes[0] - ax.restrict_to_sector(symmetry.Oh.fundamental_sector, show_edges=False) + ax.restrict_to_sector( + oqu.symmetry.Oh.fundamental_sector, show_edges=False + ) assert len(ax.patches) == 1 assert ax.patches[0].get_label() == "_stereographic_border" plt.close("all") def test_restrict_to_sector_full_projection(self): - v = Vector3d.zvector() + v = ove.Vector3d.zvector() fig = v.scatter(return_figure=True) ax = fig.axes[0] @@ -654,12 +679,12 @@ def test_restrict_to_sector_full_projection(self): # Ensure padding changes little (ideally to +/- 1.01, but it # does not on Windows...) - ax.restrict_to_sector(symmetry.Ci.fundamental_sector) + ax.restrict_to_sector(oqu.symmetry.Ci.fundamental_sector) xmin2, xmax2 = ax.get_xlim() assert all([(xmin + xmin2) <= 0.05, (xmax - xmax2) <= 0.05]) # Upper part of projection, azimuthal in [0, 180] - ax.restrict_to_sector(symmetry.C2h.fundamental_sector) + ax.restrict_to_sector(oqu.symmetry.C2h.fundamental_sector) assert np.allclose(ax.get_ylim(), [-0.01, 1.01], atol=1e-3) plt.close("all") From eabf43164f72a754e7eef2ad5ebacab90f61bbb7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:34:58 +0000 Subject: [PATCH 40/40] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../clustering_misorientations.ipynb | 1 - doc/tutorials/clustering_orientations.ipynb | 1 - doc/tutorials/crystal_directions.ipynb | 1 - doc/tutorials/crystal_map.ipynb | 1 - doc/tutorials/inverse_pole_figures.ipynb | 1 - doc/tutorials/point_groups.ipynb | 1 - doc/tutorials/s2_sampling.ipynb | 1 - doc/tutorials/stereographic_projection.ipynb | 1 - ...niform_sampling_of_orientation_space.ipynb | 1 - .../plot_symmetry_advanced.py | 2 +- orix/crystal_map/_phase.py | 6 +- orix/crystal_map/_phase_list.py | 27 +++------ orix/plot/stereographic_plot.py | 55 +++++-------------- orix/quaternion/symmetry.py | 16 ++---- orix/sampling/S2_sampling.py | 6 +- orix/tests/test_crystal_map/test_phase.py | 40 ++++---------- orix/tests/test_io/test_ang.py | 26 +++------ .../test_plot/test_stereographic_plot.py | 48 ++++------------ .../tests/test_quaternion/test_orientation.py | 1 + 19 files changed, 61 insertions(+), 175 deletions(-) diff --git a/doc/tutorials/clustering_misorientations.ipynb b/doc/tutorials/clustering_misorientations.ipynb index 404f4d9db..b571f074a 100644 --- a/doc/tutorials/clustering_misorientations.ipynb +++ b/doc/tutorials/clustering_misorientations.ipynb @@ -43,7 +43,6 @@ "from orix.quaternion.symmetry import D6\n", "from orix.vector import Vector3d\n", "\n", - "\n", "plt.rcParams.update({\"font.size\": 20, \"figure.figsize\": (10, 10)})\n", "\n", "register_projections()" diff --git a/doc/tutorials/clustering_orientations.ipynb b/doc/tutorials/clustering_orientations.ipynb index 5b15d014b..1552bd567 100644 --- a/doc/tutorials/clustering_orientations.ipynb +++ b/doc/tutorials/clustering_orientations.ipynb @@ -46,7 +46,6 @@ "from orix.quaternion.symmetry import D6\n", "from orix.vector import AxAngle, Vector3d\n", "\n", - "\n", "plt.rcParams.update(\n", " {\"font.size\": 20, \"figure.figsize\": (10, 10), \"figure.facecolor\": \"w\"}\n", ")" diff --git a/doc/tutorials/crystal_directions.ipynb b/doc/tutorials/crystal_directions.ipynb index 1e9953b19..77a5cb286 100644 --- a/doc/tutorials/crystal_directions.ipynb +++ b/doc/tutorials/crystal_directions.ipynb @@ -42,7 +42,6 @@ "from orix.quaternion import Orientation, Rotation, symmetry\n", "from orix.vector import Miller, Vector3d\n", "\n", - "\n", "plt.rcParams.update(\n", " {\n", " \"figure.figsize\": (7, 7),\n", diff --git a/doc/tutorials/crystal_map.ipynb b/doc/tutorials/crystal_map.ipynb index 65d0a67eb..3f0787e4a 100644 --- a/doc/tutorials/crystal_map.ipynb +++ b/doc/tutorials/crystal_map.ipynb @@ -44,7 +44,6 @@ "from orix.quaternion import Orientation, Rotation, symmetry\n", "from orix.vector import Vector3d\n", "\n", - "\n", "plt.rcParams.update({\"figure.figsize\": (7, 7), \"font.size\": 15})\n", "tempdir = tempfile.mkdtemp() + \"/\"" ] diff --git a/doc/tutorials/inverse_pole_figures.ipynb b/doc/tutorials/inverse_pole_figures.ipynb index 439fd1fb3..7f2ed29ba 100644 --- a/doc/tutorials/inverse_pole_figures.ipynb +++ b/doc/tutorials/inverse_pole_figures.ipynb @@ -61,7 +61,6 @@ "from orix.quaternion import Orientation, symmetry\n", "from orix.vector import Vector3d\n", "\n", - "\n", "# We'll want our plots to look a bit larger than the default size\n", "new_params = {\n", " \"figure.facecolor\": \"w\",\n", diff --git a/doc/tutorials/point_groups.ipynb b/doc/tutorials/point_groups.ipynb index fbdabebbd..9d5583f57 100644 --- a/doc/tutorials/point_groups.ipynb +++ b/doc/tutorials/point_groups.ipynb @@ -37,7 +37,6 @@ "from orix.quaternion import Rotation, symmetry\n", "from orix.vector import Vector3d\n", "\n", - "\n", "plt.rcParams.update({\"font.size\": 15})" ] }, diff --git a/doc/tutorials/s2_sampling.ipynb b/doc/tutorials/s2_sampling.ipynb index 36d5a0ccc..a1fb90eb1 100644 --- a/doc/tutorials/s2_sampling.ipynb +++ b/doc/tutorials/s2_sampling.ipynb @@ -38,7 +38,6 @@ "from orix.quaternion import symmetry\n", "from orix.sampling import sample_S2_methods, sample_S2\n", "\n", - "\n", "plt.rcParams.update({\"font.size\": 20, \"lines.markersize\": 2})" ] }, diff --git a/doc/tutorials/stereographic_projection.ipynb b/doc/tutorials/stereographic_projection.ipynb index 418ab5e79..c2047cf92 100644 --- a/doc/tutorials/stereographic_projection.ipynb +++ b/doc/tutorials/stereographic_projection.ipynb @@ -47,7 +47,6 @@ "from orix import plot # Register orix' projections with Matplotlib\n", "from orix.vector import Vector3d\n", "\n", - "\n", "# We'll want our plots to look a bit larger than the default size\n", "plt.rcParams.update(\n", " {\n", diff --git a/doc/tutorials/uniform_sampling_of_orientation_space.ipynb b/doc/tutorials/uniform_sampling_of_orientation_space.ipynb index 38408963a..507047c27 100644 --- a/doc/tutorials/uniform_sampling_of_orientation_space.ipynb +++ b/doc/tutorials/uniform_sampling_of_orientation_space.ipynb @@ -80,7 +80,6 @@ "from orix.sampling import get_sample_fundamental\n", "from orix.vector import Vector3d\n", "\n", - "\n", "plt.rcParams.update(\n", " {\n", " \"axes.grid\": True,\n", diff --git a/examples/stereographic_projection/plot_symmetry_advanced.py b/examples/stereographic_projection/plot_symmetry_advanced.py index a9463e503..7f2ad238a 100644 --- a/examples/stereographic_projection/plot_symmetry_advanced.py +++ b/examples/stereographic_projection/plot_symmetry_advanced.py @@ -31,9 +31,9 @@ import matplotlib.pyplot as plt import numpy as np +import orix.plot as opl import orix.quaternion as oqu import orix.vector as ove -import orix.plot as opl # Set a random seed for reproducability np.random.seed = 897897 diff --git a/orix/crystal_map/_phase.py b/orix/crystal_map/_phase.py index 4aaf6bae4..2d8fa7afb 100644 --- a/orix/crystal_map/_phase.py +++ b/orix/crystal_map/_phase.py @@ -33,7 +33,7 @@ import numpy as np from orix.plot._util.color import get_matplotlib_color -from orix.quaternion.symmetry import VALID_SYSTEMS, Symmetry, PointGroups +from orix.quaternion.symmetry import VALID_SYSTEMS, PointGroups, Symmetry from orix.vector.miller import Miller from orix.vector.vector3d import Vector3d @@ -132,9 +132,7 @@ def structure(self, value: Structure) -> None: # Ensure correct alignment old_matrix = value.lattice.base - new_matrix = new_structure_matrix_from_alignment( - old_matrix, x="a", z="c*" - ) + new_matrix = new_structure_matrix_from_alignment(old_matrix, x="a", z="c*") new_value = value.copy() # Ensure atom positions are expressed in the new basis diff --git a/orix/crystal_map/_phase_list.py b/orix/crystal_map/_phase_list.py index 26278daef..54e077627 100644 --- a/orix/crystal_map/_phase_list.py +++ b/orix/crystal_map/_phase_list.py @@ -121,14 +121,10 @@ class PhaseList: def __init__( self, - phases: ( - Phase | list[Phase] | dict[int, Phase] | "PhaseList" | None - ) = None, + phases: Phase | list[Phase] | dict[int, Phase] | "PhaseList" | None = None, names: str | list[str] | None = None, space_groups: int | SpaceGroup | list[int | SpaceGroup] | None = None, - point_groups: ( - str | int | Symmetry | list[str | int | Symmetry] | None - ) = None, + point_groups: str | int | Symmetry | list[str | int | Symmetry] | None = None, colors: str | list[str] | None = None, ids: int | list[int] | np.ndarray | None = None, structures: Structure | list[Structure] | None = None, @@ -199,9 +195,7 @@ def __init__( # Get first 2 * n entries in color list (for good measure) all_colors, _ = get_named_matplotlib_colors() - all_color_names = list(islice(all_colors.keys(), 2 * max_entries))[ - ::-1 - ] + all_color_names = list(islice(all_colors.keys(), 2 * max_entries))[::-1] # Create phase dictionary d = {} @@ -368,9 +362,7 @@ def __delitem__(self, key: int | str) -> None: matching_phase_id = phase_id break if matching_phase_id is None: - raise KeyError( - f"{key} is not among the phase names {self.names}." - ) + raise KeyError(f"{key} is not among the phase names {self.names}.") else: self._dict.pop(matching_phase_id) else: @@ -390,8 +382,7 @@ def __repr__(self) -> str: sg_names = ["None" if not i else i.short_name for i in self.space_groups] pg_names = ["None" if not i else i.name for i in self.point_groups] ppg_names = [ - "None" if not i else i.proper_subgroup.name - for i in self.point_groups + "None" if not i else i.proper_subgroup.name for i in self.point_groups ] # Determine column widths (allowing PhaseList to be empty) @@ -409,12 +400,8 @@ def __repr__(self) -> str: representation = ( "{:{align}{width}} ".format("Id", width=id_len, align=align) + "{:{align}{width}} ".format("Name", width=name_len, align=align) - + "{:{align}{width}} ".format( - "Space group", width=sg_len, align=align - ) - + "{:{align}{width}} ".format( - "Point group", width=pg_len, align=align - ) + + "{:{align}{width}} ".format("Space group", width=sg_len, align=align) + + "{:{align}{width}} ".format("Point group", width=pg_len, align=align) + "{:{align}{width}} ".format( "Proper point group", width=ppg_len, align=align ) diff --git a/orix/plot/stereographic_plot.py b/orix/plot/stereographic_plot.py index d37af1775..d1b3d870b 100644 --- a/orix/plot/stereographic_plot.py +++ b/orix/plot/stereographic_plot.py @@ -21,10 +21,9 @@ plotting :class:`~orix.vector.Vector3d`. """ +from collections.abc import Iterable from copy import copy, deepcopy from typing import Any, List, Literal, Optional, Tuple, Union, overload -from collections.abc import Iterable -from copy import deepcopy import matplotlib as mpl from matplotlib import rcParams @@ -445,10 +444,7 @@ def draw_circle( if x.size == 0: return - if ( - isinstance(opening_angle, np.ndarray) - and opening_angle.size == visible.size - ): + if isinstance(opening_angle, np.ndarray) and opening_angle.size == visible.size: opening_angle = opening_angle[visible] # Get set of `steps` vectors delineating a circle per vector @@ -805,13 +801,9 @@ def _azimuth_grid(self, resolution: float | None = None) -> None: label = self._get_label("azimuth_grid") self._remove_collection(label) lines = np.stack(((x_start, x_end), (y_start, y_end))).T - lines_collection = mcollections.LineCollection( - lines, label=label, **kwargs - ) + lines_collection = mcollections.LineCollection(lines, label=label, **kwargs) # Clip to fundamental sector, if set - sector_patch = self._get_collection( - self._get_label("sector"), self.patches - ) + sector_patch = self._get_collection(self._get_label("sector"), self.patches) if sector_patch is not None: lines_collection.set_clip_path(sector_patch) @@ -865,9 +857,7 @@ def _polar_grid(self, resolution: float | None = None) -> None: alpha=kwargs["alpha"], ) self._remove_collection(label) - sector_patch = self._get_collection( - self._get_label("sector"), self.patches - ) + sector_patch = self._get_collection(self._get_label("sector"), self.patches) if sector_patch is not None: circles_collection.set_clip_path(sector_patch) self.add_collection(circles_collection) @@ -914,9 +904,7 @@ def res2latlines(res, rotation_pole): v_lats = Vector3d.xvector().rotate(angle=np.radians(ticks)) lat_deltas = np.linspace(0, np.pi, 100, True) v_lines = [v.rotate(rotation_pole, lat_deltas) for v in v_lats] - xy_lines = [ - np.stack(self._projection.vector2xy(x)).T for x in v_lines - ] + xy_lines = [np.stack(self._projection.vector2xy(x)).T for x in v_lines] return xy_lines rotation_pole = [0, self.pole, 0] @@ -937,9 +925,7 @@ def res2latlines(res, rotation_pole): # Clip the grid to the fundamental sector subsection, if one is # defined - sector_patch = self._get_collection( - self._get_label("sector"), self.patches - ) + sector_patch = self._get_collection(self._get_label("sector"), self.patches) if sector_patch is not None: lc_minor.set_clip_path(sector_patch) if lc_major is not None: @@ -991,24 +977,17 @@ def res2llonglines(res, cap, rotation_pole): left = np.arange(90, 0 - res, -res)[::-1] right = np.arange(90 + res, 180 + res, res) ticks = np.hstack([left, right]) - v_longs = Vector3d.xvector().rotate( - rotation_pole, np.radians(-ticks) - ) + v_longs = Vector3d.xvector().rotate(rotation_pole, np.radians(-ticks)) long_deltas = np.radians(np.linspace(90 - cap, -90 + cap, 100, True)) v_lines = [ - v.rotate(v.rotate([0, 1, 0], np.pi / 2), long_deltas) - for v in v_longs - ] - xy_lines = [ - np.stack(self._projection.vector2xy(x)).T for x in v_lines + v.rotate(v.rotate([0, 1, 0], np.pi / 2), long_deltas) for v in v_longs ] + xy_lines = [np.stack(self._projection.vector2xy(x)).T for x in v_lines] return xy_lines rotation_pole = [0, -self.pole, 0] lc_minor = mcollections.LineCollection( - res2llonglines( - self._long_resolution, self._wulff_net_cap, rotation_pole - ), + res2llonglines(self._long_resolution, self._wulff_net_cap, rotation_pole), label=self._get_label("longitudinal_grid"), **kwargs_minor, ) @@ -1027,9 +1006,7 @@ def res2llonglines(res, cap, rotation_pole): # Clip the grid to the fundamental sector subsection, if one is # defined - sector_patch = self._get_collection( - self._get_label("sector"), self.patches - ) + sector_patch = self._get_collection(self._get_label("sector"), self.patches) if sector_patch is not None: lc_minor.set_clip_path(sector_patch) if lc_major is not None: @@ -1460,9 +1437,7 @@ def __iter__(self) -> [Vector3d, mpath.Path, np.float64]: For example, if a _SymmetryMarker is created with 4 vertices, this allows for iteration over those vertices in a 'for' loop.""" - for v, marker, size in zip( - self._vector, self._marker, self.multiplicity - ): + for v, marker, size in zip(self._vector, self._marker, self.multiplicity): yield v, marker, size @property @@ -1544,9 +1519,7 @@ def _get_kwargs_for_wulff_net_grids( return kwargs_minor, kwargs_major -def _label_in_collection( - label: str, collections: COLLECTION_TYPES -) -> int | None: +def _label_in_collection(label: str, collections: COLLECTION_TYPES) -> int | None: labels = [c.get_label() for c in collections] for i in range(len(labels)): if label == labels[i]: diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index 58a389544..b56d8fdd7 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -262,9 +262,7 @@ def fundamental_sector(self) -> "FundamentalSector": # Taken from MTEX center = Vector3d([0.707558, -0.000403, 0.706655]) elif name in ["m-3", "432"]: - n = Vector3d( - np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data]) - ) + n = Vector3d(np.vstack([vx.data, [0, -1, 1], [-1, 0, 1], vy.data, vz.data])) # Taken from MTEX center = Vector3d([0.349928, 0.348069, 0.869711]) elif name == "-43m": @@ -372,9 +370,7 @@ def __and__(self, other: Symmetry) -> Symmetry: return Symmetry.from_generators(*generators) def __hash__(self) -> int: - return hash( - self.name.encode() + self.data.tobytes() + self.improper.tobytes() - ) + return hash(self.name.encode() + self.data.tobytes() + self.improper.tobytes()) # ------------------------ Class methods ------------------------- # @@ -484,9 +480,7 @@ def fundamental_zone(self) -> Vector3d: return sr @deprecated_argument(name="orientation", since="1.5", removal="1.6") - @deprecated_argument( - name="reproject_scatter_kwargs", since="1.5", removal="1.6" - ) + @deprecated_argument(name="reproject_scatter_kwargs", since="1.5", removal="1.6") def plot( self, asymmetric_vector: Vector3d | None = None, @@ -628,9 +622,7 @@ def plot( if t != "inversion": continue for sv in (self * v).unique(): - ax.symmetry_marker( - sv, folds=f, s=marker_size, color=c, modifier=t - ) + ax.symmetry_marker(sv, folds=f, s=marker_size, color=c, modifier=t) # plot asymmetric markers if requested. if asymmetric_vector is not None: diff --git a/orix/sampling/S2_sampling.py b/orix/sampling/S2_sampling.py index a0c24518a..4aa935215 100644 --- a/orix/sampling/S2_sampling.py +++ b/orix/sampling/S2_sampling.py @@ -573,8 +573,7 @@ def sample_S2_icosahedral_mesh(resolution: float) -> Vector3d: ) _sampling_S2_method_names.add(f":func:`orix.sampling.{_func.__name__}`") -_s2_sampling_docstring = ( - """Return unit vectors that sample S2 with a specific angular +_s2_sampling_docstring = ("""Return unit vectors that sample S2 with a specific angular resolution. Parameters @@ -596,8 +595,7 @@ def sample_S2_icosahedral_mesh(resolution: float) -> Vector3d: See Also -------- {} - """ -).format( + """).format( ", ".join(map(lambda x: f'``"{x}"``', sample_S2_methods)), "\n ".join(_sampling_S2_method_names), ) diff --git a/orix/tests/test_crystal_map/test_phase.py b/orix/tests/test_crystal_map/test_phase.py index 45a51f4db..ae25420c0 100644 --- a/orix/tests/test_crystal_map/test_phase.py +++ b/orix/tests/test_crystal_map/test_phase.py @@ -190,9 +190,7 @@ def test_set_structure_phase_name(self): def test_set_structure_raises(self): p = Phase() - with pytest.raises( - ValueError, match=".* must be a diffpy.structure.Structure" - ): + with pytest.raises(ValueError, match=".* must be a diffpy.structure.Structure"): p.structure = [1, 2, 3, 90, 90, 90] @pytest.mark.parametrize( @@ -249,9 +247,7 @@ def test_shallow_copy_phase(self): assert p.__repr__() == p2.__repr__() def test_phase_init_non_matching_space_group_point_group(self): - with pytest.warns( - UserWarning, match="Setting space group to 'None', as" - ): + with pytest.warns(UserWarning, match="Setting space group to 'None', as"): _ = Phase(space_group=225, point_group="432") @pytest.mark.parametrize( @@ -273,9 +269,7 @@ def test_point_group_derived_from_space_group( def test_set_space_group_raises(self): space_group = "outer-space" - with pytest.raises( - ValueError, match=f"'{space_group}' must be of type " - ): + with pytest.raises(ValueError, match=f"'{space_group}' must be of type "): p = Phase() p.space_group = space_group @@ -294,9 +288,7 @@ def test_is_hexagonal(self): def test_structure_matrix(self): """Structure matrix is updated assuming e1 || a, e3 || c*.""" trigonal_lattice = Lattice(1.7, 1.7, 1.4, 90, 90, 120) - phase = Phase( - point_group="321", structure=Structure(lattice=trigonal_lattice) - ) + phase = Phase(point_group="321", structure=Structure(lattice=trigonal_lattice)) lattice = phase.structure.lattice # Lattice parameters are unchanged @@ -326,9 +318,7 @@ def test_structure_matrix(self): # Getting new structure matrix without passing enough parameters # raises an error - with pytest.raises( - ValueError, match="At least two of x, y, z must be set." - ): + with pytest.raises(ValueError, match="At least two of x, y, z must be set."): _ = new_structure_matrix_from_alignment(lattice.base, x="a") def test_triclinic_structure_matrix(self): @@ -368,9 +358,7 @@ def test_triclinic_structure_matrix(self): def test_lattice_vectors(self): """Correct direct and reciprocal lattice vectors.""" trigonal_lattice = Lattice(1.7, 1.7, 1.4, 90, 90, 120) - phase = Phase( - point_group="321", structure=Structure(lattice=trigonal_lattice) - ) + phase = Phase(point_group="321", structure=Structure(lattice=trigonal_lattice)) a, b, c = phase.a_axis, phase.b_axis, phase.c_axis ar, br, cr = phase.ar_axis, phase.br_axis, phase.cr_axis @@ -424,17 +412,13 @@ def test_atom_positions(self, lat, atoms): # however, Phase should (in many cases) change the basis. if np.allclose(structure.lattice.base, phase.structure.lattice.base): # In this branch we are in the same basis & all atoms should be the same - for atom_from_structure, atom_from_phase in zip( - structure, phase.structure - ): + for atom_from_structure, atom_from_phase in zip(structure, phase.structure): assert np.allclose(atom_from_structure.xyz, atom_from_phase.xyz) else: # Here we have differing basis, so xyz must disagree for at least some atoms disagreement_found = False - for atom_from_structure, atom_from_phase in zip( - structure, phase.structure - ): + for atom_from_structure, atom_from_phase in zip(structure, phase.structure): if not np.allclose(atom_from_structure.xyz, atom_from_phase.xyz): disagreement_found = True break @@ -459,9 +443,7 @@ def test_from_cif_same_structure(self, cif_file): phase1 = Phase.from_cif(cif_file) structure = loadStructure(cif_file) phase2 = Phase(structure=structure) - assert np.allclose( - phase1.structure.lattice.base, phase2.structure.lattice.base - ) + assert np.allclose(phase1.structure.lattice.base, phase2.structure.lattice.base) assert np.allclose(phase1.structure.xyz, phase2.structure.xyz) @pytest.mark.parametrize( @@ -1018,7 +1000,5 @@ def test_default_lattice(self): assert np.allclose([1, 1, 1, 90, 90, 120], lattice_parameters) def test_default_lattice_raises(self): - with pytest.raises( - ValueError, match="Unknown crystal system 'rhombohedral'" - ): + with pytest.raises(ValueError, match="Unknown crystal system 'rhombohedral'"): default_lattice("rhombohedral") diff --git a/orix/tests/test_io/test_ang.py b/orix/tests/test_io/test_ang.py index a957343b3..892e0d0cd 100644 --- a/orix/tests/test_io/test_ang.py +++ b/orix/tests/test_io/test_ang.py @@ -302,9 +302,7 @@ def test_load_ang_astar( np.ones(int(np.floor((3 * 6) / 2))) * 2, ) ), - np.array( - [[1.62176, 2.36894, 1.72386], [1.60448, 2.36754, 1.72386]] - ), + np.array([[1.62176, 2.36894, 1.72386], [1.60448, 2.36754, 1.72386]]), ), ], indirect=["angfile_emsoft"], @@ -423,9 +421,7 @@ def test_get_vendor_columns_unknown(self, temp_ang_file, n_cols_file): temp_ang_file.close() with open(temp_ang_file.name) as f: header = _get_header(f) - with pytest.warns( - UserWarning, match=f"Number of columns, {n_cols_file}, " - ): + with pytest.warns(UserWarning, match=f"Number of columns, {n_cols_file}, "): vendor, column_names = _get_vendor_columns(header, n_cols_file) assert vendor == "unknown" expected_columns = [ @@ -504,9 +500,7 @@ def test_get_phases_from_header( assert formulas == expected_formula assert phases["names"] == expected_names assert phases["point_groups"] == expected_point_groups - assert np.allclose( - phases["lattice_constants"], expected_lattice_constants - ) + assert np.allclose(phases["lattice_constants"], expected_lattice_constants) assert np.allclose(phases["ids"], expected_phase_id) def test_phasename_autogen(self): @@ -694,9 +688,7 @@ def test_1d_map(self, crystal_map_input, tmp_path): xmap_reload = load(fname) assert xmap_reload.ndim == 1 - assert np.allclose( - xmap.rotations.to_euler(), xmap_reload.rotations.to_euler() - ) + assert np.allclose(xmap.rotations.to_euler(), xmap_reload.rotations.to_euler()) @pytest.mark.parametrize( "crystal_map_input, index", @@ -709,9 +701,7 @@ def test_1d_map(self, crystal_map_input, tmp_path): ) def test_write_data_index(self, crystal_map_input, tmp_path, index): xmap = CrystalMap(**crystal_map_input) - ci = np.arange(xmap.size * xmap.rotations_per_point).reshape( - xmap.size, -1 - ) + ci = np.arange(xmap.size * xmap.rotations_per_point).reshape(xmap.size, -1) xmap.prop["ci"] = ci xmap.prop["iq"] = np.arange(xmap.size) extra_prop = "iq_times_ci" @@ -742,9 +732,9 @@ def test_write_data_index_none(self, crystal_map_input, tmp_path): property arrays. """ xmap = CrystalMap(**crystal_map_input) - xmap.prop["ci"] = np.arange( - xmap.size * xmap.rotations_per_point - ).reshape(xmap.size, xmap.rotations_per_point) + xmap.prop["ci"] = np.arange(xmap.size * xmap.rotations_per_point).reshape( + xmap.size, xmap.rotations_per_point + ) fname = tmp_path / "test_write_data_index_none.ang" save(fname, xmap, extra_prop=["ci"]) diff --git a/orix/tests/test_plot/test_stereographic_plot.py b/orix/tests/test_plot/test_stereographic_plot.py index 090b05fe7..8cd9ee48a 100644 --- a/orix/tests/test_plot/test_stereographic_plot.py +++ b/orix/tests/test_plot/test_stereographic_plot.py @@ -117,17 +117,13 @@ def test_scatter_coloring(self): assert np.allclose(c3, mcolors.to_rgba(c_color)) # Single color with single alpha - fig4 = v.scatter( - c=c_color, alpha=0.5, return_figure=True, label="scatter" - ) + fig4 = v.scatter(c=c_color, alpha=0.5, return_figure=True, label="scatter") fig4.canvas.draw() c4 = fig4.axes[0]._get_collection("scatter").get_facecolor() assert np.allclose(c4, mcolors.to_rgba(c_color, 0.5)) # Single color with varying alpha - fig5 = v.scatter( - c=c_color, alpha=c_scalar, return_figure=True, label="scatter" - ) + fig5 = v.scatter(c=c_color, alpha=c_scalar, return_figure=True, label="scatter") fig5.canvas.draw() c5 = fig5.axes[0]._get_collection("scatter").get_facecolor() rgba5 = np.full((v.size, 4), mcolors.to_rgba(c_color)) @@ -369,12 +365,8 @@ def test_order_in_hemisphere(self): _, ax = plt.subplots(ncols=2, subplot_kw=dict(projection=PROJ_NAME)) ax[1].hemisphere = "lower" - x_upper, y_upper, visible_upper = ax[0]._pretransform_input( - (v,), sort=True - ) - x_lower, y_lower, visible_lower = ax[1]._pretransform_input( - (v,), sort=True - ) + x_upper, y_upper, visible_upper = ax[0]._pretransform_input((v,), sort=True) + x_lower, y_lower, visible_lower = ax[1]._pretransform_input((v,), sort=True) x_upper_desired, y_upper_desired = ax[0]._projection.vector2xy(v[:2]) assert np.allclose(x_upper, x_upper_desired) @@ -403,14 +395,10 @@ def test_color_parameter(self): colors_rgba = np.array([mcolors.to_rgba(c) for c in colors]) fig = v.scatter(color=colors, return_figure=True) - assert np.allclose( - fig.axes[0].collections[0].get_facecolors(), colors_rgba - ) + assert np.allclose(fig.axes[0].collections[0].get_facecolors(), colors_rgba) fig2 = v.scatter(c=colors, return_figure=True) - assert np.allclose( - fig2.axes[0].collections[0].get_facecolors(), colors_rgba - ) + assert np.allclose(fig2.axes[0].collections[0].get_facecolors(), colors_rgba) def test_size_parameter(self): """Pass either ``sizes`` or ``s`` to set scatter points sizes.""" @@ -425,13 +413,9 @@ def test_size_parameter(self): class TestSymmetryMarker: - @pytest.mark.parametrize( - "v_data", [[0, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]] - ) + @pytest.mark.parametrize("v_data", [[0, 0, 1], [1, 0, 0], [1, 1, 0], [1, 1, 1]]) @pytest.mark.parametrize("folds", [1, 2, 3, 4, 6]) - @pytest.mark.parametrize( - "modifier", [None, "none", "rotoinversion", "inversion"] - ) + @pytest.mark.parametrize("modifier", [None, "none", "rotoinversion", "inversion"]) def test_main_properties(self, v_data, folds, modifier): v = ove.Vector3d(v_data) marker = opl.stereographic_plot._SymmetryMarker( @@ -455,16 +439,12 @@ def test_plot_symmetry_marker(self): v = ove.Vector3d([[1, 0, 0], [0, 1, 1]]) for i in [1, 2, 3, 4, 6]: ax.symmetry_marker(v[0], folds=i, s=marker_size, color="k") - ax.symmetry_marker( - v[1], folds=i, modifier="inversion", s=marker_size - ) + ax.symmetry_marker(v[1], folds=i, modifier="inversion", s=marker_size) ax.symmetry_marker( v, folds=1, modifier="inversion", color="C1", s=marker_size ) for i in [4, 6]: - ax.symmetry_marker( - v, folds=i, modifier="rotoinversion", s=marker_size - ) + ax.symmetry_marker(v, folds=i, modifier="rotoinversion", s=marker_size) markers = ax.collections assert len(markers) == 43 @@ -551,9 +531,7 @@ def test_draw_circle_opening_angle_array(self): def test_pdf_args(self): v = ove.Vector3d.random(10) resolution = 5 - fig, ax = plt.subplots( - ncols=2, subplot_kw=dict(projection="stereographic") - ) + fig, ax = plt.subplots(ncols=2, subplot_kw=dict(projection="stereographic")) # vector arg ax[0].pole_density_function(v, resolution=resolution) qm0 = [isinstance(c, QuadMesh) for c in ax[0].collections] @@ -661,9 +639,7 @@ def test_restrict_to_sector_edges(self): fig = v.scatter(return_figure=True) ax = fig.axes[0] - ax.restrict_to_sector( - oqu.symmetry.Oh.fundamental_sector, show_edges=False - ) + ax.restrict_to_sector(oqu.symmetry.Oh.fundamental_sector, show_edges=False) assert len(ax.patches) == 1 assert ax.patches[0].get_label() == "_stereographic_border" diff --git a/orix/tests/test_quaternion/test_orientation.py b/orix/tests/test_quaternion/test_orientation.py index b9668d125..dbc3591ea 100644 --- a/orix/tests/test_quaternion/test_orientation.py +++ b/orix/tests/test_quaternion/test_orientation.py @@ -42,6 +42,7 @@ Oh, PointGroups, ) + # isort: on from orix.vector import Miller, Vector3d