From 6b71368a7359d17f4bbc6515dd299e699701de94 Mon Sep 17 00:00:00 2001 From: Timo Reents Date: Tue, 29 Sep 2026 16:03:01 +0200 Subject: [PATCH 1/4] First draft for optional backend --- README.md | 1 + docs/source/maindoc.rst | 117 ++++++++++++++++- pyproject.toml | 4 + seekpath/getpaths.py | 53 ++++++-- seekpath/hpkot/__init__.py | 61 +++++---- seekpath/hpkot/backends.py | 229 +++++++++++++++++++++++++++++++++ tests/test_backends.py | 257 +++++++++++++++++++++++++++++++++++++ 7 files changed, 681 insertions(+), 41 deletions(-) create mode 100644 seekpath/hpkot/backends.py create mode 100644 tests/test_backends.py diff --git a/README.md b/README.md index 407a1dc..d5b37e1 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ If you use this tool, please cite the following work: - Y. Hinuma, G. Pizzi, Y. Kumagai, F. Oba, I. Tanaka, *Band structure diagram paths based on crystallography*, Comp. Mat. Sci. 128, 140 (2017) ([JOURNAL LINK](https://dx.doi.org/10.1016/j.commatsci.2016.10.015), [arXiv link](https://arxiv.org/abs/1602.06402)). - You should also cite [spglib](https://atztogo.github.io/spglib/) that is an essential library used in the implementation: A. Togo, I. Tanaka, "Spglib: a software library for crystal symmetry search", arXiv:1808.01590 (2018) ([spglib arXiv link](https://arxiv.org/abs/1808.01590)). +- If you run `SeeK-path` with the optional `moyopy` backend (`backend='moyopy'`), you should cite [moyo](https://github.com/spglib/moyo) in place of spglib for the symmetry analysis: K. Shinohara, *moyo: A fast and robust crystal symmetry finder, written in Rust* (2026) ([figshare link](https://figshare.com/articles/software/moyo_A_fast_and_robust_crystal_symmetry_finder_written_in_Rust_/31081162), doi:10.6084/m9.figshare.31081162.v1). ## How to install and how to use diff --git a/docs/source/maindoc.rst b/docs/source/maindoc.rst index f207c40..126417f 100644 --- a/docs/source/maindoc.rst +++ b/docs/source/maindoc.rst @@ -42,7 +42,7 @@ How to use ========== The main interface of the code is the :py:func:`~seekpath.getpaths.get_path` python function:: - seekpath.get_path(structure, with_time_reversal, recipe, threshold, symprec, angle_tolerance) + seekpath.get_path(structure, with_time_reversal, recipe, threshold, symprec, angle_tolerance, backend) You need to pass a crystal structure, a boolean flag (``with_time_reversal``) to say if time-reversal symmetry is present or not, and optionally, a recipe (currently only the string ``hpkot`` is supported) and a numerical threshold. @@ -59,7 +59,118 @@ where (if ``N`` is the number of atoms): The output of the function is a dictionary containing, among other quantities, the k-vector coefficients, the suggested band path, whether the system has inversion symmetry, the crystallographic primitive lattice, the reciprocal primitive lattice. A detailed description of all output information and their format can be found in the function docstring. (Note that the ``threshold`` is the one used by seekpath to identify -e.g. the order of axes in an orthorhombic cell; instead ``symprec`` and ``angle_tolerance`` are just passed to spglib). +e.g. the order of axes in an orthorhombic cell; instead ``symprec`` and ``angle_tolerance`` are just passed to the symmetry backend). + +------------------ +Symmetry backends +------------------ + +The symmetry analysis that standardizes the input structure is done by +`spglib`_ by default. Passing ``backend='moyopy'`` uses `moyopy`_ instead, a +Rust reimplementation of spglib:: + + seekpath.get_path(structure, backend='moyopy') + +``moyopy`` is an optional dependency, installed with:: + + pip install seekpath[moyopy] + +``moyopy >= 0.21`` is required. Earlier versions choose conventional cells +that do not follow the conventions the HPKOT recipe expects (before 0.17), are +pathologically slow on perfect supercells (before 0.18), return standardized +cells that keep the distortion of a slightly distorted input instead of +symmetrizing it as ``spglib`` does (before 0.20), or report the mirror-image +space group of a chiral crystal given in a left-handed basis (before 0.21). + +What agrees +~~~~~~~~~~~ + +:py:func:`~seekpath.getpaths.get_path` and +:py:func:`~seekpath.getpaths.get_explicit_k_path` give the **same** result with +either backend. For all 60 reference structures shipped with seekpath, with and +without time reversal, the Bravais lattice classification, the extended Bravais +symbol, the space group, the path, the k-point labels and their coordinates are +identical, and the primitive lattices agree to within 1e-10. This also holds +under random rotations and unimodular changes of the input basis, for +left-handed input bases, and for supercells. Only the atom positions in the +standardized cells can differ (case 4). + +What can differ +~~~~~~~~~~~~~~~ + +Four cases are known. None of them makes one backend right and the other wrong, +but they are worth knowing about before switching an existing workflow. + +**1. K-points in the basis of the original cell.** +:py:func:`~seekpath.getpaths.get_path_orig_cell` and +:py:func:`~seekpath.getpaths.get_explicit_k_path_orig_cell` express the k-points +in the basis of the *input* cell. That depends on which of the several +symmetry-equivalent alignments of the standardized cell the backend picked, and +the two libraries do not always pick the same one. + +For 25 of the 60 reference structures the two backends give identical +k-points. For the other 35 the k-points differ by an integral operation in the +conventional basis, most often a 180 or 120 degree rotation, and the two paths +are symmetry-equivalent (allowing for time reversal) in every case: a band +structure computed along either is the same, but the numbers are not +identical. Code that compares ``get_path_orig_cell`` output against stored +k-points should therefore not switch backend without re-generating them. + +**2. Very loose** ``symprec`` **, right at a symmetry-promotion threshold.** +Where a structure sits on the boundary between two space groups, the two +libraries cross that boundary at slightly different tolerances, because they +apply ``symprec`` to different measures of the atomic mismatch: ``moyopy`` +promotes to the higher-symmetry group at the smaller tolerance of the two. +Displacing every atom of each reference structure by 0.02 Angstrom and raising +``symprec`` until the undistorted space group is recovered, ``spglib`` needs a +tolerance 1.1 to 2.0 times larger than ``moyopy`` does (median 1.7 over the 28 +structures where both recover it). Away from such a boundary the two agree over +the whole practical range. +If a structure's space group changes when ``symprec`` is nudged, that is a sign +the tolerance is too loose to be meaningful, whichever backend is used. + +**3. Triclinic cells with 90 degree reciprocal angles (** ``aP2`` **vs** ``aP3`` +**).** A triclinic crystal sitting on a higher-symmetry lattice - a defect cell +or a substituted supercell, say - can be classified either way, because the test +that separates the two looks at the sign of a quantity that is exactly zero. +This is **not** a backend difference: the spglib backend alone returns both +answers for the same crystal supplied in different orientations. seekpath raises +:py:class:`~seekpath.hpkot.EdgeCaseWarning` when it happens, and that warning +should be taken seriously whichever backend is in use. + +**4. Atom positions in the standardized cell.** The two libraries may choose +different, symmetry-equivalent origins, so ``conv_positions`` and +``primitive_positions`` can differ by a shift (and in atom order). This does not +affect the k-paths, which are origin-independent. + +.. note:: For a supercell whose lattice has lower symmetry than the crystal + itself, ``moyopy`` logs a warning that it omitted some symmetry operations. + seekpath does not use these operations, so the warning does not affect its + results. It can be silenced with + ``logging.getLogger('moyo').setLevel(logging.ERROR)``. + +Performance +~~~~~~~~~~~ + +``moyopy`` is the faster of the two across the board. Measured through +:py:func:`~seekpath.getpaths.get_path` on rocksalt supercells, either perfect or +with every atom displaced slightly to break the supercell symmetry: + +============================= ========== ========== +cell ``spglib`` ``moyopy`` +============================= ========== ========== +128 atoms, low symmetry 2.7 ms 1.6 ms +432 atoms, low symmetry 17.8 ms 8.4 ms +1024 atoms, low symmetry 87.3 ms 34.4 ms +128 atoms, perfect supercell 9.0 ms 1.0 ms +432 atoms, perfect supercell 10.0 ms 2.8 ms +1024 atoms, perfect supercell 14.7 ms 11.3 ms +============================= ========== ========== + +The speedup grows with the number of atoms for a low-symmetry cell, and shrinks +for a large perfect supercell, where ``spglib`` scales well because it reduces +to the primitive cell early. + ---------------------------------------- K-point path for non-standard unit cells @@ -127,6 +238,8 @@ https://aiida-core.readthedocs.io/en/latest/datatypes/kpoints.html .. _JOURNAL LINK: https://dx.doi.org/10.1016/j.commatsci.2016.10.015 .. _arXiv link: https://arxiv.org/abs/1602.06402 .. _spglib: https://atztogo.github.io/spglib/ + +.. _moyopy: https://spglib.github.io/moyo/python/ .. _Materials Cloud: https://www.materialscloud.org/tools/seekpath/ .. _docker hub: https://hub.docker.com/r/giovannipizzi/seekpath/ .. _AiiDA: https://www.aiida.net diff --git a/pyproject.toml b/pyproject.toml index a6dcdf9..cce0a0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,8 +43,12 @@ Downloads = "https://github.com/materialscloud-org/seekpath/archive/v2.2.2.tar.g bz = [ "scipy>=1", ] +moyopy = [ + "moyopy>=0.21", +] dev = [ "black==23.3.0", + "moyopy>=0.21; python_version >= '3.10'", "pre-commit~=3.5", "prospector==1.11.0", "pytest==7.3.1", diff --git a/seekpath/getpaths.py b/seekpath/getpaths.py index f9a8ebc..49e42c2 100644 --- a/seekpath/getpaths.py +++ b/seekpath/getpaths.py @@ -5,6 +5,7 @@ import numpy as np import warnings from . import SupercellWarning +from .hpkot import DEFAULT_BACKEND def get_explicit_from_implicit(seekpath_output, reference_distance): @@ -82,6 +83,7 @@ def get_path( threshold=1.0e-7, symprec=1e-05, angle_tolerance=-1.0, + backend=DEFAULT_BACKEND, ): r""" Return the kpoint path information for band structure given a @@ -125,9 +127,16 @@ def get_path( Note that depending on the bravais lattice, the meaning of the threshold is different (angle, length, ...) - :param symprec: the symmetry precision used internally by SPGLIB + :param symprec: the symmetry precision used internally by the symmetry backend - :param angle_tolerance: the angle_tolerance used internally by SPGLIB + :param angle_tolerance: the angle_tolerance used internally by the symmetry + backend + + :param backend: the symmetry backend used to standardize the structure, + either ``'spglib'`` (the default) or ``'moyopy'``. The ``'moyopy'`` + backend requires the optional ``moyopy >= 0.21`` dependency and is + usually faster; see the documentation for where the results of the + two backends can differ. :return: a dictionary with the following @@ -185,6 +194,7 @@ def get_path( threshold=threshold, symprec=symprec, angle_tolerance=angle_tolerance, + backend=backend, ) else: @@ -203,6 +213,7 @@ def get_explicit_k_path( threshold=1.0e-7, symprec=1e-05, angle_tolerance=-1.0, + backend=DEFAULT_BACKEND, ): r""" Return the kpoint path for band structure (in scaled and absolute @@ -257,9 +268,16 @@ def get_explicit_k_path( Note that depending on the bravais lattice, the meaning of the threshold is different (angle, length, ...) - :param symprec: the symmetry precision used internally by SPGLIB + :param symprec: the symmetry precision used internally by the symmetry backend + + :param angle_tolerance: the angle_tolerance used internally by the symmetry + backend - :param angle_tolerance: the angle_tolerance used internally by SPGLIB + :param backend: the symmetry backend used to standardize the structure, + either ``'spglib'`` (the default) or ``'moyopy'``. The ``'moyopy'`` + backend requires the optional ``moyopy >= 0.21`` dependency and is + usually faster; see the documentation for where the results of the + two backends can differ. .. versionchanged:: 1.8 The key ``segments`` has been renamed ``explicit_segments`` @@ -313,6 +331,7 @@ def get_explicit_k_path( threshold=threshold, symprec=symprec, angle_tolerance=angle_tolerance, + backend=backend, ) else: @@ -336,6 +355,7 @@ def get_path_orig_cell( threshold=1.0e-7, symprec=1e-05, angle_tolerance=-1.0, + backend=DEFAULT_BACKEND, ): r""" Return the kpoint path information for band structure given a @@ -388,9 +408,16 @@ def get_path_orig_cell( Note that depending on the bravais lattice, the meaning of the threshold is different (angle, length, ...) - :param symprec: the symmetry precision used internally by SPGLIB + :param symprec: the symmetry precision used internally by the symmetry backend - :param angle_tolerance: the angle_tolerance used internally by SPGLIB + :param angle_tolerance: the angle_tolerance used internally by the symmetry + backend + + :param backend: the symmetry backend used to standardize the structure, + either ``'spglib'`` (the default) or ``'moyopy'``. The ``'moyopy'`` + backend requires the optional ``moyopy >= 0.21`` dependency and is + usually faster; see the documentation for where the results of the + two backends can differ. :return: a dictionary with the following @@ -420,6 +447,7 @@ def get_path_orig_cell( symprec=symprec, angle_tolerance=angle_tolerance, recipe=recipe, + backend=backend, ) # The volume ratio is negative for a left-handed input cell, so only its @@ -480,6 +508,7 @@ def get_explicit_k_path_orig_cell( threshold=1.0e-7, symprec=1e-05, angle_tolerance=-1.0, + backend=DEFAULT_BACKEND, ): r""" Return the kpoint path for band structure (in scaled and absolute @@ -537,9 +566,16 @@ def get_explicit_k_path_orig_cell( Note that depending on the bravais lattice, the meaning of the threshold is different (angle, length, ...) - :param symprec: the symmetry precision used internally by SPGLIB + :param symprec: the symmetry precision used internally by the symmetry backend + + :param angle_tolerance: the angle_tolerance used internally by the symmetry + backend - :param angle_tolerance: the angle_tolerance used internally by SPGLIB + :param backend: the symmetry backend used to standardize the structure, + either ``'spglib'`` (the default) or ``'moyopy'``. The ``'moyopy'`` + backend requires the optional ``moyopy >= 0.21`` dependency and is + usually faster; see the documentation for where the results of the + two backends can differ. .. versionchanged:: 1.8 The key ``segments`` has been renamed ``explicit_segments`` @@ -593,6 +629,7 @@ def get_explicit_k_path_orig_cell( symprec=symprec, angle_tolerance=angle_tolerance, recipe=recipe, + backend=backend, ) # Set reciprocal_primitive_lattice as the reciprocal lattice of the original diff --git a/seekpath/hpkot/__init__.py b/seekpath/hpkot/__init__.py index e6930c2..fb4d864 100644 --- a/seekpath/hpkot/__init__.py +++ b/seekpath/hpkot/__init__.py @@ -13,6 +13,15 @@ the Materials Project (https://materialsproject.org). """ +# ``SymmetryDetectionError`` is re-exported here so that +# ``seekpath.hpkot.SymmetryDetectionError`` and ``seekpath.SymmetryDetectionError`` +# keep working; it is defined alongside the backends that raise it. +from .backends import ( # noqa: F401 + DEFAULT_BACKEND, + SUPPORTED_BACKENDS, + SymmetryDetectionError, +) + class EdgeCaseWarning(RuntimeWarning): """ @@ -21,18 +30,13 @@ class EdgeCaseWarning(RuntimeWarning): """ -class SymmetryDetectionError(Exception): - """ - Error raised if spglib could not detect the symmetry. - """ - - def get_path( structure, with_time_reversal=True, threshold=1.0e-7, symprec=1e-05, angle_tolerance=-1.0, + backend=DEFAULT_BACKEND, ): r""" Return the kpoint path information for band structure given a @@ -76,9 +80,16 @@ def get_path( Note that depending on the bravais lattice, the meaning of the threshold is different (angle, length, ...) - :param symprec: the symmetry precision used internally by SPGLIB + :param symprec: the symmetry precision used internally by the symmetry backend - :param angle_tolerance: the angle_tolerance used internally by SPGLIB + :param angle_tolerance: the angle_tolerance used internally by the symmetry + backend + + :param backend: the symmetry backend used to standardize the structure, + either ``'spglib'`` (the default) or ``'moyopy'``. The ``'moyopy'`` + backend requires the optional ``moyopy >= 0.21`` dependency and is + usually faster; see the documentation for where the results of the + two backends can differ. :return: a dictionary with the following @@ -136,49 +147,37 @@ def get_path( import numpy as np from .tools import ( - check_spglib_version, extend_kparam, eval_expr, eval_expr_simple, get_cell_params, - get_dot_access_dataset, get_path_data, get_reciprocal_cell_rows, get_real_cell_from_reciprocal_rows, ) + from .backends import get_symmetry_dataset, niggli_reduce from .spg_mapping import get_spgroup_data, get_primitive - # I check if the SPGlib version is recent enough (raises ValueError) - # otherwise - spglib = check_spglib_version() - structure_internal = ( np.array(structure[0]), np.array(structure[1]), np.array(structure[2]), ) - # Symmetry analysis by SPGlib, get crystallographic lattice, + # Symmetry analysis by the chosen backend, get crystallographic lattice, # and cell parameters for this lattice - dataset = get_dot_access_dataset( - spglib.get_symmetry_dataset( - structure_internal, symprec=symprec, angle_tolerance=angle_tolerance - ) + dataset = get_symmetry_dataset( + structure_internal, + symprec=symprec, + angle_tolerance=angle_tolerance, + backend=backend, ) - if dataset is None: - raise SymmetryDetectionError( - 'Spglib could not detect the symmetry of the system' - ) conv_lattice = dataset.std_lattice conv_positions = dataset.std_positions conv_types = dataset.std_types a, b, c, cosalpha, cosbeta, cosgamma = get_cell_params(conv_lattice) spgrp_num = dataset.number - # This is the transformation from the original to the crystallographic - # conventional (called std in spglib) - # Lattice^{crystallographic_bravais} = L^{original} * transf_matrix - transf_matrix = dataset.transformation_matrix - volume_conv_wrt_original = np.linalg.det(transf_matrix) + volume_original_wrt_conv = dataset.volume_original_wrt_conv # Get the properties of the spacegroup, needed to get the bravais_lattice properties = get_spgroup_data()[spgrp_num] @@ -317,7 +316,7 @@ def get_path( # I use the default eps here, this could be changed reciprocal_cell_orig = get_reciprocal_cell_rows(conv_lattice) ## This is Niggli-reduced - reciprocal_cell2 = spglib.niggli_reduce(reciprocal_cell_orig) + reciprocal_cell2 = niggli_reduce(reciprocal_cell_orig) real_cell2 = get_real_cell_from_reciprocal_rows(reciprocal_cell2) # TODO: get transformation matrix? @@ -502,8 +501,8 @@ def get_path( # For the time being disabled, not valid for aP lattices # (for which we would need the transformation matrix from niggli) #'transformation_matrix': transf_matrix, - 'volume_original_wrt_conv': volume_conv_wrt_original, - 'volume_original_wrt_prim': volume_conv_wrt_original * np.linalg.det(invP), + 'volume_original_wrt_conv': volume_original_wrt_conv, + 'volume_original_wrt_prim': volume_original_wrt_conv * np.linalg.det(invP), 'spacegroup_number': dataset.number, 'spacegroup_international': dataset.international, 'rotation_matrix': dataset.std_rotation_matrix, diff --git a/seekpath/hpkot/backends.py b/seekpath/hpkot/backends.py new file mode 100644 index 0000000..9013306 --- /dev/null +++ b/seekpath/hpkot/backends.py @@ -0,0 +1,229 @@ +"""Symmetry backends used to standardize a crystal structure. + +seekpath needs only a small part of a full symmetry dataset: the conventional +(standardized) cell, the space-group number and international symbol, the +rotation from the input orientation to the standardized one, and the volume +ratio between the input and the conventional cell. This module normalizes what +the supported symmetry libraries return into a single :class:`SymmetryDataset`, +so that everything downstream in :mod:`seekpath.hpkot` stays backend agnostic. + +Two backends are supported: + +``spglib`` + The default, and a hard dependency of seekpath. + +``moyopy`` + An optional backend, requiring ``moyopy >= 0.21``. moyopy is a Rust + reimplementation of spglib and is usually faster. + +.. note:: moyopy is queried with ``Setting.spglib()``, which selects the same + Hall-symbol representative that spglib uses. Together with the + conventional-cell fixes released in moyo 0.13 (orthorhombic ``a <= b <= c`` + axis ordering) and 0.17 (F-centered orthorhombic cells, settings whose axis + permutation needs an origin shift, and the monoclinic cell choice), this + makes moyopy reproduce the conventional cells that the HPKOT recipe expects. + The floor is 0.21 because, like spglib, moyopy only returns + symmetry-refined standardized cells from 0.20 on; before, they kept the + distortion of the input, which seekpath would pass on to the conventional + and primitive lattices it returns. From 0.21 on, moyopy also returns + right-handed standardized cells for a left-handed input; before, it kept + the input handedness and reported the mirror-image space group of a chiral + crystal (e.g. P4_3 for P4_1). + +.. note:: Niggli reduction is always done with spglib, for both backends, since + moyopy does not implement it. It is only used for the triclinic (aP) branch + and operates on a single 3x3 matrix, so it is not performance relevant. +""" + +from dataclasses import dataclass + +import numpy as np + +#: The backend used when the caller does not ask for a specific one. +DEFAULT_BACKEND = 'spglib' + +#: The backends that :func:`get_symmetry_dataset` accepts. +SUPPORTED_BACKENDS = ('spglib', 'moyopy') + +#: Oldest moyopy whose standardized cells are refined, right-handed and follow +#: the conventions HPKOT needs. +MOYOPY_MIN_VERSION = '0.21' + + +class SymmetryDetectionError(Exception): + """Error raised if the symmetry of the structure could not be detected.""" + + +@dataclass +class SymmetryDataset: + """The subset of a symmetry dataset that seekpath uses. + + :param number: the international space-group number. + :param international: the international (Hermann-Mauguin) symbol, without + spaces. + :param std_lattice: the conventional cell, as a 3x3 array with the lattice + vectors as *rows*. + :param std_positions: fractional coordinates of the atoms in the + conventional cell. + :param std_types: atomic numbers of the atoms in the conventional cell. + :param std_rotation_matrix: the rotation in Cartesian space bringing the + input structure onto the standardized one. + :param volume_original_wrt_conv: the volume of the input cell divided by + the volume of the conventional cell. + """ + + number: int + international: str + std_lattice: np.ndarray + std_positions: np.ndarray + std_types: np.ndarray + std_rotation_matrix: np.ndarray + volume_original_wrt_conv: float + + +def get_symmetry_dataset( + structure, symprec=1e-05, angle_tolerance=-1.0, backend=DEFAULT_BACKEND +): + """Standardize ``structure`` with the requested backend. + + :param structure: a tuple ``(cell, positions, numbers)``, with the lattice + vectors as rows of ``cell`` and ``positions`` in fractional + coordinates. + :param symprec: the symmetry precision. + :param angle_tolerance: the angle tolerance. The spglib convention of a + negative value meaning "use the default" is honoured by both backends. + :param backend: one of :data:`SUPPORTED_BACKENDS`. + + :return: a :class:`SymmetryDataset`. + + :raise ValueError: if ``backend`` is not a supported backend. + :raise SymmetryDetectionError: if the backend could not detect the + symmetry of the structure. + """ + if backend == 'spglib': + return _get_spglib_dataset(structure, symprec, angle_tolerance) + if backend == 'moyopy': + return _get_moyopy_dataset(structure, symprec, angle_tolerance) + raise ValueError( + f"Unknown symmetry backend '{backend}', should be one of {SUPPORTED_BACKENDS}" + ) + + +def _get_spglib_dataset(structure, symprec, angle_tolerance): + """Build a :class:`SymmetryDataset` with spglib.""" + from .tools import check_spglib_version, get_dot_access_dataset + + # Raises a ValueError if the spglib version is too old + spglib = check_spglib_version() + + dataset = get_dot_access_dataset( + spglib.get_symmetry_dataset( + structure, symprec=symprec, angle_tolerance=angle_tolerance + ) + ) + if dataset is None: + raise SymmetryDetectionError( + 'spglib could not detect the symmetry of the system' + ) + + return SymmetryDataset( + number=dataset.number, + international=dataset.international.replace(' ', ''), + std_lattice=np.array(dataset.std_lattice), + std_positions=np.array(dataset.std_positions), + std_types=np.array(dataset.std_types), + std_rotation_matrix=np.array(dataset.std_rotation_matrix), + # spglib's transformation matrix maps the conventional cell onto the + # input one, so its determinant is already V_original / V_conventional + volume_original_wrt_conv=np.linalg.det(dataset.transformation_matrix), + ) + + +def _get_moyopy_dataset(structure, symprec, angle_tolerance): + """Build a :class:`SymmetryDataset` with moyopy.""" + moyopy = _check_moyopy_version() + + cell, positions, numbers = structure + moyo_cell = moyopy.Cell( + np.array(cell, dtype=float).tolist(), + np.array(positions, dtype=float).tolist(), + np.array(numbers).tolist(), + ) + + try: + dataset = moyopy.MoyoDataset( + moyo_cell, + symprec=symprec, + # spglib takes the angle tolerance in degrees and spells "use the + # default" as a negative value; moyopy takes radians, or None + angle_tolerance=( + np.deg2rad(angle_tolerance) if angle_tolerance > 0 else None + ), + # Select the same Hall-symbol representative that spglib uses, + # rather than moyopy's default ITA setting + setting=moyopy.Setting.spglib(), + ) + except Exception as exc: + raise SymmetryDetectionError( + f'moyopy could not detect the symmetry of the system: {exc}' + ) from exc + + # moyopy has no `international` field; look the symbol up from the number + international = moyopy.SpaceGroupType(dataset.number).hm_short.replace(' ', '') + + # `std_linear` is the change of basis from the input cell to the + # conventional one, so V_original / V_conventional is the inverse of its + # determinant. As with spglib, the ratio is negative for a left-handed input + volume_original_wrt_conv = 1 / np.linalg.det(np.array(dataset.std_linear)) + + return SymmetryDataset( + number=dataset.number, + international=international, + std_lattice=np.array(dataset.std_cell.basis), + std_positions=np.array(dataset.std_cell.positions), + std_types=np.array(dataset.std_cell.numbers), + std_rotation_matrix=np.array(dataset.std_rotation_matrix), + volume_original_wrt_conv=volume_original_wrt_conv, + ) + + +def _check_moyopy_version(): + """Import moyopy, checking it is recent enough. + + :return: the moyopy module. + + :raise ValueError: if moyopy is missing or older than + :data:`MOYOPY_MIN_VERSION`. + """ + from importlib.metadata import version + + from packaging.version import Version + + try: + import moyopy + except ImportError as exc: + raise ValueError( + f'moyopy >= {MOYOPY_MIN_VERSION} is required for the ' + "'moyopy' backend, but it could not be imported. Install it " + 'with `pip install seekpath[moyopy]`' + ) from exc + + if Version(version('moyopy')) < Version(MOYOPY_MIN_VERSION): + raise ValueError( + f'Invalid moyopy version, need >= {MOYOPY_MIN_VERSION} for ' + 'symmetry-refined standardized cells that follow the conventions ' + 'the HPKOT recipe expects' + ) + + return moyopy + + +def niggli_reduce(lattice): + """Return the Niggli-reduced form of ``lattice`` (vectors as rows). + + Always uses spglib: moyopy does not implement Niggli reduction, and + spglib is a hard dependency of seekpath in any case. + """ + from .tools import check_spglib_version + + return check_spglib_version().niggli_reduce(lattice) diff --git a/tests/test_backends.py b/tests/test_backends.py new file mode 100644 index 0000000..dd02f0e --- /dev/null +++ b/tests/test_backends.py @@ -0,0 +1,257 @@ +"""Test the symmetry backends, and that they agree with each other. + +The ``moyopy`` backend is optional, so every test that needs it is skipped when +it is not installed. +""" + +import glob +import os + +import numpy as np +import pytest +from test_paths_hpkot import simple_read_poscar + +import seekpath +from seekpath import hpkot +from seekpath.hpkot.backends import ( + DEFAULT_BACKEND, + SUPPORTED_BACKENDS, + SymmetryDetectionError, + get_symmetry_dataset, +) + +BAND_PATH_DATA = os.path.join(os.path.dirname(hpkot.__file__), 'band_path_data') + +try: + import moyopy as _moyopy # noqa: F401 + + HAS_MOYOPY = True +except ImportError: + HAS_MOYOPY = False + +needs_moyopy = pytest.mark.skipif(not HAS_MOYOPY, reason='moyopy is not installed') + +# Every (extended Bravais symbol, POSCAR) pair shipped as reference data +REFERENCE_STRUCTURES = sorted( + (os.path.basename(folder), os.path.basename(poscar).replace('POSCAR_', '')) + for folder in glob.glob(os.path.join(BAND_PATH_DATA, '*')) + if os.path.isdir(folder) + for poscar in glob.glob(os.path.join(folder, 'POSCAR_*')) +) + + +def read_reference(ext_bravais, variant): + """Read the reference POSCAR for an extended Bravais symbol.""" + return simple_read_poscar( + os.path.join(BAND_PATH_DATA, ext_bravais, f'POSCAR_{variant}') + ) + + +def ids(param): + """Readable test ids for the (ext_bravais, variant) pairs.""" + return f'{param[0]}-{param[1]}' + + +class TestBackendSelection: + """Test how the backend is selected and validated.""" + + def test_default_is_spglib(self): + """The default backend must stay spglib, for backwards compatibility.""" + assert DEFAULT_BACKEND == 'spglib' + + def test_unknown_backend_raises(self): + """An unknown backend name is rejected with a helpful message.""" + structure = read_reference('cP1', 'inversion') + with pytest.raises(ValueError, match="Unknown symmetry backend 'nope'"): + hpkot.get_path(structure, backend='nope') + + @pytest.mark.parametrize('backend', SUPPORTED_BACKENDS) + def test_symmetry_detection_error(self, backend): + """Both backends report a failed detection the same way. + + A degenerate cell (two parallel lattice vectors, so zero volume) cannot + be analysed, and must raise ``SymmetryDetectionError`` whichever backend + is used, rather than the backend's own exception type. + """ + if backend == 'moyopy' and not HAS_MOYOPY: + pytest.skip('moyopy is not installed') + degenerate = ( + [[4.0, 0.0, 0.0], [4.0, 0.0, 0.0], [0.0, 0.0, 4.0]], + [[0.0, 0.0, 0.0]], + [6], + ) + with pytest.raises(SymmetryDetectionError): + get_symmetry_dataset(degenerate, backend=backend) + + +@needs_moyopy +class TestMoyopyDataset: + """Test the moyopy dataset against the spglib one, field by field.""" + + @pytest.mark.parametrize('case', REFERENCE_STRUCTURES, ids=ids) + def test_dataset_fields_agree(self, case): + """The standardized cell, space group and volume ratio must agree. + + The atom positions are deliberately not compared: moyo may pick a + different, symmetry-equivalent origin, which is a legitimate choice and + does not affect the k-path. + """ + structure = read_reference(*case) + spglib_ds = get_symmetry_dataset(structure, backend='spglib') + moyopy_ds = get_symmetry_dataset(structure, backend='moyopy') + + assert spglib_ds.number == moyopy_ds.number + assert spglib_ds.international == moyopy_ds.international + assert sorted(spglib_ds.std_types) == sorted(moyopy_ds.std_types) + np.testing.assert_allclose( + spglib_ds.std_lattice, moyopy_ds.std_lattice, atol=1e-8 + ) + np.testing.assert_allclose( + spglib_ds.volume_original_wrt_conv, + moyopy_ds.volume_original_wrt_conv, + atol=1e-8, + ) + + @pytest.mark.parametrize(('degrees', 'expected'), [(-1.0, None), (5.0, 0.0872665)]) + def test_angle_tolerance_is_converted(self, monkeypatch, degrees, expected): + """seekpath's angle tolerance is in degrees (as spglib's), moyopy's in radians.""" + import moyopy + + received = {} + original = moyopy.MoyoDataset + + def spy(*args, **kwargs): + received.update(kwargs) + return original(*args, **kwargs) + + monkeypatch.setattr(moyopy, 'MoyoDataset', spy) + get_symmetry_dataset( + read_reference('cP1', 'inversion'), + angle_tolerance=degrees, + backend='moyopy', + ) + if expected is None: + assert received['angle_tolerance'] is None + else: + assert received['angle_tolerance'] == pytest.approx(expected) + + +@needs_moyopy +class TestBackendsAgree: + """Test that both backends give the same k-path for the reference set.""" + + @pytest.mark.parametrize('with_time_reversal', [True, False]) + @pytest.mark.parametrize('case', REFERENCE_STRUCTURES, ids=ids) + def test_get_path_agrees(self, case, with_time_reversal): + """``get_path`` must give identical results with either backend.""" + structure = read_reference(*case) + kwargs = {'with_time_reversal': with_time_reversal} + res_spglib = hpkot.get_path(structure, backend='spglib', **kwargs) + res_moyopy = hpkot.get_path(structure, backend='moyopy', **kwargs) + + for key in ( + 'bravais_lattice', + 'bravais_lattice_extended', + 'spacegroup_number', + 'spacegroup_international', + 'has_inversion_symmetry', + 'augmented_path', + 'path', + ): + assert res_spglib[key] == res_moyopy[key], f'{key} differs' + + assert set(res_spglib['point_coords']) == set(res_moyopy['point_coords']) + for label, coords in res_spglib['point_coords'].items(): + np.testing.assert_allclose( + coords, res_moyopy['point_coords'][label], atol=1e-6, err_msg=label + ) + + for key in ('primitive_lattice', 'reciprocal_primitive_lattice'): + np.testing.assert_allclose( + res_spglib[key], res_moyopy[key], atol=1e-8, err_msg=key + ) + + @pytest.mark.parametrize('case', REFERENCE_STRUCTURES, ids=ids) + def test_reference_classification(self, case): + """moyopy must reproduce the HPKOT extended Bravais classification. + + This is the same assertion the spglib-only tests make, so it checks + moyopy against the reference data rather than only against spglib. + """ + ext_bravais, variant = case + structure = read_reference(ext_bravais, variant) + res = hpkot.get_path(structure, with_time_reversal=False, backend='moyopy') + + assert res['bravais_lattice_extended'] == ext_bravais + assert res['has_inversion_symmetry'] == variant.startswith('inversion') + + @pytest.mark.parametrize('case', REFERENCE_STRUCTURES, ids=ids) + def test_explicit_k_path_agrees(self, case): + """The explicit (interpolated) k-point list must agree as well.""" + structure = read_reference(*case) + res_spglib = seekpath.get_explicit_k_path(structure, backend='spglib') + res_moyopy = seekpath.get_explicit_k_path(structure, backend='moyopy') + + assert ( + res_spglib['explicit_kpoints_labels'] + == (res_moyopy['explicit_kpoints_labels']) + ) + np.testing.assert_allclose( + res_spglib['explicit_kpoints_rel'], + res_moyopy['explicit_kpoints_rel'], + atol=1e-6, + ) + + +@needs_moyopy +class TestOrigCellPathsAreEquivalent: + """Test the k-path expressed in the basis of the *original* cell. + + Unlike ``get_path``, ``get_path_orig_cell`` depends on + ``std_rotation_matrix``, i.e. on which of the symmetry-equivalent + standardizations the backend picked. spglib and moyopy may pick different + ones, so the two paths need not be numerically identical - but they must be + related by a single symmetry operation of the crystal, which makes them + physically equivalent. + """ + + @staticmethod + def _symmetry_equivalent(points_a, points_b, rotations): + """Is there one operation mapping every k-point of a onto b?""" + labels = sorted(points_a) + k_a = np.array([points_a[label] for label in labels]) + k_b = np.array([points_b[label] for label in labels]) + + candidates = [np.array(rot, dtype=float) for rot in rotations] + # Time reversal (k -> -k) is a symmetry of the band structure too + candidates += [-candidate for candidate in candidates] + + for candidate in candidates: + for matrix in ( + candidate, + candidate.T, + np.linalg.inv(candidate), + np.linalg.inv(candidate).T, + ): + difference = k_a @ matrix - k_b + # Two k-points differing by a reciprocal lattice vector are equal + difference -= np.round(difference) + if np.abs(difference).max() < 1e-5: + return True + return False + + @pytest.mark.parametrize('case', REFERENCE_STRUCTURES, ids=ids) + def test_orig_cell_path_equivalent(self, case): + """The original-cell k-points must be symmetry-equivalent.""" + import spglib + + structure = read_reference(*case) + res_spglib = seekpath.get_path_orig_cell(structure, backend='spglib') + res_moyopy = seekpath.get_path_orig_cell(structure, backend='moyopy') + + assert res_spglib['path'] == res_moyopy['path'] + + rotations = spglib.get_symmetry_dataset(structure, symprec=1e-5).rotations + assert self._symmetry_equivalent( + res_spglib['point_coords'], res_moyopy['point_coords'], rotations + ) From c5b2fc0307acdcaba199a9406e828a5a4851c99b Mon Sep 17 00:00:00 2001 From: Timo Reents Date: Tue, 29 Sep 2026 16:23:36 +0200 Subject: [PATCH 2/4] Clean-up --- docs/source/maindoc.rst | 7 ----- seekpath/getpaths.py | 16 +++++------ seekpath/hpkot/__init__.py | 7 ++--- seekpath/hpkot/backends.py | 55 ++++++-------------------------------- tests/test_backends.py | 1 - 5 files changed, 18 insertions(+), 68 deletions(-) diff --git a/docs/source/maindoc.rst b/docs/source/maindoc.rst index 126417f..0b6aaf3 100644 --- a/docs/source/maindoc.rst +++ b/docs/source/maindoc.rst @@ -75,13 +75,6 @@ Rust reimplementation of spglib:: pip install seekpath[moyopy] -``moyopy >= 0.21`` is required. Earlier versions choose conventional cells -that do not follow the conventions the HPKOT recipe expects (before 0.17), are -pathologically slow on perfect supercells (before 0.18), return standardized -cells that keep the distortion of a slightly distorted input instead of -symmetrizing it as ``spglib`` does (before 0.20), or report the mirror-image -space group of a chiral crystal given in a left-handed basis (before 0.21). - What agrees ~~~~~~~~~~~ diff --git a/seekpath/getpaths.py b/seekpath/getpaths.py index 49e42c2..3781e0d 100644 --- a/seekpath/getpaths.py +++ b/seekpath/getpaths.py @@ -134,8 +134,8 @@ def get_path( :param backend: the symmetry backend used to standardize the structure, either ``'spglib'`` (the default) or ``'moyopy'``. The ``'moyopy'`` - backend requires the optional ``moyopy >= 0.21`` dependency and is - usually faster; see the documentation for where the results of the + backend requires the optional ``moyopy`` dependency and is usually + faster; see the documentation for where the results of the two backends can differ. @@ -275,8 +275,8 @@ def get_explicit_k_path( :param backend: the symmetry backend used to standardize the structure, either ``'spglib'`` (the default) or ``'moyopy'``. The ``'moyopy'`` - backend requires the optional ``moyopy >= 0.21`` dependency and is - usually faster; see the documentation for where the results of the + backend requires the optional ``moyopy`` dependency and is usually + faster; see the documentation for where the results of the two backends can differ. .. versionchanged:: 1.8 @@ -415,8 +415,8 @@ def get_path_orig_cell( :param backend: the symmetry backend used to standardize the structure, either ``'spglib'`` (the default) or ``'moyopy'``. The ``'moyopy'`` - backend requires the optional ``moyopy >= 0.21`` dependency and is - usually faster; see the documentation for where the results of the + backend requires the optional ``moyopy`` dependency and is usually + faster; see the documentation for where the results of the two backends can differ. @@ -573,8 +573,8 @@ def get_explicit_k_path_orig_cell( :param backend: the symmetry backend used to standardize the structure, either ``'spglib'`` (the default) or ``'moyopy'``. The ``'moyopy'`` - backend requires the optional ``moyopy >= 0.21`` dependency and is - usually faster; see the documentation for where the results of the + backend requires the optional ``moyopy`` dependency and is usually + faster; see the documentation for where the results of the two backends can differ. .. versionchanged:: 1.8 diff --git a/seekpath/hpkot/__init__.py b/seekpath/hpkot/__init__.py index fb4d864..8b104e9 100644 --- a/seekpath/hpkot/__init__.py +++ b/seekpath/hpkot/__init__.py @@ -13,9 +13,6 @@ the Materials Project (https://materialsproject.org). """ -# ``SymmetryDetectionError`` is re-exported here so that -# ``seekpath.hpkot.SymmetryDetectionError`` and ``seekpath.SymmetryDetectionError`` -# keep working; it is defined alongside the backends that raise it. from .backends import ( # noqa: F401 DEFAULT_BACKEND, SUPPORTED_BACKENDS, @@ -87,8 +84,8 @@ def get_path( :param backend: the symmetry backend used to standardize the structure, either ``'spglib'`` (the default) or ``'moyopy'``. The ``'moyopy'`` - backend requires the optional ``moyopy >= 0.21`` dependency and is - usually faster; see the documentation for where the results of the + backend requires the optional ``moyopy`` dependency and is usually + faster; see the documentation for where the results of the two backends can differ. diff --git a/seekpath/hpkot/backends.py b/seekpath/hpkot/backends.py index 9013306..daf3a59 100644 --- a/seekpath/hpkot/backends.py +++ b/seekpath/hpkot/backends.py @@ -13,40 +13,20 @@ The default, and a hard dependency of seekpath. ``moyopy`` - An optional backend, requiring ``moyopy >= 0.21``. moyopy is a Rust - reimplementation of spglib and is usually faster. - -.. note:: moyopy is queried with ``Setting.spglib()``, which selects the same - Hall-symbol representative that spglib uses. Together with the - conventional-cell fixes released in moyo 0.13 (orthorhombic ``a <= b <= c`` - axis ordering) and 0.17 (F-centered orthorhombic cells, settings whose axis - permutation needs an origin shift, and the monoclinic cell choice), this - makes moyopy reproduce the conventional cells that the HPKOT recipe expects. - The floor is 0.21 because, like spglib, moyopy only returns - symmetry-refined standardized cells from 0.20 on; before, they kept the - distortion of the input, which seekpath would pass on to the conventional - and primitive lattices it returns. From 0.21 on, moyopy also returns - right-handed standardized cells for a left-handed input; before, it kept - the input handedness and reported the mirror-image space group of a chiral - crystal (e.g. P4_3 for P4_1). - -.. note:: Niggli reduction is always done with spglib, for both backends, since - moyopy does not implement it. It is only used for the triclinic (aP) branch - and operates on a single 3x3 matrix, so it is not performance relevant. + An optional backend. moyopy is a Rust reimplementation of spglib and is + usually faster. It is queried with ``Setting.spglib()``, which selects the + same Hall-symbol representative that spglib uses. + +.. note:: Niggli reduction is always done with spglib, since moyopy does not + implement it. """ from dataclasses import dataclass import numpy as np -#: The backend used when the caller does not ask for a specific one. DEFAULT_BACKEND = 'spglib' - -#: The backends that :func:`get_symmetry_dataset` accepts. SUPPORTED_BACKENDS = ('spglib', 'moyopy') - -#: Oldest moyopy whose standardized cells are refined, right-handed and follow -#: the conventions HPKOT needs. MOYOPY_MIN_VERSION = '0.21' @@ -113,7 +93,6 @@ def _get_spglib_dataset(structure, symprec, angle_tolerance): """Build a :class:`SymmetryDataset` with spglib.""" from .tools import check_spglib_version, get_dot_access_dataset - # Raises a ValueError if the spglib version is too old spglib = check_spglib_version() dataset = get_dot_access_dataset( @@ -133,8 +112,6 @@ def _get_spglib_dataset(structure, symprec, angle_tolerance): std_positions=np.array(dataset.std_positions), std_types=np.array(dataset.std_types), std_rotation_matrix=np.array(dataset.std_rotation_matrix), - # spglib's transformation matrix maps the conventional cell onto the - # input one, so its determinant is already V_original / V_conventional volume_original_wrt_conv=np.linalg.det(dataset.transformation_matrix), ) @@ -154,13 +131,9 @@ def _get_moyopy_dataset(structure, symprec, angle_tolerance): dataset = moyopy.MoyoDataset( moyo_cell, symprec=symprec, - # spglib takes the angle tolerance in degrees and spells "use the - # default" as a negative value; moyopy takes radians, or None angle_tolerance=( np.deg2rad(angle_tolerance) if angle_tolerance > 0 else None ), - # Select the same Hall-symbol representative that spglib uses, - # rather than moyopy's default ITA setting setting=moyopy.Setting.spglib(), ) except Exception as exc: @@ -168,12 +141,8 @@ def _get_moyopy_dataset(structure, symprec, angle_tolerance): f'moyopy could not detect the symmetry of the system: {exc}' ) from exc - # moyopy has no `international` field; look the symbol up from the number international = moyopy.SpaceGroupType(dataset.number).hm_short.replace(' ', '') - # `std_linear` is the change of basis from the input cell to the - # conventional one, so V_original / V_conventional is the inverse of its - # determinant. As with spglib, the ratio is negative for a left-handed input volume_original_wrt_conv = 1 / np.linalg.det(np.array(dataset.std_linear)) return SymmetryDataset( @@ -209,21 +178,13 @@ def _check_moyopy_version(): ) from exc if Version(version('moyopy')) < Version(MOYOPY_MIN_VERSION): - raise ValueError( - f'Invalid moyopy version, need >= {MOYOPY_MIN_VERSION} for ' - 'symmetry-refined standardized cells that follow the conventions ' - 'the HPKOT recipe expects' - ) + raise ValueError(f'Invalid moyopy version, need >= {MOYOPY_MIN_VERSION}') return moyopy def niggli_reduce(lattice): - """Return the Niggli-reduced form of ``lattice`` (vectors as rows). - - Always uses spglib: moyopy does not implement Niggli reduction, and - spglib is a hard dependency of seekpath in any case. - """ + """Return the Niggli-reduced form of ``lattice`` (vectors as rows).""" from .tools import check_spglib_version return check_spglib_version().niggli_reduce(lattice) diff --git a/tests/test_backends.py b/tests/test_backends.py index dd02f0e..7fd0a9a 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -31,7 +31,6 @@ needs_moyopy = pytest.mark.skipif(not HAS_MOYOPY, reason='moyopy is not installed') -# Every (extended Bravais symbol, POSCAR) pair shipped as reference data REFERENCE_STRUCTURES = sorted( (os.path.basename(folder), os.path.basename(poscar).replace('POSCAR_', '')) for folder in glob.glob(os.path.join(BAND_PATH_DATA, '*')) From de5b04ddc87578a43e7db70e8630f237de4af8d1 Mon Sep 17 00:00:00 2001 From: Timo Reents Date: Tue, 29 Sep 2026 16:39:45 +0200 Subject: [PATCH 3/4] Clean-up and simplification --- seekpath/hpkot/__init__.py | 6 +++--- seekpath/hpkot/backends.py | 29 ++++++++++++----------------- tests/test_backends.py | 16 +++++----------- 3 files changed, 20 insertions(+), 31 deletions(-) diff --git a/seekpath/hpkot/__init__.py b/seekpath/hpkot/__init__.py index 8b104e9..834c472 100644 --- a/seekpath/hpkot/__init__.py +++ b/seekpath/hpkot/__init__.py @@ -15,7 +15,6 @@ from .backends import ( # noqa: F401 DEFAULT_BACKEND, - SUPPORTED_BACKENDS, SymmetryDetectionError, ) @@ -142,6 +141,7 @@ def get_path( import warnings import numpy as np + import spglib from .tools import ( extend_kparam, @@ -152,7 +152,7 @@ def get_path( get_reciprocal_cell_rows, get_real_cell_from_reciprocal_rows, ) - from .backends import get_symmetry_dataset, niggli_reduce + from .backends import get_symmetry_dataset from .spg_mapping import get_spgroup_data, get_primitive structure_internal = ( @@ -313,7 +313,7 @@ def get_path( # I use the default eps here, this could be changed reciprocal_cell_orig = get_reciprocal_cell_rows(conv_lattice) ## This is Niggli-reduced - reciprocal_cell2 = niggli_reduce(reciprocal_cell_orig) + reciprocal_cell2 = spglib.niggli_reduce(reciprocal_cell_orig) real_cell2 = get_real_cell_from_reciprocal_rows(reciprocal_cell2) # TODO: get transformation matrix? diff --git a/seekpath/hpkot/backends.py b/seekpath/hpkot/backends.py index daf3a59..ed512ec 100644 --- a/seekpath/hpkot/backends.py +++ b/seekpath/hpkot/backends.py @@ -16,17 +16,14 @@ An optional backend. moyopy is a Rust reimplementation of spglib and is usually faster. It is queried with ``Setting.spglib()``, which selects the same Hall-symbol representative that spglib uses. - -.. note:: Niggli reduction is always done with spglib, since moyopy does not - implement it. """ from dataclasses import dataclass +from functools import lru_cache import numpy as np DEFAULT_BACKEND = 'spglib' -SUPPORTED_BACKENDS = ('spglib', 'moyopy') MOYOPY_MIN_VERSION = '0.21' @@ -80,13 +77,13 @@ def get_symmetry_dataset( :raise SymmetryDetectionError: if the backend could not detect the symmetry of the structure. """ - if backend == 'spglib': - return _get_spglib_dataset(structure, symprec, angle_tolerance) - if backend == 'moyopy': - return _get_moyopy_dataset(structure, symprec, angle_tolerance) - raise ValueError( - f"Unknown symmetry backend '{backend}', should be one of {SUPPORTED_BACKENDS}" - ) + try: + get_dataset = _BACKENDS[backend] + except KeyError: + raise ValueError( + f"Unknown symmetry backend '{backend}', should be one of {SUPPORTED_BACKENDS}" + ) from None + return get_dataset(structure, symprec, angle_tolerance) def _get_spglib_dataset(structure, symprec, angle_tolerance): @@ -107,7 +104,7 @@ def _get_spglib_dataset(structure, symprec, angle_tolerance): return SymmetryDataset( number=dataset.number, - international=dataset.international.replace(' ', ''), + international=dataset.international, std_lattice=np.array(dataset.std_lattice), std_positions=np.array(dataset.std_positions), std_types=np.array(dataset.std_types), @@ -156,6 +153,7 @@ def _get_moyopy_dataset(structure, symprec, angle_tolerance): ) +@lru_cache(maxsize=None) def _check_moyopy_version(): """Import moyopy, checking it is recent enough. @@ -183,8 +181,5 @@ def _check_moyopy_version(): return moyopy -def niggli_reduce(lattice): - """Return the Niggli-reduced form of ``lattice`` (vectors as rows).""" - from .tools import check_spglib_version - - return check_spglib_version().niggli_reduce(lattice) +_BACKENDS = {'spglib': _get_spglib_dataset, 'moyopy': _get_moyopy_dataset} +SUPPORTED_BACKENDS = tuple(_BACKENDS) diff --git a/tests/test_backends.py b/tests/test_backends.py index 7fd0a9a..f64251f 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -15,7 +15,6 @@ from seekpath import hpkot from seekpath.hpkot.backends import ( DEFAULT_BACKEND, - SUPPORTED_BACKENDS, SymmetryDetectionError, get_symmetry_dataset, ) @@ -64,7 +63,9 @@ def test_unknown_backend_raises(self): with pytest.raises(ValueError, match="Unknown symmetry backend 'nope'"): hpkot.get_path(structure, backend='nope') - @pytest.mark.parametrize('backend', SUPPORTED_BACKENDS) + @pytest.mark.parametrize( + 'backend', ['spglib', pytest.param('moyopy', marks=needs_moyopy)] + ) def test_symmetry_detection_error(self, backend): """Both backends report a failed detection the same way. @@ -72,8 +73,6 @@ def test_symmetry_detection_error(self, backend): be analysed, and must raise ``SymmetryDetectionError`` whichever backend is used, rather than the backend's own exception type. """ - if backend == 'moyopy' and not HAS_MOYOPY: - pytest.skip('moyopy is not installed') degenerate = ( [[4.0, 0.0, 0.0], [4.0, 0.0, 0.0], [0.0, 0.0, 4.0]], [[0.0, 0.0, 0.0]], @@ -193,7 +192,7 @@ def test_explicit_k_path_agrees(self, case): assert ( res_spglib['explicit_kpoints_labels'] - == (res_moyopy['explicit_kpoints_labels']) + == res_moyopy['explicit_kpoints_labels'] ) np.testing.assert_allclose( res_spglib['explicit_kpoints_rel'], @@ -226,12 +225,7 @@ def _symmetry_equivalent(points_a, points_b, rotations): candidates += [-candidate for candidate in candidates] for candidate in candidates: - for matrix in ( - candidate, - candidate.T, - np.linalg.inv(candidate), - np.linalg.inv(candidate).T, - ): + for matrix in (candidate, candidate.T): difference = k_a @ matrix - k_b # Two k-points differing by a reciprocal lattice vector are equal difference -= np.round(difference) From 2f34ad1cc51deaacbec20e4f35d1d9c838a2a807 Mon Sep 17 00:00:00 2001 From: Timo Reents Date: Tue, 29 Sep 2026 17:03:57 +0200 Subject: [PATCH 4/4] Update requirements --- docs/source/maindoc.rst | 3 ++- pyproject.toml | 2 +- seekpath/hpkot/backends.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/source/maindoc.rst b/docs/source/maindoc.rst index 0b6aaf3..6ab15cb 100644 --- a/docs/source/maindoc.rst +++ b/docs/source/maindoc.rst @@ -71,7 +71,8 @@ Rust reimplementation of spglib:: seekpath.get_path(structure, backend='moyopy') -``moyopy`` is an optional dependency, installed with:: +``moyopy`` is an optional dependency that requires Python >= 3.10, installed +with:: pip install seekpath[moyopy] diff --git a/pyproject.toml b/pyproject.toml index cce0a0c..423e8cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ bz = [ "scipy>=1", ] moyopy = [ - "moyopy>=0.21", + "moyopy>=0.21; python_version >= '3.10'", ] dev = [ "black==23.3.0", diff --git a/seekpath/hpkot/backends.py b/seekpath/hpkot/backends.py index ed512ec..d01141b 100644 --- a/seekpath/hpkot/backends.py +++ b/seekpath/hpkot/backends.py @@ -172,7 +172,7 @@ def _check_moyopy_version(): raise ValueError( f'moyopy >= {MOYOPY_MIN_VERSION} is required for the ' "'moyopy' backend, but it could not be imported. Install it " - 'with `pip install seekpath[moyopy]`' + 'with `pip install seekpath[moyopy]` (requires Python >= 3.10)' ) from exc if Version(version('moyopy')) < Version(MOYOPY_MIN_VERSION):