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"