diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5abda71b6..03e93c1dd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -110,7 +110,7 @@ jobs: - name: Run tests run: | - pytest --pyargs orix --runslow --reruns 2 -n 2 --cov=orix + pytest --pyargs orix --slow --reruns 2 -n 2 --cov=orix - name: Generate line coverage if: ${{ matrix.os == 'ubuntu-latest' }} diff --git a/.zenodo.json b/.zenodo.json index 02d910a00..73e0a2119 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -26,9 +26,8 @@ "orcid": "0000-0002-6402-9879" }, { - "name": "Austin Gerlt", - "orcid": "0000-0002-2204-2055", - "affiliation": "The Ohio State University" + "name": "Viljar Johan Femoen", + "affiliation": "Norwegian University of Science and Technology" }, { "name": "Anders Christian Mathisen", @@ -45,16 +44,25 @@ "affiliation": "University of Wisconsin Madison" }, { - "name": "Simon Høgås" + "name": "Austin Gerlt", + "orcid": "0000-0002-2204-2055", + "affiliation": "The Ohio State University" }, { - "name": "Viljar Johan Femoen", - "affiliation": "Norwegian University of Science and Technology" + "name": "Simon Høgås" }, { "name": "Alessandra da Silva", "orcid": "0000-0003-0465-504X" }, + { + "name": "Ondrej Lexa", + "orcid": "0000-0003-4616-9154", + "affiliation": "Charles University, Prague" + }, + { + "name": "Eric Prestat" + }, { "name": "Alexander Clausen", "orcid": "0000-0002-9555-7455", diff --git a/doc/conf.py b/doc/conf.py index 2b81c2d3e..9dafd02cc 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,3 +1,22 @@ +# +# Copyright 2019-2025 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 . +# + # Configuration file for the Sphinx documentation app. # See the documentation for a full list of configuration options: # https://www.sphinx-doc.org/en/master/usage/configuration.html @@ -64,9 +83,10 @@ "numpydoc": ("https://numpydoc.readthedocs.io/en/latest", None), "pooch": ("https://www.fatiando.org/pooch/latest", None), "pytest": ("https://docs.pytest.org/en/stable", None), + "pytest-xdist": ("https://pytest-xdist.readthedocs.io/en/stable/", None), "python": ("https://docs.python.org/3", None), "pyxem": ("https://pyxem.readthedocs.io/en/latest", None), - "readthedocs": ("https://docs.readthedocs.io/en/stable", None), + "readthedocs": ("https://docs.readthedocs.com/platform/stable/", None), "scipy": ("https://docs.scipy.org/doc/scipy", None), "sklearn": ("https://scikit-learn.org/stable", None), "sphinx": ("https://www.sphinx-doc.org/en/master", None), diff --git a/doc/dev/running_writing_tests.rst b/doc/dev/running_writing_tests.rst index ab205cf4e..da832cb86 100644 --- a/doc/dev/running_writing_tests.rst +++ b/doc/dev/running_writing_tests.rst @@ -3,11 +3,12 @@ Run and write tests All functionality in orix is tested with :doc:`pytest `. The tests reside in a ``tests`` module. -Tests are short methods that call functions in ``orix`` and compare resulting output -values with known answers. +Tests are short methods that call functions in orix and compare resulting output values +with known answers. + Install necessary dependencies to run the tests:: - pip install --editable ".[tests]" + pip install -e ".[tests]" Some useful :doc:`fixtures ` are available in the ``conftest.py`` file. @@ -22,29 +23,28 @@ Some useful :doc:`fixtures ` are available in the To run the tests:: - pytest --cov --pyargs orix -n auto + pytest --cov --pyargs orix -n auto -The ``-n auto`` is an optional flag to enable parallelized testing. -The ``--cov`` flag makes :doc:`coverage.py ` print a nice report. -For an even nicer presentation, you can use ``coverage.py`` directly:: +The ``-n auto`` is an optional flag to enable parallelized testing with +:doc:`pytest-xdist `. +We aim to cover all lines when all :ref:`dependencies` are installed. +The ``--cov`` flag makes :doc:`coverage.py ` print a nice coverage +report. +For an even nicer presentation, you can use coverage.py directly:: - coverage html + coverage html Coverage can then be inspected in the browser by opening ``htmlcov/index.html``. -We strive for 100% test coverage of lines when all dependencies are installed. - -If you have a test that takes a long time to run, you can mark it to skip it from running by default - -.. code-block:: Python +If a test takes a long time to run, you can mark it to skip it from running by default:: @pytest.mark.slow def test_slow_function(): - pass + ... -Then you can run the tests with the ``--runslow`` option to skip slow tests:: +To run tests marked as slow, add the flag when running pytest:: - pytest --runslow + pytest --slow Docstring examples are tested with :doc:`pytest ` as well. :mod:`numpy` and :mod:`matplotlib.pyplot` should not be imported in examples as they are diff --git a/examples/stereographic_projection/plot_symmetry_operations.py b/examples/stereographic_projection/plot_symmetry_operations.py index f50c03150..3564f1e23 100644 --- a/examples/stereographic_projection/plot_symmetry_operations.py +++ b/examples/stereographic_projection/plot_symmetry_operations.py @@ -3,51 +3,53 @@ Plot symmetry operations ======================== -This example shows how to draw proper symmetry operations :math:`s` -(no reflections or inversions). +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 -from orix import plot +import orix.plot +from orix.quaternion.symmetry import PointGroups from orix.vector import Vector3d -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 = PointGroups.get_set("procedural") + +# show the table of symmetry information +print(point_groups) -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], - ] +# prepare the plots +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() + +# 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(asymmetric_vector=v, ax=ax[i]) diff --git a/orix/__init__.py b/orix/__init__.py index 47a6f19a6..87273fd4b 100644 --- a/orix/__init__.py +++ b/orix/__init__.py @@ -25,14 +25,16 @@ "Ben Martineau", "Paddy Harrison", "Phillip Crout", - "Austin Gerlt", "Duncan Johnstone", "Niels Cautaerts", + "Viljar Johan Femoen", "Anders Christian Mathisen", "Zhou Xu", "Carter Francis", + "Austin Gerlt", "Simon Høgås", - "Viljar Johan Femoen", "Alessandra da Silva", + "Ondrej Lexa", + "Eric Prestat", "Alexander Clausen", ] diff --git a/orix/crystal_map/phase_list.py b/orix/crystal_map/phase_list.py index ff99ee899..1519946f8 100644 --- a/orix/crystal_map/phase_list.py +++ b/orix/crystal_map/phase_list.py @@ -1,4 +1,5 @@ -# Copyright 2018-2024 the orix developers +# +# Copyright 2019-2025 the orix developers # # This file is part of orix. # @@ -9,11 +10,12 @@ # # 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 +# 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 . +# along with orix. If not, see . +# from __future__ import annotations @@ -33,9 +35,9 @@ from orix.quaternion.symmetry import ( _EDAX_POINT_GROUP_ALIASES, + PointGroups, Symmetry, - _groups, - get_point_group, + _point_groups_dictionary, ) from orix.vector import Miller, Vector3d @@ -232,13 +234,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 = _point_groups_dictionary["permutations_repeated"] if isinstance(value, int): value = str(value) if isinstance(value, str): @@ -246,7 +249,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 @@ -366,18 +369,26 @@ def deepcopy(self) -> Phase: return copy.deepcopy(self) def expand_asymmetric_unit(self) -> Phase: - """Return new instance with all symmetrically equivalent atoms. + """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 = Phase(structure=structure, space_group=227) + >>> phase = ocm.Phase(structure=structure, space_group=227) >>> phase.structure [Si 0.000000 0.000000 1.000000 1.0000] - >>> expanded = phase.expand_asymmetric_unit() - >>> expanded.structure + >>> 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, @@ -411,9 +422,10 @@ def expand_asymmetric_unit(self) -> Phase: diffpy_structure.append(new_atom) # This handles conversion back to correct alignment - out = Phase(self) - out.structure = diffpy_structure - return out + expanded_phase = self.__class__(self) + expanded_phase.structure = diffpy_structure + + return expanded_phase class PhaseList: diff --git a/orix/io/plugins/ctf.py b/orix/io/plugins/ctf.py index dfdc79943..08249b1b7 100644 --- a/orix/io/plugins/ctf.py +++ b/orix/io/plugins/ctf.py @@ -1,4 +1,5 @@ -# Copyright 2018-2024 the orix developers +# +# Copyright 2019-2025 the orix developers # # This file is part of orix. # @@ -9,11 +10,12 @@ # # 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 +# 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 . +# along with orix. If not, see . +# """Reader of a crystal map from a file in the Channel Text File (CTF) format. @@ -21,14 +23,14 @@ from io import TextIOWrapper import re -from typing import Dict, List, Tuple +from typing import Any, Literal from diffpy.structure import Lattice, Structure import numpy as np -from orix.crystal_map import CrystalMap, PhaseList -from orix.crystal_map.crystal_map import _data_slices_from_coordinates -from orix.quaternion import Rotation +from orix.crystal_map.crystal_map import CrystalMap, _data_slices_from_coordinates +from orix.crystal_map.phase_list import PhaseList +from orix.quaternion.rotation import Rotation __all__ = ["file_reader"] @@ -45,8 +47,8 @@ def file_reader(filename: str) -> CrystalMap: The map in the input is assumed to be 2D. - Many vendors/programs produce a .ctf files. Files from the following - vendors/programs are tested: + Many vendors/programs can write a .ctf file. Files from the + following vendors/programs are tested: * Oxford Instruments AZtec * Bruker Esprit @@ -74,6 +76,15 @@ def file_reader(filename: str) -> CrystalMap: deviation (MAD), band contrast (BC), and band slope (BS) renamed to DP (dot product), OSM (orientation similarity metric), and IQ (image quality), respectively. + + Description of error codes provided in CTF file: + - 0: Success + - 1: Low BC + - 2: Low BS + - 3: No solution + - 4: High MAD + - 5: Not yet analyzed (job cancelled before point) + - 6: Unexpected error (exceptions etc.) """ with open(filename, "r") as f: header, data_starting_row, vendor = _get_header(f) @@ -143,9 +154,11 @@ def file_reader(filename: str) -> CrystalMap: return CrystalMap(**data_dict) -def _get_header(file: TextIOWrapper) -> Tuple[List[str], int, List[str]]: - """Return file header, row number of start of data in file, and the - detected vendor(s). +def _get_header( + file: TextIOWrapper, +) -> tuple[list[str], int, Literal["oxford_or_bruker", "emsoft", "astar", "mtex"]]: + """Return file header, row number for the first line with data, and + the detected vendor. Parameters ---------- @@ -155,17 +168,16 @@ def _get_header(file: TextIOWrapper) -> Tuple[List[str], int, List[str]]: Returns ------- header - List with header lines as individual elements. + List with header lines. data_starting_row - The starting row number for the data lines + Row number for the first line with data. vendor - Vendor detected based on some header pattern. Default is to - assume Oxford/Bruker, ``"oxford_or_bruker"`` (assuming no - difference between the two vendor's CTF formats). Other options - are ``"emsoft"``, ``"astar"``, and ``"mtex"``. + Detected vendor. Default is to assume Oxford or Bruker, + "oxford_or_bruker" (assuming identical CTF formatting). Other + options are "emsoft", "astar", and "mtex". """ vendor = [] - vendor_pattern = { + vendor_patterns = { "emsoft": re.compile( ( r"EMsoft v\. ([A-Za-z0-9]+(_[A-Za-z0-9]+)+); BANDS=pattern index, " @@ -176,36 +188,40 @@ def _get_header(file: TextIOWrapper) -> Tuple[List[str], int, List[str]]: "mtex": re.compile("(?<=)Created from mtex"), } + # Keep header lines and any matching vendor patterns (potentially + # more than one) header = [] line = file.readline() - i = 0 - # Prevent endless loop by not reading past 1 000 lines - while not line.startswith("Phase\tX\tY") and i < 1_000: - for k, v in vendor_pattern.items(): - if v.search(line): + data_starting_row = 0 + max_header_lines = 1_000 + while data_starting_row < max_header_lines and not line.startswith("Phase\tX\tY"): + for k, v in vendor_patterns.items(): + match = v.search(line) + if match is not None: vendor.append(k) header.append(line.rstrip()) - i += 1 + data_starting_row += 1 line = file.readline() - vendor = vendor[0] if len(vendor) == 1 else "oxford_or_bruker" + if len(vendor) == 1: + vendor = vendor[0] + else: + vendor = "oxford_or_bruker" - return header, i + 1, vendor + return header, data_starting_row + 1, vendor -def _get_phases_from_header(header: List[str]) -> dict: - """Return phase names and symmetries detected in a .ctf file header. +def _get_phases_from_header(header: list[str]) -> dict[str, list]: + """Return phase names and symmetries detected in a CTF file header. Parameters ---------- header - List with header lines as individual elements. - vendor - Vendor of the file. + List with header lines. Returns ------- - phase_dict + phases_dict Dictionary with the following keys (and types): "ids" (int), "names" (str), "space_groups" (int), "point_groups" (str), "lattice_constants" (list of floats). @@ -215,53 +231,52 @@ def _get_phases_from_header(header: List[str]) -> dict: This function has been tested with files from the following vendor's formats: Oxford AZtec HKL v5/v6 and EMsoft v4/v5. """ - phases = { + phases_dict = { "ids": [], "names": [], - "point_groups": [], "space_groups": [], + "point_groups": [], "lattice_constants": [], } - for i, line in enumerate(header): + for line_number, line in enumerate(header): if line.startswith("Phases"): break n_phases = int(line.split("\t")[1]) - # Laue classes - laue_ids = [ - "-1", - "2/m", - "mmm", - "4/m", - "4/mmm", - "-3", - "-3m", - "6/m", - "6/mmm", - "m3", - "m-3m", - ] + laue_ids = { + 1: "-1", + 2: "2/m", + 3: "mmm", + 4: "4/m", + 5: "4/mmm", + 6: "-3", + 7: "-3m", + 8: "6/m", + 9: "6/mmm", + 10: "m3", + 11: "m-3m", + } - for j in range(n_phases): - phase_data = header[i + 1 + j].split("\t") - phases["ids"].append(j + 1) + for i in range(n_phases): + phase_data = header[line_number + 1 + i].split("\t") + phases_dict["ids"].append(i + 1) abcABG = ";".join(phase_data[:2]) abcABG = abcABG.split(";") - abcABG = [float(i.replace(",", ".")) for i in abcABG] - phases["lattice_constants"].append(abcABG) - phases["names"].append(phase_data[2]) + abcABG = [float(lat.replace(",", ".")) for lat in abcABG] + phases_dict["lattice_constants"].append(abcABG) + phases_dict["names"].append(phase_data[2]) laue_id = int(phase_data[3]) - phases["point_groups"].append(laue_ids[laue_id - 1]) + phases_dict["point_groups"].append(laue_ids[laue_id]) sg = int(phase_data[4]) if sg == 0: sg = None - phases["space_groups"].append(sg) + phases_dict["space_groups"].append(sg) - return phases + return phases_dict -def _fix_astar_coords(header: List[str], data_dict: dict) -> dict: +def _fix_astar_coords(header: list[str], data_dict: dict[str, Any]) -> dict[str, Any]: """Return the data dictionary with coordinate arrays possibly fixed for ASTAR Index files. @@ -301,7 +316,7 @@ def _fix_astar_coords(header: List[str], data_dict: dict) -> dict: return data_dict -def _get_xy_step(header: List[str]) -> Dict[str, float]: +def _get_xy_step(header: list[str]) -> dict[str, float]: pattern_step = re.compile(r"(?<=[XY]Step[\t\s])(.*)") steps = {"x": None, "y": None} for line in header: @@ -315,7 +330,7 @@ def _get_xy_step(header: List[str]) -> Dict[str, float]: return steps -def _get_xy_cells(header: List[str]) -> Dict[str, int]: +def _get_xy_cells(header: list[str]) -> dict[str, int]: pattern_cells = re.compile(r"(?<=[XY]Cells[\t\s])(.*)") cells = {"x": None, "y": None} for line in header: 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..962834cd5 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 @@ -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,38 @@ 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, folds: int, modifier="none", **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.") - - 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=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 folds == 1: + marker_size = marker_size / 2 + # 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") + 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 @@ -968,3 +975,145 @@ 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: + """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 + Vector(s) giving marker positions in the 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 (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__( + self, + v: Vector3d | np.ndarray | list | tuple, + size: int = 1, + folds: Literal[1, 2, 3, 4, 6] = 2, + modifier: Literal[None, "rotation", "rotoinversion", "inversion"] = None, + ): + 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, "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}" + ) + self._vector = Vector3d(v) + self._size = size + self._folds = folds + self._inner_shape = modifier + + @property + def angle_deg(self) -> np.ndarray: + """Position in degrees.""" + return np.rad2deg(self._vector.azimuth) + 90 + + @property + 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 + def n(self) -> int: + """Number of symmetry markers.""" + return self._vector.size + + def __iter__(self) -> [Vector3d, mpath.Path, np.float64]: + """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) -> 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) + 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 an inversion center + circle = mpath.Path.circle((0, 0), 0.75) + vert = np.copy(circle.vertices) + code = circle.codes + if self._inner_shape == "inversion": + 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 == "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 == "rotoinversion": + # 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/quaternion.py b/orix/quaternion/quaternion.py index 9e0b260dd..b0b67730c 100644 --- a/orix/quaternion/quaternion.py +++ b/orix/quaternion/quaternion.py @@ -1,4 +1,5 @@ -# Copyright 2018-2024 the orix developers +# +# Copyright 2019-2025 the orix developers # # This file is part of orix. # @@ -9,15 +10,16 @@ # # 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 +# 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 . +# along with orix. If not, see . +# from __future__ import annotations -from typing import Any, Optional, Tuple, Union +from typing import Any import warnings import dask.array as da @@ -29,7 +31,9 @@ from orix._base import Object3d from orix.constants import installed from orix.quaternion import _conversions -from orix.vector import AxAngle, Homochoric, Miller, Rodrigues, Vector3d +from orix.vector.miller import Miller +from orix.vector.neo_euler import AxAngle, Homochoric, Rodrigues +from orix.vector.vector3d import Vector3d class Quaternion(Object3d): @@ -193,9 +197,7 @@ def conj(self) -> Quaternion: def __invert__(self) -> Quaternion: return self.__class__(self.conj.data / (self.norm**2)[..., np.newaxis]) - def __mul__( - self, other: Union[Quaternion, Vector3d] - ) -> Union[Quaternion, Vector3d]: + def __mul__(self, other: Quaternion | Vector3d) -> Quaternion | Vector3d: if isinstance(other, Quaternion): if installed["numpy-quaternion"]: import quaternion @@ -231,7 +233,7 @@ def __mul__( def __neg__(self) -> Quaternion: return self.__class__(-self.data) - def __eq__(self, other: Union[Any, Quaternion]) -> bool: + def __eq__(self, other: Any | Quaternion) -> bool: """Check if quaternions have equal shapes and components.""" if ( isinstance(other, Quaternion) @@ -247,8 +249,8 @@ def __eq__(self, other: Union[Any, Quaternion]) -> bool: @classmethod def from_axes_angles( cls, - axes: Union[np.ndarray, Vector3d, tuple, list], - angles: Union[np.ndarray, tuple, list, float], + axes: np.ndarray | Vector3d | tuple | list, + angles: np.ndarray | tuple | list | float, degrees: bool = False, ) -> Quaternion: r"""Create unit quaternions from axis-angle pairs @@ -299,8 +301,7 @@ def from_axes_angles( @classmethod def from_homochoric( - cls, - ho: Union[Vector3d, Homochoric, np.ndarray, tuple, list], + cls, ho: Vector3d | Homochoric | np.ndarray | tuple | list ) -> Quaternion: r"""Create unit quaternions from homochoric vectors :math:`\mathbf{h}` :cite:`rowenhorst2015consistent`. @@ -346,8 +347,8 @@ def from_homochoric( @classmethod def from_rodrigues( cls, - ro: Union[np.ndarray, Vector3d, tuple, list], - angles: Union[np.ndarray, tuple, list, float, None] = None, + ro: np.ndarray | Vector3d | tuple | list, + angles: np.ndarray | tuple | list | float | None = None, ) -> Quaternion: r"""Create unit quaternions from three-component Rodrigues vectors :math:`\hat{\mathbf{n}}` or four-component @@ -448,7 +449,7 @@ def from_rodrigues( @classmethod def from_euler( cls, - euler: Union[np.ndarray, tuple, list], + euler: np.ndarray | tuple | list, direction: str = "lab2crystal", degrees: bool = False, ) -> Quaternion: @@ -505,7 +506,7 @@ def from_euler( return Q @classmethod - def from_matrix(cls, matrix: Union[np.ndarray, tuple, list]) -> Quaternion: + def from_matrix(cls, matrix: np.ndarray | tuple | list) -> Quaternion: """Create unit quaternions from orientation matrices :cite:`rowenhorst2015consistent`. @@ -552,9 +553,13 @@ def from_scipy_rotation(cls, rotation: SciPyRotation) -> Quaternion: Returns ------- - quaternion + Q Quaternions. + See Also + -------- + to_scipy_rotation + Notes ----- The SciPy rotation is inverted to be consistent with the orix @@ -600,17 +605,17 @@ def from_scipy_rotation(cls, rotation: SciPyRotation) -> Quaternion: @classmethod def from_align_vectors( cls, - other: Union[Vector3d, tuple, list], - initial: Union[Vector3d, tuple, list], - weights: Optional[np.ndarray] = None, + other: Vector3d | tuple | list, + initial: Vector3d | tuple | list, + weights: np.ndarray | None = None, return_rmsd: bool = False, return_sensitivity: bool = False, - ) -> Union[ - Quaternion, - Tuple[Quaternion, float], - Tuple[Quaternion, np.ndarray], - Tuple[Quaternion, float, np.ndarray], - ]: + ) -> ( + Quaternion + | tuple[Quaternion, float] + | tuple[Quaternion, np.ndarray] + | tuple[Quaternion, float, np.ndarray] + ): """Estimate a quaternion to optimally align two sets of vectors. This method wraps @@ -739,7 +744,7 @@ def triple_cross(cls, q1: Quaternion, q2: Quaternion, q3: Quaternion) -> Quatern return Q @classmethod - def identity(cls, shape: Union[int, tuple] = (1,)) -> Quaternion: + def identity(cls, shape: int | tuple = (1,)) -> Quaternion: """Create identity quaternions. Parameters @@ -836,7 +841,7 @@ def to_axes_angles(self) -> AxAngle: ax = AxAngle(axes * angles) return ax - def to_rodrigues(self, frank: bool = False) -> Union[Rodrigues, np.ndarray]: + def to_rodrigues(self, frank: bool = False) -> Rodrigues | np.ndarray: r"""Return the unit quaternions as Rodrigues or Rodrigues-Frank vectors :cite:`rowenhorst2015consistent`. @@ -946,45 +951,44 @@ def to_homochoric(self) -> Homochoric: return ho def to_scipy_rotation(self) -> SciPyRotation: - r"""Return the unit quaternions as - :class:`scipy.spatial.transform.Rotation` objects used in scipy's - spatial module. + r"""Return unit quaternions as a SciPy rotation. Returns ------- - SciPy_Rotation - a Rotation object generated from the unit quaternion data - (i.e, unaffected by symmetry, phase, or length). + scipy_rotation + A SciPy rotation (flattened) given by the unit quaternions + without considering any symmetry. + + See Also + -------- + from_scipy_rotation Notes ----- - SciPy by default uses the Active rotation convention along with the - vector-scalar quaternion definition, as opposed to ORIX's passive, - scalar-vector convention. Thus, the following quaternion in orix: - :math: `q_{orix} = [q_0, q_1, q_2, q_3]` - represents the same operations as the following quaternion in scipy: - :math: `q_{SciPy} = [-q_1, -q_2, -q_3, q_0]` - - See the function description for Quaternion.from_scipy_rotation - for an example of how these differing parameterizations still produce - identical rotation operations. - - Additionally, note that Orix enforces :math: `q_0 >= 0` whereas - SciPy does not. Thus, the operation - - >>> Quaternion.from_scipy_rotation(r).to_scipy_rotation.as_quat() - - will produce an identical rotation operation, but not - necessarily an idential quaternion. Look up "quaternion double cover" - for more information on why this occurs. - - Finally, ORIX supports N-dimensional arrays, whereas SciPy - currently supports only 1-dimensional vectors. Thus, this function - will also flatten arrays when converting to SciPy Rotations. + SciPy by default uses the active rotation interpretation along + with the vector-scalar quaternion definition, as opposed to + orix's passive one, scalar-vector interpretation. Thus, the + following quaternion in orix, + :math:`Q_{orix} = [q_0, q_1, q_2, q_3]` represents the same + transformation as the following quaternion in SciPy: + :math:`Q_{SciPy} = [-q_1, -q_2, -q_3, q_0]` + + See the function description for :meth:`from_scipy_rotation` for + an example of how these differing parameterizations still + produce identical transformations. + + Additionally, note that orix enforces :math:`Q_0 \geq 0` whereas + SciPy does not. Thus, the operation:: + + Quaternion.from_scipy_rotation(r).to_scipy_rotation.as_quat() + + will produce an identical transformation, but not necessarily an + idential quaternion. Look up "quaternion double cover" for more + information on why this occurs. """ if self.ndim > 1: warnings.warn( - "\n {} dimension greater than 1. ".format(self.__class__.__name__) + f"\n {self.__class__.__name__} dimension greater than 1. " + "Flattening into a 1-dimensional vector" ) self = self.flatten() @@ -1075,11 +1079,11 @@ def mean(self) -> Quaternion: def outer( self, - other: Union[Quaternion, Vector3d], + other: Quaternion | Vector3d, lazy: bool = False, chunk_size: int = 20, progressbar: bool = True, - ) -> Union[Quaternion, Vector3d]: + ) -> Quaternion | Vector3d: """Return the outer products of the quaternions and the other quaternions or vectors. @@ -1171,7 +1175,7 @@ def inv(self) -> Quaternion: # -------------------- Other private methods --------------------- # def _outer_dask( - self, other: Union[Quaternion, Vector3d], chunk_size: int = 20 + self, other: Quaternion | Vector3d, chunk_size: int = 20 ) -> da.Array: """Compute the product of every quaternion in this instance to every quaternion or vector in another instance, returned as a diff --git a/orix/quaternion/rotation.py b/orix/quaternion/rotation.py index 64ce43e86..811f79a01 100644 --- a/orix/quaternion/rotation.py +++ b/orix/quaternion/rotation.py @@ -1,4 +1,5 @@ -# Copyright 2018-2024 the orix developers +# +# Copyright 2019-2025 the orix developers # # This file is part of orix. # @@ -9,23 +10,24 @@ # # 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 +# 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 . +# along with orix. If not, see . +# from __future__ import annotations -from typing import Any, Tuple, Union +from typing import Any import dask.array as da from dask.diagnostics import ProgressBar import numpy as np from scipy.special import hyp0f1 -from orix.quaternion import Quaternion -from orix.vector import Vector3d +from orix.quaternion.quaternion import Quaternion +from orix.vector.vector3d import Vector3d class Rotation(Quaternion): @@ -73,7 +75,7 @@ class Rotation(Quaternion): True """ - def __init__(self, data: Union[np.ndarray, Rotation, list, tuple]): + def __init__(self, data: np.ndarray | Rotation | list | tuple) -> None: super().__init__(data) self._data = np.concatenate((self.data, np.zeros(self.shape + (1,))), axis=-1) if isinstance(data, Rotation): @@ -104,8 +106,8 @@ def antipodal(self) -> Rotation: # ------------------------ Dunder methods ------------------------ # def __mul__( - self, other: Union[Rotation, Quaternion, Vector3d, np.ndarray, int, list] - ): + self, other: Rotation | Quaternion | Vector3d | np.ndarray | int | list + ) -> Rotation | Quaternion | Vector3d: # Combine rotations self * other as first other, then self if isinstance(other, Rotation): Q = Quaternion(self) * Quaternion(other) @@ -146,7 +148,7 @@ def __invert__(self) -> Rotation: R.improper = self.improper return R - def __eq__(self, other: Union[Any, Rotation]) -> bool: + def __eq__(self, other: Any | Rotation) -> bool: """Check if the rotations have equal shapes and values.""" if ( isinstance(other, Rotation) @@ -163,9 +165,9 @@ def __eq__(self, other: Union[Any, Rotation]) -> bool: @classmethod def random_vonmises( cls, - shape: Union[int, tuple] = (1,), + shape: int | tuple = (1,), alpha: float = 1.0, - reference: Union[list, tuple, Rotation] = (1, 0, 0, 0), + reference: Rotation | list | tuple = (1, 0, 0, 0), ) -> Rotation: """Return random rotations with a simplified Von Mises-Fisher distribution. @@ -206,11 +208,9 @@ def unique( return_index: bool = False, return_inverse: bool = False, antipodal: bool = True, - ) -> Union[ - Rotation, - Tuple[Rotation, np.ndarray], - Tuple[Rotation, np.ndarray, np.ndarray], - ]: + ) -> ( + Rotation | tuple[Rotation, np.ndarray] | tuple[Rotation, np.ndarray, np.ndarray] + ): """Return the unique rotations from these rotations. Two rotations are not unique if they have the same propriety @@ -343,11 +343,11 @@ def angle_with_outer(self, other: Rotation, degrees: bool = False) -> np.ndarray def outer( self, - other: Union[Rotation, Vector3d], + other: Rotation | Vector3d, lazy: bool = False, chunk_size: int = 20, progressbar: bool = True, - ) -> Union[Rotation, Vector3d]: + ) -> Rotation | Vector3d: """Return the outer rotation products of the rotations and the other rotations or vectors. diff --git a/orix/quaternion/symmetry.py b/orix/quaternion/symmetry.py index ec3061eff..95a5754e7 100644 --- a/orix/quaternion/symmetry.py +++ b/orix/quaternion/symmetry.py @@ -1,4 +1,5 @@ -# Copyright 2018-2024 the orix developers +# +# Copyright 2019-2025 the orix developers # # This file is part of orix. # @@ -9,26 +10,28 @@ # # 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 +# 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 . +# along with orix. If not, see . +# from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Generator, 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, deprecated_argument from orix.quaternion.rotation import Rotation -from orix.vector import Vector3d +from orix.vector.vector3d import Vector3d if TYPE_CHECKING: # pragma: no cover - from orix.quaternion import Orientation - from orix.vector import FundamentalSector + from orix.quaternion.orientation import Orientation + from orix.vector.fundamental_sector import FundamentalSector class Symmetry(Rotation): @@ -56,6 +59,7 @@ class Symmetry(Rotation): """ name = "" + _schoenflies = "" # -------------------------- Properties -------------------------- # @@ -72,7 +76,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 = _point_groups_dictionary["permutations"] + return [g for g in groups if g._tuples <= self._tuples] @property def proper_subgroups(self) -> list[Symmetry]: @@ -136,10 +141,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 @@ -165,6 +172,7 @@ 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" @@ -182,6 +190,7 @@ def system(self) -> str | None: return "cubic" else: return None + # fmt: on @property def _tuples(self) -> set: @@ -218,7 +227,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])) @@ -279,9 +289,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 @@ -322,14 +332,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] @@ -409,6 +419,8 @@ def from_generators(cls, *generators: Rotation) -> Symmetry: # --------------------- Other public methods --------------------- # 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 {} @@ -418,6 +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)""" axis_orders = self.get_axis_orders() if len(axis_orders) == 0: return Vector3d.zvector(), np.inf @@ -461,82 +475,397 @@ 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, + asymmetric_vector: Vector3d | None = None, orientation: "Orientation | None" = None, + show_name: bool = True, + ax: plt.Axes | None = None, + return_figure: bool = True, + marker_dict: dict = {}, reproject_scatter_kwargs: dict | None = None, - **kwargs, - ) -> mfigure.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. + marker_size: float = 150.0, + mirror_width: float = 2.0, + asymetric_vector_dict: dict = {}, + 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. 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`. + 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. + 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: + 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. + Default is 50. Returns ------- fig 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 + ----- + + 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. """ - 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}$") + # 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 + + # dictionary of default colors for the symmetry markers. + colors = { + 1: "black", + 2: "green", + 3: "red", + 4: "purple", + 6: "magenta", + "m": "blue", + } + # 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 ax is None: + fig, ax = plt.subplots(subplot_kw={"projection": "stereographic"}) + else: + fig = ax.get_figure() + + # add a default title if requested + if show_name: + ax.set_title(self._schoenflies + " ( " + 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() + 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(): + # 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())) + if np.abs(z_ang - (np.pi / 2)) < 1e-4: + ax.symmetry_marker( + sv, folds=f, s=marker_size, color=c, modifier=None + ) + else: + 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 + # (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(): + ax.symmetry_marker(sv, folds=f, s=marker_size, color=c, modifier=t) + + # plot asymmetric markers if requested. + if asymmetric_vector is not None: + v_symm = self.outer(asymmetric_vector).flatten() + vdict = { + "upper_color": "black", + "lower_color": "grey", + "upper_marker": "+", + "lower_marker": "o", + } + vdict.update(asymetric_vector_dict) + mask = v_symm.z >= 0 + ax.scatter( + -1 * v_symm[~mask], + marker=vdict["lower_marker"], + c=vdict["lower_color"], + ) + ax.scatter( + v_symm[mask], + marker=vdict["upper_marker"], + c=vdict["upper_color"], + ) + # return the figure if requested if return_figure: - 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 + # 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 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( + (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( + (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( + (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( + (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( + (axis, m_flag, "rotoinversion", folds), + ) + continue + # if it it not a rotational symmetry of any type, it's a mirror + else: + elements.append( + (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 +# 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 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. + +# 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 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, 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 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 for Crystallography: Volume A" Section 12.1 +# 3)"Crystallogrpahic Texture and Group Representations", Chi-Sing Man,Ch2 + +# ---------------- 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 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. + +# 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 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, 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 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 for 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" -Ci = Symmetry([(1, 0, 0, 0), (1, 0, 0, 0)]) +C1._schoenflies = "C1" +Ci = Symmetry([(1, 0, 0, 0), (-1, 0, 0, 0)]) Ci.improper = [0, 1] Ci.name = "-1" +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._schoenflies = "S2" # Special generators _mirror_xy = Symmetry([(1, 0, 0, 0), (0, 0.75**0.5, -(0.75**0.5), 0)]) @@ -546,37 +875,53 @@ def plot( # 2-fold rotations C2x = Symmetry([(1, 0, 0, 0), (0, 1, 0, 0)]) C2x.name = "211" +C2x._schoenflies = "C2x" C2y = Symmetry([(1, 0, 0, 0), (0, 0, 1, 0)]) C2y.name = "121" +C2y._schoenflies = "C2y" C2z = Symmetry([(1, 0, 0, 0), (0, 0, 0, 1)]) C2z.name = "112" +C2z._schoenflies = "C2z" C2 = Symmetry(C2z) C2.name = "2" +C2._schoenflies = "C2" +# included redundant point group D1 == C2 +D1 = Symmetry(C2z) +D1.name = "2" +D1._schoenflies = "D1" # Mirrors Csx = Symmetry([(1, 0, 0, 0), (0, 1, 0, 0)]) Csx.improper = [0, 1] Csx.name = "m11" +Csx._schoenflies = "Csx" Csy = Symmetry([(1, 0, 0, 0), (0, 0, 1, 0)]) Csy.improper = [0, 1] Csy.name = "1m1" +Csy._schoenflies = "Csy" Csz = Symmetry([(1, 0, 0, 0), (0, 0, 0, 1)]) Csz.improper = [0, 1] Csz.name = "11m" +Csz._schoenflies = "Csz" Cs = Symmetry(Csz) Cs.name = "m" +Cs._schoenflies = "Cs" # Monoclinic C2h = Symmetry.from_generators(C2, Cs) C2h.name = "2/m" +C2h._schoenflies = "C2h" # Orthorhombic D2 = Symmetry.from_generators(C2z, C2x, C2y) D2.name = "222" -C2v = Symmetry.from_generators(C2x, Csz) +D2._schoenflies = "D2" +C2v = Symmetry.from_generators(C2z, Csx) C2v.name = "mm2" +C2v._schoenflies = "C2v" D2h = Symmetry.from_generators(Csz, Csx, Csy) D2h.name = "mmm" +D2h._schoenflies = "D2h" # 4-fold rotations C4x = Symmetry( @@ -584,7 +929,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 +937,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,144 +945,366 @@ 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) C4.name = "4" +C4._schoenflies = "C4" # Tetragonal S4 = Symmetry(C4) S4.improper = [0, 1, 0, 1] S4.name = "-4" +S4._schoenflies = "S4" +# include redundant point group C4i == S4 +C4i = Symmetry(C4) +C4i.improper = [0, 1, 0, 1] +C4i.name = "-4" +C4i._schoenflies = "C4i" C4h = Symmetry.from_generators(C4, Cs) C4h.name = "4/m" +C4h._schoenflies = "C4h" D4 = Symmetry.from_generators(C4, C2x, C2y) D4.name = "422" +D4._schoenflies = "D4" C4v = Symmetry.from_generators(C4, Csx) C4v.name = "4mm" +C4v._schoenflies = "C4v" D2d = Symmetry.from_generators(D2, _mirror_xy) D2d.name = "-42m" +D2d._schoenflies = "D2d" D4h = Symmetry.from_generators(C4h, Csx, Csy) D4h.name = "4/mmm" +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)]) -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" +C3._schoenflies = "C3" # Trigonal +C3i = Symmetry.from_generators(C3, Ci) +C3i.name = "-3" +C3i._schoenflies = "C3i" +# include redundant point group S6==C3i S6 = Symmetry.from_generators(C3, Ci) S6.name = "-3" +S6._schoenflies = "S6" D3x = Symmetry.from_generators(C3, C2x) D3x.name = "321" +D3x._schoenflies = "D3x" D3y = Symmetry.from_generators(C3, C2y) D3y.name = "312" +D3y._schoenflies = "D3y" D3 = Symmetry(D3x) D3.name = "32" +D3._schoenflies = "D3" C3v = Symmetry.from_generators(C3, Csx) C3v.name = "3m" +C3v._schoenflies = "C3v" D3d = Symmetry.from_generators(S6, Csx) D3d.name = "-3m" +D3d._schoenflies = "D3d" # Hexagonal C6 = Symmetry.from_generators(C3, C2) C6.name = "6" +C6._schoenflies = "C6" C3h = Symmetry.from_generators(C3, Cs) C3h.name = "-6" +C3h._schoenflies = "C3h" C6h = Symmetry.from_generators(C6, Cs) C6h.name = "6/m" +C6h._schoenflies = "C6h" D6 = Symmetry.from_generators(C6, C2x, C2y) D6.name = "622" +D6._schoenflies = "D6" C6v = Symmetry.from_generators(C6, Csx) C6v.name = "6mm" +C6v._schoenflies = "C6v" D3h = Symmetry.from_generators(C3, C2y, Csz) D3h.name = "-6m2" +D3h._schoenflies = "D3h" D6h = Symmetry.from_generators(D6, Csz) D6h.name = "6/mmm" +D6h._schoenflies = "D6h" # Cubic T = Symmetry.from_generators(C2, _cubic) T.name = "23" +T._schoenflies = "T" Th = Symmetry.from_generators(T, Ci) Th.name = "m-3" +Th._schoenflies = "Th" O = Symmetry.from_generators(C4, _cubic, C2x) O.name = "432" +O._schoenflies = "O" Td = Symmetry.from_generators(T, _mirror_xy) Td.name = "-43m" +Td._schoenflies = "Td" Oh = Symmetry.from_generators(O, Ci) 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, + ], +} -# 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_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: - """Return points symmetrically equivalent to identity with respect - to ``s1`` and ``s2``. - - Parameters - ---------- - s1 - First symmetry. - s2 - Second symmetry. - - Returns - ------- - distinguished_points - Distinguished points. - """ - distinguished_points = s1.outer(s2).antipodal.unique(antipodal=False) - return distinguished_points[distinguished_points.angle > 0] - - -spacegroup2pointgroup_dict = { +# 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}, @@ -780,39 +1347,294 @@ def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: } -def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: - """Map a space group number to its (proper) point group. +class PointGroups(list): + # make a lookup table of common subsets of Point Groups + subset_names = _point_groups_dictionary.keys() + _point_group_names = dict( + [(x.name, x) for x in _point_groups_dictionary["permutations_repeated"]] + ).keys() + + 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(). However, a list of Symmetry + objects can also be passed in to create a custom PointGroup. + + Parameters + ---------- + symmetry_list + 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 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: + 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 = ( + "| Name | System | HM | Laue | Proper |\n" + + "=" * 49 + + "\n" + + "\n".join( + [ + "| " + + "| ".join( + [ + 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.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 + 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.). + + 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 Hermann-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 'groups' list first, then 'permutations', + # then 'permutations_repeated'. + print(vars().keys()) + 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()] + # 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 + try: + return 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 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 + 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 _spacegroup2pointgroup_dict[pgn]["proper"] + else: + 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 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 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 + 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 entries are + symmetrically identical. + "proper": + 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 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 ITC. 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 + """ + 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( + "'name' must be one of " + + f"{', '.join(map(str, self.subset_names))}, not '{name}'" + ) + + +def get_distinguished_points(s1: Symmetry, s2: Symmetry = C1) -> Rotation: + """Return points symmetrically equivalent to identity with respect + to ``s1`` and ``s2``. Parameters ---------- - space_group_number - Between 1 and 231. - proper - Whether to return the point group with proper rotations only - (``True``), or just the point group (``False``). Default is - ``False``. + s1 + First symmetry. + s2 + Second symmetry. Returns ------- - point_group - One of the 11 proper or 32 point groups. - - 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' + distinguished_points + Distinguished points. + """ + distinguished_points = s1.outer(s2).antipodal.unique(antipodal=False) + return distinguished_points[distinguished_points.angle > 0] + + +@deprecated( + since="0.14", + removal="0.15", + alternative="PointGroups.from_space_group", +) +def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: + """ + 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.from_space_group(space_group_number, proper) # Point group alias mapping. This is needed because in EDAX TSL OIM @@ -831,30 +1653,33 @@ def get_point_group(space_group_number: int, proper: bool = False) -> Symmetry: 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 _point_groups_dictionary["permutations_repeated"]: + if g.name == name: + valid_name = True + break + 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._schoenflies[-1], ["x", "y", "z"]): + s_name = g._schoenflies[:-1] + 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 _point_groups_dictionary["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: + 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/conftest.py b/orix/tests/conftest.py index 7684e6d6a..6852fd3dc 100644 --- a/orix/tests/conftest.py +++ b/orix/tests/conftest.py @@ -16,80 +16,64 @@ # You should have received a copy of the GNU General Public License # along with orix. If not, see . # - - -import os -from tempfile import TemporaryDirectory - from diffpy.structure import Atom, Lattice, Structure from h5py import File import matplotlib.pyplot as plt import numpy as np import pytest -from orix import constants -from orix.crystal_map import CrystalMap, PhaseList, create_coordinate_arrays -from orix.quaternion import Rotation +from orix.constants import installed +from orix.crystal_map.crystal_map import CrystalMap, create_coordinate_arrays +from orix.crystal_map.phase_list import PhaseList +from orix.quaternion.rotation import Rotation # --------------------------- pytest hooks --------------------------- # -def pytest_sessionstart(session): # pragma: no cover +def pytest_sessionstart(session): plt.rcParams["backend"] = "agg" # -------------------- Control of test selection --------------------- # skipif_numpy_quaternion_present = pytest.mark.skipif( - constants.installed["numpy-quaternion"], reason="numpy-quaternion installed" + installed["numpy-quaternion"], reason="numpy-quaternion installed" ) skipif_numpy_quaternion_missing = pytest.mark.skipif( - not constants.installed["numpy-quaternion"], reason="numpy-quaternion not installed" + not installed["numpy-quaternion"], reason="numpy-quaternion not installed" ) -# ---------------------------- IO fixtures --------------------------- # - -# ----------------------------- .ang file ---------------------------- # - def pytest_addoption(parser): parser.addoption( - "--runslow", action="store_true", default=False, help="run slow tests" + "--slow", action="store_true", default=False, help="Run slow tests" ) -def pytest_configure(config): - config.addinivalue_line("markers", "slow: mark test as slow to run") +# Markers are defined in package configuration +MARKERS = ["slow"] -def pytest_collection_modifyitems(config, items): - if config.getoption("--runslow"): - # --runslow given in cli: do not skip slow tests - return - else: # pragma: no cover - skip_slow = pytest.mark.skip(reason="need --runslow option to run") - for item in items: - if "slow" in item.keywords: - item.add_marker(skip_slow) +def pytest_runtest_setup(item): + # Skip certain tests when flag is missing: + # https://docs.pytest.org/en/stable/reference/reference.html#pytest.hookspec.pytest_runtest_setup + for marker in MARKERS: + marker_str = f"--{marker}" + if marker in item.keywords and not item.config.getoption(marker_str): + pytest.skip(f"Needs {marker_str} flag to run") -@pytest.fixture() -def temp_ang_file(): - with TemporaryDirectory() as tempdir: - f = open(os.path.join(tempdir, "temp_ang_file.ang"), mode="w+") - yield f +# ---------------------------- IO fixtures --------------------------- # +# ----------------------------- .ang file ---------------------------- # -@pytest.fixture(params=["h5"]) -def temp_file_path(request): - """Temporary file in a temporary directory for use when tests need - to write, and sometimes read again, data to, and from, a file. - """ - ext = request.param - with TemporaryDirectory() as tmp: - file_path = os.path.join(tmp, "data_temp." + ext) - yield file_path + +@pytest.fixture() +def temp_ang_file(tmpdir): + fname = tmpdir.join("temp_ang_file.ang") + with open(fname, mode="w+") as f: + yield f ANGFILE_TSL_HEADER = r"""# TEM_PIXperUM 1.000000 @@ -392,7 +376,7 @@ def angfile_emsoft(tmpdir, request): # Variable map shape and step sizes CTF_OXFORD_HEADER = r"""Channel Text File Prj standard steel sample -Author +Author JobMode Grid XCells %i YCells %i @@ -754,7 +738,7 @@ def ctf_emsoft(tmpdir, request): AcqE1 0.0000 AcqE2 0.0000 AcqE3 0.0000 -Euler angles refer to Sample Coordinate system (CS0)! Mag 0.0000 Coverage 0 Device 0 KV 0.0000 TiltAngle 0.0000 TiltAxis 0 DetectorOrientationE1 0.0000 DetectorOrientationE2 0.0000 DetectorOrientationE3 0.0000 WorkingDistance 0.0000 InsertionDistance 0.0000 +Euler angles refer to Sample Coordinate system (CS0)! Mag 0.0000 Coverage 0 Device 0 KV 0.0000 TiltAngle 0.0000 TiltAxis 0 DetectorOrientationE1 0.0000 DetectorOrientationE2 0.0000 DetectorOrientationE3 0.0000 WorkingDistance 0.0000 InsertionDistance 0.0000 Phases 1 4.079;4.079;4.079 90.000;90.000;90.000 Gold 11 0 Created from mtex Phase X Y Bands Error Euler1 Euler2 Euler3 MAD BC BS""" diff --git a/orix/tests/io/test_ang.py b/orix/tests/io/test_ang.py index b224bfdaf..f1f57e789 100644 --- a/orix/tests/io/test_ang.py +++ b/orix/tests/io/test_ang.py @@ -1,4 +1,5 @@ -# Copyright 2018-2024 the orix developers +# +# Copyright 2019-2025 the orix developers # # This file is part of orix. # @@ -9,11 +10,12 @@ # # 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 +# 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 . +# along with orix. If not, see . +# import numpy as np import pytest @@ -371,23 +373,24 @@ def test_load_ang_emsoft( def test_get_header(self, temp_ang_file): temp_ang_file.write(ANGFILE_ASTAR_HEADER) temp_ang_file.close() - assert _get_header(open(temp_ang_file.name)) == [ - "# File created from ACOM RES results", - "# ni-dislocations.res", - "# ".rstrip(), - "# ".rstrip(), - "# MaterialName Nickel", - "# Formula", - "# Symmetry 43", - "# LatticeConstants 3.520 3.520 3.520 90.000 90.000 90.000", - "# NumberFamilies 4", - "# hklFamilies 1 1 1 1 0.000000", - "# hklFamilies 2 0 0 1 0.000000", - "# hklFamilies 2 2 0 1 0.000000", - "# hklFamilies 3 1 1 1 0.000000", - "#", - "# GRID: SqrGrid#", - ] + with open(temp_ang_file.name) as f: + assert _get_header(f) == [ + "# File created from ACOM RES results", + "# ni-dislocations.res", + "# ".rstrip(), + "# ".rstrip(), + "# MaterialName Nickel", + "# Formula", + "# Symmetry 43", + "# LatticeConstants 3.520 3.520 3.520 90.000 90.000 90.000", + "# NumberFamilies 4", + "# hklFamilies 1 1 1 1 0.000000", + "# hklFamilies 2 0 0 1 0.000000", + "# hklFamilies 2 2 0 1 0.000000", + "# hklFamilies 3 1 1 1 0.000000", + "#", + "# GRID: SqrGrid#", + ] @pytest.mark.parametrize( "expected_vendor, expected_columns, vendor_header", @@ -419,7 +422,8 @@ def test_get_vendor_columns( temp_ang_file.write(vendor_header) temp_ang_file.close() - header = _get_header(open(temp_ang_file.name)) + with open(temp_ang_file.name) as f: + header = _get_header(f) vendor, column_names = _get_vendor_columns(header, n_cols_file) assert vendor == expected_vendor @@ -429,7 +433,8 @@ def test_get_vendor_columns( def test_get_vendor_columns_unknown(self, temp_ang_file, n_cols_file): temp_ang_file.write("Look at me!\nI'm Mr. .ang file!\n") temp_ang_file.close() - header = _get_header(open(temp_ang_file.name)) + with open(temp_ang_file.name) as f: + header = _get_header(f) 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" diff --git a/orix/tests/io/test_h5ebsd.py b/orix/tests/io/test_h5ebsd.py index c364e71a9..b309bbecc 100644 --- a/orix/tests/io/test_h5ebsd.py +++ b/orix/tests/io/test_h5ebsd.py @@ -1,4 +1,5 @@ -# Copyright 2018-2024 the orix developers +# +# Copyright 2019-2025 the orix developers # # This file is part of orix. # @@ -9,11 +10,12 @@ # # 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 +# 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 . +# along with orix. If not, see . +# from h5py import File @@ -22,12 +24,13 @@ class TestH5ebsd: - def test_hdf5group2dict_update_dict(self, temp_file_path, crystal_map): + def test_hdf5group2dict_update_dict(self, tmp_path, crystal_map): """Can read datasets from an HDF5 file into an existing dictionary. """ - save(temp_file_path, crystal_map) - with File(temp_file_path, mode="r") as f: + fname = tmp_path / "test.h5" + save(fname, crystal_map) + with File(fname, mode="r") as f: this_dict = {"hello": "there"} this_dict = hdf5group2dict(f["crystal_map"], dictionary=this_dict) assert this_dict["hello"] == "there" diff --git a/orix/tests/io/test_io.py b/orix/tests/io/test_io.py index fe276927e..462589104 100644 --- a/orix/tests/io/test_io.py +++ b/orix/tests/io/test_io.py @@ -1,4 +1,5 @@ -# Copyright 2018-2024 the orix developers +# +# Copyright 2019-2025 the orix developers # # This file is part of orix. # @@ -9,11 +10,12 @@ # # 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 +# 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 . +# along with orix. If not, see . +# from collections import OrderedDict from contextlib import contextmanager @@ -73,11 +75,11 @@ def test_load_no_filename_match(self): with pytest.raises(IOError, match=f"No filename matches '{fname}'."): _ = load(fname) - @pytest.mark.parametrize("temp_file_path", ["ktf"], indirect=["temp_file_path"]) - def test_load_unsupported_format(self, temp_file_path): - np.savetxt(temp_file_path, X=np.random.rand(100, 8)) - with pytest.raises(IOError, match=f"Could not read "): - _ = load(temp_file_path) + def test_load_unsupported_format(self, tmp_path): + fname = tmp_path / "unsupported_file.ktf" + np.savetxt(fname, X=np.random.rand(100, 8)) + with pytest.raises(IOError, match="Could not read "): + _ = load(fname) @pytest.mark.parametrize( "manufacturer, expected_plugin", @@ -88,61 +90,62 @@ def test_load_unsupported_format(self, temp_file_path): ("Oxford", None), ], ) - def test_plugin_from_manufacturer( - self, temp_file_path, manufacturer, expected_plugin - ): + def test_plugin_from_manufacturer(self, manufacturer, expected_plugin, tmp_path): h5ebsd_plugin_list = [bruker_h5ebsd, emsoft_h5ebsd, orix_hdf5] - with File(temp_file_path, mode="w") as f: + fname = tmp_path / "test.h5" + with File(fname, mode="w") as f: f.create_dataset(name="Manufacturer", data=manufacturer) assert ( - _plugin_from_manufacturer(temp_file_path, plugins=h5ebsd_plugin_list) + _plugin_from_manufacturer(fname, plugins=h5ebsd_plugin_list) is expected_plugin ) - def test_overwrite_or_not(self, crystal_map, temp_file_path): - save(temp_file_path, crystal_map) + def test_overwrite_or_not(self, crystal_map, tmp_path): + fname = tmp_path / "test.h5" + save(fname, crystal_map) with pytest.warns(UserWarning, match="Not overwriting, since your terminal "): - _overwrite_or_not(temp_file_path) + _overwrite_or_not(fname) @pytest.mark.parametrize( "answer, expected", [("y", True), ("n", False), ("m", None)] ) - def test_overwrite_or_not_input( - self, crystal_map, temp_file_path, answer, expected - ): - save(temp_file_path, crystal_map) + def test_overwrite_or_not_input(self, crystal_map, answer, expected, tmp_path): + fname = tmp_path / "test.h5" + save(fname, crystal_map) if answer == "m": with replace_stdin(StringIO(answer)): with pytest.raises(EOFError): - _overwrite_or_not(temp_file_path) + _overwrite_or_not(fname) else: with replace_stdin(StringIO(answer)): - assert _overwrite_or_not(temp_file_path) is expected + assert _overwrite_or_not(fname) is expected - @pytest.mark.parametrize("temp_file_path", ["angs", "hdf4", "h6"]) - def test_save_unsupported_raises(self, temp_file_path, crystal_map): - _, ext = os.path.splitext(temp_file_path) + @pytest.mark.parametrize("ext", ["angs", "hdf4", "h6"]) + def test_save_unsupported_raises(self, ext, crystal_map, tmp_path): + fname = tmp_path / f"test.{ext}" with pytest.raises(IOError, match=f"'{ext}' does not correspond to any "): - save(temp_file_path, crystal_map) + save(fname, crystal_map) - def test_save_overwrite_raises(self, temp_file_path, crystal_map): + def test_save_overwrite_raises(self, crystal_map, tmp_path): with pytest.raises(ValueError, match="`overwrite` parameter can only be "): - save(temp_file_path, crystal_map, overwrite=1) + save(tmp_path / "test.h5", crystal_map, overwrite=1) @pytest.mark.parametrize( "overwrite, expected_phase_name", [(True, "hepp"), (False, "")] ) def test_save_overwrite( - self, temp_file_path, crystal_map, overwrite, expected_phase_name + self, crystal_map, overwrite, expected_phase_name, tmp_path ): + fname = tmp_path / "test.h5" + assert crystal_map.phases[0].name == "" - save(temp_file_path, crystal_map) - assert os.path.isfile(temp_file_path) is True + save(fname, crystal_map) + assert os.path.isfile(fname) is True crystal_map.phases[0].name = "hepp" - save(temp_file_path, crystal_map, overwrite=overwrite) + save(fname, crystal_map, overwrite=overwrite) - crystal_map2 = load(temp_file_path) + crystal_map2 = load(fname) assert crystal_map2.phases[0].name == expected_phase_name diff --git a/orix/tests/io/test_orix_hdf5.py b/orix/tests/io/test_orix_hdf5.py index 11796600c..8640788c0 100644 --- a/orix/tests/io/test_orix_hdf5.py +++ b/orix/tests/io/test_orix_hdf5.py @@ -1,4 +1,5 @@ -# Copyright 2018-2024 the orix developers +# +# Copyright 2019-2025 the orix developers # # This file is part of orix. # @@ -9,11 +10,12 @@ # # 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 +# 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 . +# along with orix. If not, see . +# from diffpy.structure.spacegroups import GetSpaceGroup from h5py import File @@ -22,7 +24,7 @@ from orix import __version__ as orix_version from orix.crystal_map import CrystalMap, Phase -from orix.io import load, save +import orix.io as oio from orix.io.plugins.orix_hdf5 import ( atom2dict, crystalmap2dict, @@ -42,10 +44,12 @@ class TestOrixHDF5Plugin: - def test_file_writer(self, crystal_map, temp_file_path): - save(filename=temp_file_path, object2write=crystal_map) + def test_file_writer(self, crystal_map, tmp_path): + fname = tmp_path / "test.h5" + + oio.save(filename=fname, object2write=crystal_map) - with File(temp_file_path) as f: + with File(fname) as f: assert f["manufacturer"][()][0].decode() == "orix" assert f["version"][()][0].decode() == orix_version @@ -57,10 +61,12 @@ def test_file_writer(self, crystal_map, temp_file_path): ], indirect=["crystal_map_input"], ) - def test_write_read_masked(self, crystal_map_input, temp_file_path): + def test_write_read_masked(self, crystal_map_input, tmp_path): + fname = tmp_path / "test.h5" + xmap = CrystalMap(**crystal_map_input) - save(filename=temp_file_path, object2write=xmap[xmap.x > 2]) - xmap2 = load(temp_file_path) + oio.save(filename=fname, object2write=xmap[xmap.x > 2]) + xmap2 = oio.load(fname) assert xmap2.size != xmap.size with pytest.raises(ValueError, match="operands could not be broadcast"): @@ -70,13 +76,14 @@ def test_write_read_masked(self, crystal_map_input, temp_file_path): assert xmap2.size == xmap.size assert np.allclose(xmap2.x, xmap.x) - def test_file_writer_raises(self, temp_file_path, crystal_map): + def test_file_writer_raises(self, crystal_map, tmp_path): + fname = tmp_path / "test.h5" with pytest.raises(OSError, match="Cannot write to the already open file "): - with File(temp_file_path, mode="w") as _: - save(temp_file_path, crystal_map, overwrite=True) + with File(fname, mode="w") as _: + oio.save(fname, crystal_map, overwrite=True) - def test_dict2hdf5group(self, temp_file_path): - with File(temp_file_path, mode="w") as f: + def test_dict2hdf5group(self, tmp_path): + with File(tmp_path / "test.h5", mode="w") as f: group = f.create_group(name="a_group") with pytest.warns(UserWarning, match="The orix HDF5 writer could not"): dict2hdf5group( @@ -141,9 +148,11 @@ def test_structure2dict(self, phase_list): assert np.allclose(lattice1["baserot"], lattice2["baserot"]) assert_dictionaries_are_equal(structure_dict["atoms"], this_dict["atoms"]) - def test_file_reader(self, crystal_map, temp_file_path): - save(filename=temp_file_path, object2write=crystal_map) - xmap2 = load(filename=temp_file_path) + def test_file_reader(self, crystal_map, tmp_path): + fname = tmp_path / "test.h5" + + oio.save(filename=fname, object2write=crystal_map) + xmap2 = oio.load(filename=fname) assert_dictionaries_are_equal(crystal_map.__dict__, xmap2.__dict__) def test_dict2crystalmap(self, crystal_map): @@ -207,7 +216,7 @@ def test_dict2atom(self, phase_list): assert str(atom.element) == str(atom2.element) assert np.allclose(atom.xyz, atom2.xyz) - def test_write_read_nd_crystalmap_properties(self, temp_file_path, crystal_map): + def test_write_read_nd_crystalmap_properties(self, crystal_map, tmp_path): """Crystal map properties with more than one value in each point (e.g. top matching scores from dictionary indexing) can be written and read from file correctly. @@ -225,8 +234,9 @@ def test_write_read_nd_crystalmap_properties(self, temp_file_path, crystal_map): prop3d = np.arange(map_size * 4).reshape(prop3d_shape) xmap.prop[prop3d_name] = prop3d - save(filename=temp_file_path, object2write=xmap) - xmap2 = load(temp_file_path) + fname = tmp_path / "test.h5" + oio.save(filename=fname, object2write=xmap) + xmap2 = oio.load(fname) assert np.allclose(xmap2.prop[prop2d_name], xmap.prop[prop2d_name]) assert np.allclose(xmap2.prop[prop3d_name], xmap.prop[prop3d_name]) diff --git a/orix/tests/plot/test_stereographic_plot.py b/orix/tests/plot/test_stereographic_plot.py index b05c520ba..cfb170331 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,84 +287,39 @@ 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("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) + 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) + 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)) 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], folds=i, s=marker_size, color="k") + 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) 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"): - ax.symmetry_marker([0, 0, 1], fold=5) + assert len(markers) == 43 plt.close("all") @@ -432,8 +378,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 +434,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_orientation.py b/orix/tests/quaternion/test_orientation.py index f02424500..d8b72f124 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, + PointGroups, ) -from orix.vector import Miller, Vector3d +from orix.vector import Miller, Vector3d # isort: on # fmt: on +groups = PointGroups.get_set("permutations_repeated") +proper_groups = PointGroups.get_set("proper_groups") + @pytest.fixture def vector(request): @@ -89,11 +91,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 +299,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)) @@ -363,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,)) @@ -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) @@ -798,7 +803,7 @@ 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) 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..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 @@ -34,12 +35,17 @@ 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 + PointGroups, + _get_unique_symmetry_elements ) # isort: on # fmt: on from orix.vector import Vector3d +# fmt: onfrom orix.vector import Vector3d + +_groups = PointGroups.get_set("permutations") + @pytest.fixture(params=[(1, 2, 3)]) def vector(request): @@ -54,23 +60,23 @@ 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), + (-1, -2, 3), + (-1, 2, 3), (1, -2, 3), - (1, -2, -3), - (1, 2, -3), ], ), ( - C4v, + PointGroups.get("C4v"), (1, 2, 3), [ (1, 2, 3), @@ -84,7 +90,7 @@ def all_symmetries(request): ], ), ( - D4, + PointGroups.get("D4"), (1, 2, 3), [ (1, 2, 3), @@ -98,7 +104,7 @@ def all_symmetries(request): ], ), ( - C6, + PointGroups.get("C6"), (1, 2, 3), [ (1, 2, 3), @@ -110,7 +116,7 @@ def all_symmetries(request): ], ), ( - Td, + PointGroups.get("Td"), (1, 2, 3), [ (1, 2, 3), @@ -140,7 +146,7 @@ def all_symmetries(request): ], ), ( - Oh, + PointGroups.get("Oh"), (1, 2, 3), [ (1, 2, 3), @@ -276,8 +282,8 @@ def test_is_proper(symmetry, expected): "symmetry, expected", [ (C1, [C1]), - (D2, [C1, C2x, C2y, C2z, D2]), - (C6v, [C1, Csx, Csy, C2z, 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 +294,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): @@ -305,7 +311,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 +450,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): @@ -512,33 +518,78 @@ 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] - - 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$" - + 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, ax=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") -@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") +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_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") + + 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: @@ -595,9 +646,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 @@ -645,10 +696,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 +713,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) @@ -675,7 +731,9 @@ def test_fundamental_sector_s6(self): 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.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) @@ -684,7 +742,9 @@ def test_fundamental_sector_d3(self): 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.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) @@ -702,7 +762,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) @@ -718,7 +780,9 @@ def test_fundamental_sector_c3h(self): 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.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) @@ -727,7 +791,9 @@ def test_fundamental_sector_c6h(self): 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.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) @@ -736,7 +802,9 @@ def test_fundamental_sector_d6(self): 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.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) @@ -752,7 +820,9 @@ def test_fundamental_sector_d3h(self): 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.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) @@ -761,7 +831,9 @@ def test_fundamental_sector_d6h(self): 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.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) @@ -771,7 +843,12 @@ def test_fundamental_sector_t(self): 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) @@ -860,6 +937,48 @@ def test_equality(symmetry): assert Rotation(symmetry) == symmetry +@pytest.mark.parametrize( + ["subset", "length", "proper_count"], + [ + ["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 = 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 = 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( + 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): + PointGroups.get_set("banana") + + class TestLaueGroup: def test_crystal_system(self): assert Ci.system == "triclinic" @@ -883,7 +1002,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: @@ -919,7 +1039,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)) diff --git a/orix/tests/test_crystal_map.py b/orix/tests/test_crystal_map.py index b8b4a38d6..9d4ad2e4a 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 @@ -207,8 +212,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"], @@ -660,9 +675,9 @@ 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): @@ -1042,7 +1057,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) diff --git a/orix/tests/test_miller.py b/orix/tests/test_miller.py index d16969666..03046f808 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( @@ -482,7 +488,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: @@ -833,18 +840,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 +860,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): diff --git a/orix/vector/miller.py b/orix/vector/miller.py index 897898cec..2511a95a1 100644 --- a/orix/vector/miller.py +++ b/orix/vector/miller.py @@ -1,4 +1,5 @@ -# Copyright 2018-2024 the orix developers +# +# Copyright 2019-2025 the orix developers # # This file is part of orix. # @@ -9,11 +10,12 @@ # # 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 +# 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 . +# along with orix. If not, see . +# from __future__ import annotations @@ -21,12 +23,6 @@ from itertools import product from typing import TYPE_CHECKING, Optional, Tuple, Union -try: - # New in Python 3.11 - from typing import Self -except ImportError: # pragma: no cover - from typing_extensions import Self - from diffpy.structure import Lattice import numpy as np @@ -344,7 +340,7 @@ def is_hexagonal(self) -> bool: return self.phase.is_hexagonal @property - def unit(self) -> Self: + def unit(self) -> Miller: """Return unit vectors.""" m = self.__class__(xyz=super().unit.data, phase=self.phase) m.coordinate_format = self.coordinate_format @@ -363,7 +359,7 @@ def __repr__(self) -> str: f"{name} {shape}, point group {symmetry}, {coordinate_format}\n" f"{data}" ) - def __getitem__(self, key) -> Self: + def __getitem__(self, key) -> Miller: """NumPy fancy indexing of vectors.""" m = self.__class__(xyz=self.data[key], phase=self.phase).deepcopy() m.coordinate_format = self.coordinate_format @@ -378,7 +374,7 @@ def from_highest_indices( uvw: Union[np.ndarray, list, tuple, None] = None, hkl: Union[np.ndarray, list, tuple, None] = None, include_zero_vector: bool = False, - ) -> Self: + ) -> Miller: """Create a set of unique direct or reciprocal lattice vectors from three highest indices and a phase (crystal lattice and symmetry). @@ -405,7 +401,7 @@ def from_highest_indices( return cls(**init_kw).unique() @classmethod - def from_min_dspacing(cls, phase: "Phase", min_dspacing: float = 0.05) -> Self: + def from_min_dspacing(cls, phase: "Phase", min_dspacing: float = 0.05) -> Miller: """Create a set of unique reciprocal lattice vectors with a a direct space interplanar spacing greater than a lower threshold. @@ -432,7 +428,7 @@ def random( phase: "Phase", shape: Union[int, tuple] = 1, coordinate_format: str = "xyz", - ) -> Self: + ) -> Miller: """Create random Miller indices. Parameters @@ -466,11 +462,11 @@ def random( # --------------------- Other public methods --------------------- # - def deepcopy(self) -> Self: + def deepcopy(self) -> Miller: """Return a deepcopy of the instance.""" return deepcopy(self) - def round(self, max_index: int = 20) -> Self: + def round(self, max_index: int = 20) -> Miller: """Round a set of index triplet (Miller) or quartet (Miller-Bravais/Weber) to the *closest* smallest integers. @@ -498,7 +494,9 @@ def symmetrise( unique: bool = False, return_multiplicity: bool = False, return_index: bool = False, - ) -> Union[Self, Tuple[Self, np.ndarray], Tuple[Self, np.ndarray, np.ndarray]]: + ) -> Union[ + Miller, Tuple[Miller, np.ndarray], Tuple[Miller, np.ndarray, np.ndarray] + ]: """Return vectors symmetrically equivalent to the vectors. Parameters @@ -585,7 +583,7 @@ def symmetrise( def angle_with( self, - other: Self, + other: Miller, use_symmetry: bool = False, degrees: bool = False, ) -> np.ndarray: @@ -630,7 +628,7 @@ def angle_with( return angles - def cross(self, other: Self) -> Self: + def cross(self, other: Miller) -> Miller: """Return the cross products of the vectors with the other vectors, which is considered the zone axes between the vectors. @@ -652,7 +650,7 @@ def cross(self, other: Self) -> Self: m.coordinate_format = new_fmt[self.coordinate_format] return m - def dot(self, other: Self) -> np.ndarray: + def dot(self, other: Miller) -> np.ndarray: """Return the dot products of the vectors and the other vectors. Parameters @@ -669,7 +667,7 @@ def dot(self, other: Self) -> np.ndarray: self._compatible_with(other, raise_error=True) return super().dot(other) - def dot_outer(self, other: Self) -> np.ndarray: + def dot_outer(self, other: Miller) -> np.ndarray: """Return the outer dot products of the vectors and the other vectors. @@ -687,7 +685,7 @@ def dot_outer(self, other: Self) -> np.ndarray: self._compatible_with(other, raise_error=True) return super().dot_outer(other) - def flatten(self) -> Self: + def flatten(self) -> Miller: """Return the flattened vectors. Returns @@ -699,7 +697,7 @@ def flatten(self) -> Self: m.coordinate_format = self.coordinate_format return m - def transpose(self, *axes: Optional[int]) -> Self: + def transpose(self, *axes: Optional[int]) -> Miller: """Return a new instance with the data transposed. The order may be undefined if :attr:`ndim` is originally 2. In @@ -725,7 +723,7 @@ def get_nearest(self, *args) -> NotImplemented: """NotImplemented.""" return NotImplemented - def mean(self, use_symmetry: bool = False) -> Self: + def mean(self, use_symmetry: bool = False) -> Miller: """Return the mean vector of the set of vectors. Parameters @@ -745,7 +743,7 @@ def mean(self, use_symmetry: bool = False) -> Self: m.coordinate_format = self.coordinate_format return m - def reshape(self, *shape: Union[int, tuple]) -> Self: + def reshape(self, *shape: Union[int, tuple]) -> Miller: """Return a new instance with the vectors reshaped. Parameters @@ -764,7 +762,7 @@ def reshape(self, *shape: Union[int, tuple]) -> Self: def unique( self, use_symmetry: bool = False, return_index: bool = False - ) -> Union[Self, Tuple[Self, np.ndarray]]: + ) -> Union[Miller, Tuple[Miller, np.ndarray]]: """Unique vectors in ``self``. Parameters @@ -809,7 +807,7 @@ def unique( else: return m - def in_fundamental_sector(self, symmetry: Optional["Symmetry"] = None) -> Self: + def in_fundamental_sector(self, symmetry: Optional["Symmetry"] = None) -> Miller: """Project Miller indices to a symmetry's fundamental sector (inverse pole figure). @@ -856,7 +854,7 @@ def in_fundamental_sector(self, symmetry: Optional["Symmetry"] = None) -> Self: # -------------------- Other private methods --------------------- # - def _compatible_with(self, other: Self, raise_error: bool = False) -> bool: + def _compatible_with(self, other: Miller, raise_error: bool = False) -> bool: """Whether ``self`` and ``other`` are the same (the same crystal lattice and symmetry) with vectors in the same space. diff --git a/pyproject.toml b/pyproject.toml index ec28e9d97..314d50251 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,8 +36,6 @@ dependencies = [ "pycifrw", "scipy", "tqdm", - # TODO: Remove once Python >= 3.11 - "typing_extensions", ] [project.optional-dependencies] @@ -49,7 +47,8 @@ doc = [ "memory_profiler", "nbconvert >= 7.16.4", "nbsphinx >= 0.7", - "numpydoc", + # Restriction due to https://github.com/pyxem/orix/issues/570 + "numpydoc != 1.9.0", "pydata-sphinx-theme", "scikit-image", "scikit-learn", @@ -100,12 +99,16 @@ addopts = [ "-ra", "--ignore=doc/_static/img/colormap_banners/create_colormap_banners.py", "--ignore=examples/*/*.py", + "--strict-markers", ] doctest_optionflags = "NORMALIZE_WHITESPACE" filterwarnings = [ "ignore:Deprecated call to `pkg_resources:DeprecationWarning", "ignore:pkg_resources is deprecated as an API:DeprecationWarning", ] +markers = [ + "slow: mark test as slow", +] [tool.isort] profile = "black"