diff --git a/.github/requirements-3.10.txt b/.github/requirements-3.10.txt index 33faa80b..2017ec7a 100644 --- a/.github/requirements-3.10.txt +++ b/.github/requirements-3.10.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --python-version=3.10 pyproject.toml tests/requirements.in +# uv --allow-python-downloads pip compile --python-version=3.10 pyproject.toml requirements-sympy.txt tests/requirements.in ase==3.24.0 # via -r tests/requirements.in contourpy==1.3.1 @@ -8,11 +8,11 @@ cycler==0.12.1 # via matplotlib exceptiongroup==1.2.2 # via pytest -fonttools==4.56.0 +fonttools==4.57.0 # via matplotlib -gemmi==0.7.0 +gemmi==0.7.1 # via -r tests/requirements.in -iniconfig==2.0.0 +iniconfig==2.1.0 # via pytest kiwisolver==1.4.8 # via matplotlib @@ -20,7 +20,9 @@ matplotlib==3.10.1 # via ase more-itertools==10.6.0 # via parsnip-cif (pyproject.toml) -numpy==2.2.3 +mpmath==1.3.0 + # via sympy +numpy==2.2.4 # via # parsnip-cif (pyproject.toml) # ase @@ -39,9 +41,11 @@ pluggy==1.5.0 # via pytest ply==3.11 # via pycifrw -pycifrw==4.4.6 +prettytable==3.16.0 + # via pycifrw +pycifrw==5.0.1 # via -r tests/requirements.in -pyparsing==3.2.1 +pyparsing==3.2.3 # via matplotlib pytest==8.3.5 # via @@ -55,5 +59,11 @@ scipy==1.15.2 # via ase six==1.17.0 # via python-dateutil +sympy==1.13.3 + # via + # -r requirements-sympy.txt + # -r tests/requirements.in tomli==2.2.1 # via pytest +wcwidth==0.2.13 + # via prettytable diff --git a/.github/requirements-3.11.txt b/.github/requirements-3.11.txt index e2c19975..da9408e8 100644 --- a/.github/requirements-3.11.txt +++ b/.github/requirements-3.11.txt @@ -1,16 +1,16 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --python-version=3.11 pyproject.toml tests/requirements.in +# uv --allow-python-downloads pip compile --python-version=3.11 pyproject.toml requirements-sympy.txt tests/requirements.in ase==3.24.0 # via -r tests/requirements.in contourpy==1.3.1 # via matplotlib cycler==0.12.1 # via matplotlib -fonttools==4.56.0 +fonttools==4.57.0 # via matplotlib -gemmi==0.7.0 +gemmi==0.7.1 # via -r tests/requirements.in -iniconfig==2.0.0 +iniconfig==2.1.0 # via pytest kiwisolver==1.4.8 # via matplotlib @@ -18,7 +18,9 @@ matplotlib==3.10.1 # via ase more-itertools==10.6.0 # via parsnip-cif (pyproject.toml) -numpy==2.2.3 +mpmath==1.3.0 + # via sympy +numpy==2.2.4 # via # parsnip-cif (pyproject.toml) # ase @@ -37,9 +39,11 @@ pluggy==1.5.0 # via pytest ply==3.11 # via pycifrw -pycifrw==4.4.6 +prettytable==3.16.0 + # via pycifrw +pycifrw==5.0.1 # via -r tests/requirements.in -pyparsing==3.2.1 +pyparsing==3.2.3 # via matplotlib pytest==8.3.5 # via @@ -53,3 +57,9 @@ scipy==1.15.2 # via ase six==1.17.0 # via python-dateutil +sympy==1.13.3 + # via + # -r requirements-sympy.txt + # -r tests/requirements.in +wcwidth==0.2.13 + # via prettytable diff --git a/.github/requirements-3.12.txt b/.github/requirements-3.12.txt index 1ce14145..b215e5d3 100644 --- a/.github/requirements-3.12.txt +++ b/.github/requirements-3.12.txt @@ -1,16 +1,16 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --python-version=3.12 pyproject.toml tests/requirements.in +# uv --allow-python-downloads pip compile --python-version=3.12 pyproject.toml requirements-sympy.txt tests/requirements.in ase==3.24.0 # via -r tests/requirements.in contourpy==1.3.1 # via matplotlib cycler==0.12.1 # via matplotlib -fonttools==4.56.0 +fonttools==4.57.0 # via matplotlib -gemmi==0.7.0 +gemmi==0.7.1 # via -r tests/requirements.in -iniconfig==2.0.0 +iniconfig==2.1.0 # via pytest kiwisolver==1.4.8 # via matplotlib @@ -18,7 +18,9 @@ matplotlib==3.10.1 # via ase more-itertools==10.6.0 # via parsnip-cif (pyproject.toml) -numpy==2.2.3 +mpmath==1.3.0 + # via sympy +numpy==2.2.4 # via # parsnip-cif (pyproject.toml) # ase @@ -37,9 +39,11 @@ pluggy==1.5.0 # via pytest ply==3.11 # via pycifrw -pycifrw==4.4.6 +prettytable==3.16.0 + # via pycifrw +pycifrw==5.0.1 # via -r tests/requirements.in -pyparsing==3.2.1 +pyparsing==3.2.3 # via matplotlib pytest==8.3.5 # via @@ -53,3 +57,9 @@ scipy==1.15.2 # via ase six==1.17.0 # via python-dateutil +sympy==1.13.3 + # via + # -r requirements-sympy.txt + # -r tests/requirements.in +wcwidth==0.2.13 + # via prettytable diff --git a/.github/requirements-3.13.txt b/.github/requirements-3.13.txt index edb13ea2..8d6c11aa 100644 --- a/.github/requirements-3.13.txt +++ b/.github/requirements-3.13.txt @@ -1,16 +1,16 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --python-version=3.13 pyproject.toml tests/requirements.in +# uv --allow-python-downloads pip compile --python-version=3.13 pyproject.toml requirements-sympy.txt tests/requirements.in ase==3.24.0 # via -r tests/requirements.in contourpy==1.3.1 # via matplotlib cycler==0.12.1 # via matplotlib -fonttools==4.56.0 +fonttools==4.57.0 # via matplotlib -gemmi==0.7.0 +gemmi==0.7.1 # via -r tests/requirements.in -iniconfig==2.0.0 +iniconfig==2.1.0 # via pytest kiwisolver==1.4.8 # via matplotlib @@ -18,7 +18,9 @@ matplotlib==3.10.1 # via ase more-itertools==10.6.0 # via parsnip-cif (pyproject.toml) -numpy==2.2.3 +mpmath==1.3.0 + # via sympy +numpy==2.2.4 # via # parsnip-cif (pyproject.toml) # ase @@ -37,9 +39,11 @@ pluggy==1.5.0 # via pytest ply==3.11 # via pycifrw -pycifrw==4.4.6 +prettytable==3.16.0 + # via pycifrw +pycifrw==5.0.1 # via -r tests/requirements.in -pyparsing==3.2.1 +pyparsing==3.2.3 # via matplotlib pytest==8.3.5 # via @@ -53,3 +57,9 @@ scipy==1.15.2 # via ase six==1.17.0 # via python-dateutil +sympy==1.13.3 + # via + # -r requirements-sympy.txt + # -r tests/requirements.in +wcwidth==0.2.13 + # via prettytable diff --git a/.github/requirements-3.8.txt b/.github/requirements-3.8.txt index d753427e..e5b831db 100644 --- a/.github/requirements-3.8.txt +++ b/.github/requirements-3.8.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --python-version=3.8 pyproject.toml tests/requirements.in +# uv --allow-python-downloads pip compile --python-version=3.8 pyproject.toml requirements-sympy.txt tests/requirements.in ase==3.23.0 # via -r tests/requirements.in contourpy==1.1.1 @@ -8,23 +8,25 @@ cycler==0.12.1 # via matplotlib exceptiongroup==1.2.2 # via pytest -fonttools==4.55.3 +fonttools==4.57.0 # via matplotlib -gemmi==0.7.0 +gemmi==0.7.1 # via -r tests/requirements.in importlib-resources==6.4.5 # via matplotlib -iniconfig==2.0.0 +iniconfig==2.1.0 # via pytest kiwisolver==1.4.7 # via matplotlib matplotlib==3.7.5 # via ase more-itertools==10.5.0 - # via parsnip (pyproject.toml) + # via parsnip-cif (pyproject.toml) +mpmath==1.3.0 + # via sympy numpy==1.24.4 # via - # parsnip (pyproject.toml) + # parsnip-cif (pyproject.toml) # ase # contourpy # matplotlib @@ -41,11 +43,13 @@ pluggy==1.5.0 # via pytest ply==3.11 # via pycifrw -pycifrw==4.4.6 +prettytable==3.11.0 + # via pycifrw +pycifrw==5.0.1 # via -r tests/requirements.in pyparsing==3.1.4 # via matplotlib -pytest==8.3.4 +pytest==8.3.5 # via # -r tests/requirements.in # pytest-doctestplus @@ -57,7 +61,13 @@ scipy==1.10.1 # via ase six==1.17.0 # via python-dateutil +sympy==1.13.3 + # via + # -r requirements-sympy.txt + # -r tests/requirements.in tomli==2.2.1 # via pytest +wcwidth==0.2.13 + # via prettytable zipp==3.20.2 # via importlib-resources diff --git a/.github/requirements-3.9.txt b/.github/requirements-3.9.txt index 6a81d63e..efed36e6 100644 --- a/.github/requirements-3.9.txt +++ b/.github/requirements-3.9.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile --python-version=3.9 pyproject.toml tests/requirements.in +# uv --allow-python-downloads pip compile --python-version=3.9 pyproject.toml requirements-sympy.txt tests/requirements.in ase==3.24.0 # via -r tests/requirements.in contourpy==1.3.0 @@ -8,13 +8,13 @@ cycler==0.12.1 # via matplotlib exceptiongroup==1.2.2 # via pytest -fonttools==4.56.0 +fonttools==4.57.0 # via matplotlib -gemmi==0.7.0 +gemmi==0.7.1 # via -r tests/requirements.in importlib-resources==6.5.2 # via matplotlib -iniconfig==2.0.0 +iniconfig==2.1.0 # via pytest kiwisolver==1.4.7 # via matplotlib @@ -22,6 +22,8 @@ matplotlib==3.9.4 # via ase more-itertools==10.6.0 # via parsnip-cif (pyproject.toml) +mpmath==1.3.0 + # via sympy numpy==2.0.2 # via # parsnip-cif (pyproject.toml) @@ -41,9 +43,11 @@ pluggy==1.5.0 # via pytest ply==3.11 # via pycifrw -pycifrw==4.4.6 +prettytable==3.16.0 + # via pycifrw +pycifrw==5.0.1 # via -r tests/requirements.in -pyparsing==3.2.1 +pyparsing==3.2.3 # via matplotlib pytest==8.3.5 # via @@ -57,7 +61,13 @@ scipy==1.13.1 # via ase six==1.17.0 # via python-dateutil +sympy==1.13.3 + # via + # -r requirements-sympy.txt + # -r tests/requirements.in tomli==2.2.1 # via pytest +wcwidth==0.2.13 + # via prettytable zipp==3.21.0 # via importlib-resources diff --git a/.github/workflows/update-uv-lockfiles.sh b/.github/workflows/update-uv-lockfiles.sh index e52abe2e..8a436e05 100755 --- a/.github/workflows/update-uv-lockfiles.sh +++ b/.github/workflows/update-uv-lockfiles.sh @@ -1,5 +1,5 @@ #!/bin/bash -for version in 3.{9..13}; do - uv pip compile --python-version="$version" pyproject.toml tests/requirements.in > ".github/requirements-$version.txt" +for version in 3.{8..13}; do + uv pip compile --python-version="$version" pyproject.toml requirements-sympy.txt tests/requirements.in > ".github/requirements-$version.txt" done diff --git a/README.rst b/README.rst index 1d9d92f8..91b59913 100644 --- a/README.rst +++ b/README.rst @@ -75,7 +75,8 @@ additional dependencies are required to run the tests and build the docs. .. code:: bash pip install . # Install with no additional dependencies - pip install .[tests] # Install with dependencies required to run tests + pip install .[sympy] # Install with sympy for symbolic unit cell math + pip install .[tests] # Install with dependencies required to run tests (including sympy) pip install .[tests,doc] # Install with dependencies required to run tests and make docs Dependencies diff --git a/changelog.rst b/changelog.rst index 507d5520..9d15bdbf 100644 --- a/changelog.rst +++ b/changelog.rst @@ -10,11 +10,19 @@ v0.X.X - 20XX-XX-XX Added ~~~~~ - Additional testpath flag to conftest +- Symbolic parsing mode for ``build_unit_cell`` + +Changed +~~~~~~~ +- ``build_unit_cell`` now uses sympy by default if it is intalled - otherwise, it falls + back to the previous variant Fixed ~~~~~ - Accessing data pairs with ``get_from_pairs`` or ``__getitem__`` now allows for case-insensitive searches - Quote-delimited strings containing the delimiting character are now parsed properly +- ``build_unit_cell`` now rounds coordinates before wrapping into the box, fixing edge cases + where boundary atoms were not properly deduplicated v0.2.1 - 2025-03-12 ------------------- diff --git a/parsnip/__init__.py b/parsnip/__init__.py index 4dc8133e..6436b65f 100644 --- a/parsnip/__init__.py +++ b/parsnip/__init__.py @@ -3,7 +3,6 @@ """``parsnip``: a package for the simple reading and processing of .cif files.""" -# from . import parse, patterns, unitcells from .parsnip import CifFile __version__ = "0.2.1" diff --git a/parsnip/parsnip.py b/parsnip/parsnip.py index cd9d5d28..37e0b217 100644 --- a/parsnip/parsnip.py +++ b/parsnip/parsnip.py @@ -72,6 +72,7 @@ from collections.abc import Iterable from fnmatch import filter as fnfilter from fnmatch import fnmatch +from importlib.util import find_spec from pathlib import Path from typing import ClassVar @@ -157,6 +158,8 @@ def __init__(self, fn: str | Path, cast_values: bool = False): with open(fn) as file: self._parse(peekable(file)) + _SYMPY_AVAILABLE = find_spec("sympy") is not None + @property def pairs(self): """A dict containing key-value pairs extracted from the file. @@ -482,6 +485,7 @@ def build_unit_cell( self, n_decimal_places: int = 4, additional_columns: str | Iterable[str] | None = None, + parse_mode: str = "python_float", verbose: bool = False, ): """Reconstruct fractional atomic positions from Wyckoff sites and symops. @@ -493,6 +497,14 @@ def build_unit_cell( possible atomic sites. These are then wrapped into the unit cell and filtered for uniqueness to yield the final crystal. + .. tip:: + + If the parsed unit cell has more atoms than expected, decrease + ``n_decimal_places`` to account for noise. If the unit cell has fewer atoms + than expected, increase ``n_decimal_places`` to ensure atoms are compared + with sufficient precision. In many cases, setting ``parse_mode='sympy'`` + can improve the accuracy of reconstructed unit cells. + Example ------- Construct the atomic positions of the FCC unit cell from its Wyckoff sites: @@ -524,13 +536,18 @@ def build_unit_cell( ---------- n_decimal_places : int, optional The number of decimal places to round each position to for the - uniqueness comparison. Values higher than 4 may not work for all CIF - files. Default value = ``4`` + uniqueness comparison. Ideally this should be set to the number of + decimal places included in the CIF file, but ``3`` and ``4`` work in + most cases. Default value = ``4`` additional_columns : str | typing.Iterable[str] | None, optional A column name or list of column names from the loop containing the Wyckoff site positions. This data is replicated alongside the atomic coordinates and returned in an auxiliary array. Default value = ``None`` + parse_mode : {'sympy', 'python_float'}, optional + Whether to parse lattice sites symbolically (``parse_mode='sympy'``) or + numerically (``parse_mode='python_float'``). Sympy is typically more + accurate, but may be slower. Default value = ``'python_float'`` verbose : bool, optional Whether to print debug information about the uniqueness checks. Default value = ``False`` @@ -547,17 +564,19 @@ def build_unit_cell( ValueError If the ``additional_columns`` are not properly associated with the Wyckoff positions. - - - .. caution:: - - Reconstructing positions requires several floating point calculations that - can be impacted by low-precision data in CIF files. Typically, at least four - decimal places are required to accurately reconstruct complicated unit - cells: less precision than this can yield cells with duplicate or missing - positions. + ImportError + If ``parse_mode='sympy'`` and Sympy is not installed. """ - symops, fractional_positions = self.symops, self.wyckoff_positions + if parse_mode == "sympy" and not self.__class__._SYMPY_AVAILABLE: + raise ImportError( + "Sympy is not available! Please set parse_mode='python_float' " + "or install sympy." + ) + valid_modes = {"sympy", "python_float"} + if parse_mode not in valid_modes: + raise ValueError(f"Parse mode '{parse_mode}' not in {valid_modes}.") + + symops = self.symops if additional_columns is not None: # Find the table of Wyckoff positions and compare to keys in additional_data @@ -581,16 +600,21 @@ def build_unit_cell( symops, separator=",", threshold=np.inf, floatmode="unique" ) + frac_strs = self.get_from_loops(self.__class__._WYCKOFF_KEYS) + all_frac_positions = [ - _safe_eval(symops_str, *xyz) for xyz in fractional_positions + _safe_eval(symops_str, *xyz, parse_mode=parse_mode) for xyz in frac_strs ] # Compute N_symmetry_elements coordinates for each Wyckoff site pos = np.vstack(all_frac_positions) - pos %= 1 # Wrap particles into the box - # Filter unique points. TODO: support arbitrary precision, fractional sites + # Wrap into box - works generally because these are fractional coordinates + unrounded_pos = pos.copy() % 1 + pos = pos.round(n_decimal_places) % 1 + + # Filter unique points _, unique_fractional, unique_counts = np.unique( - pos.round(n_decimal_places), return_index=True, return_counts=True, axis=0 + pos, return_index=True, return_counts=True, axis=0 ) # Double-check for duplicates with real space coordinates @@ -605,7 +629,6 @@ def build_unit_cell( # Merge unique points from realspace and fractional calculations unique_indices = sorted({*unique_fractional} & {*unique_realspace}) - # TODO: Reintroduce AFLOW test suite if verbose: _write_debug_output( @@ -616,13 +639,13 @@ def build_unit_cell( ) if additional_columns is None: - return pos[unique_indices] + return unrounded_pos[unique_indices] tiled_data = np.repeat( self.get_from_loops(additional_columns), len(symops), axis=0 ) - return tiled_data[unique_indices], pos[unique_indices] + return tiled_data[unique_indices], unrounded_pos[unique_indices] @property def box(self): @@ -664,7 +687,6 @@ def lattice_vectors(self): r"""The lattice vectors of the unit cell, with :math:`\vec{a_1}\perp[100]`. .. important:: - The lattice vectors are stored as *columns* of the returned matrix, similar to `freud to_matrix()`_. This matrix must be transposed when creating a Freud box or transforming fractional coordinates to absolute. diff --git a/parsnip/patterns.py b/parsnip/patterns.py index 4f4e7e57..b2fb56f5 100644 --- a/parsnip/patterns.py +++ b/parsnip/patterns.py @@ -26,12 +26,35 @@ _bracket_pattern = re.compile(r"(\[|\])") +def _reformat_sublist(s: str) -> str: + return f"[{s.lstrip('[').rstrip(']')}]" + + def _flatten_or_none(ls: list): """Return the sole element from a list of l=1, None if l=0, else l.""" return None if not ls else ls[0] if len(ls) == 1 else ls -def _safe_eval(str_input: str, x: int | float, y: int | float, z: int | float): +def _sympy_evaluate_array(arr: str) -> list[list[float]]: + from sympy import Rational, sympify + + one = Rational(1) + return [ + [ + float(sympify(coord, rational=True, locals={}) % one) + for coord in ls.strip("]").strip("[").split(",") + ] + for ls in arr.split("],") + ] + + +def _safe_eval( + str_input: str, + x: int | float, + y: int | float, + z: int | float, + parse_mode: str = "python_float", +): """Attempt to safely evaluate a string of symmetry equivalent positions. Python's ``eval`` is notoriously unsafe. While we could evaluate the entire list at @@ -62,22 +85,27 @@ def _safe_eval(str_input: str, x: int | float, y: int | float, z: int | float): :math:`(N,3)` list of fractional coordinates. """ - ordered_inputs = {"x": "{0:.20f}", "y": "{1:.20f}", "z": "{2:.20f}"} + ordered_inputs = {"x": "{0}", "y": "{1}", "z": "{2}"} # Replace any x, y, or z with the same character surrounded by curly braces. Then, # perform substitutions to insert the actual values. substituted_string = ( re.sub(r"([xyz])", r"{\1}", str_input).format(**ordered_inputs).format(x, y, z) ) - # Remove any unexpected characters from the string. - safe_string = re.sub(r"[^\d\[\]\,\+\-\/\*\.]", "", substituted_string) + # Remove any unexpected characters from the string, including precision specifiers. + safe_string = re.sub(r"(\(\d+\))|[^\d\[\]\,\+\-\/\*\.]", "", substituted_string) + # Double check to be sure: assert all(char in ",.0123456789+-/*[]" for char in safe_string), ( "Evaluation aborted. Check that symmetry operation string only contains " "numerics or characters in { [],.+-/ } and adjust `regex_filter` param " "accordingly." ) - return eval(safe_string, {"__builtins__": {}}, {}) # noqa: S307 + if parse_mode == "sympy": + return _sympy_evaluate_array(safe_string) + if parse_mode == "python_float": + return eval(safe_string, {"__builtins__": {}}, {}) # noqa: S307 + raise ValueError(f"Unknown parse mode '{parse_mode}' was provided!") def _write_debug_output(unique_indices, unique_counts, pos, check="Initial"): diff --git a/pyproject.toml b/pyproject.toml index 396317a9..22a8b249 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,9 @@ Issues = "https://github.com/glotzerlab/parsnip/issues" packages=["parsnip"] [tool.setuptools.dynamic] -optional-dependencies = {tests = { file = ["tests/requirements.in"] }, doc = { file = ["doc/requirements.txt"] }} +optional-dependencies.tests = { file = ["tests/requirements.in"] } +optional-dependencies.doc = { file = ["doc/requirements.txt"] } +optional-dependencies.sympy = { file = ["requirements-sympy.txt"] } [tool.pytest.ini_options] testpaths = ["tests", "parsnip", "doc"] diff --git a/requirements-sympy.txt b/requirements-sympy.txt new file mode 100644 index 00000000..ded0ee75 --- /dev/null +++ b/requirements-sympy.txt @@ -0,0 +1 @@ +sympy diff --git a/tests/conftest.py b/tests/conftest.py index e95b0771..898614df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -178,6 +178,13 @@ def random_keys_mark(n_samples=10): file=CifFile(data_file_path + "PDB_4INS_head.cif"), ) +structure_issue_42 = CifData( + filename=data_file_path + "no42.cif", + symop_keys=("_symmetry_equiv_pos_as_xyz",), + atom_site_keys=atom_site_keys[:-1], + file=CifFile(data_file_path + "no42.cif"), +) + bad_cif = CifData( filename=data_file_path + "INTENTIONALLY_BAD_CIF.cif", symop_keys=("_space_group_symop_id", "_space_group_symop_operation_xyz"), @@ -211,6 +218,7 @@ def random_keys_mark(n_samples=10): cod_aP16, izasc_gismondine, pdb_4INS, + structure_issue_42, ] cif_files_mark = pytest.mark.parametrize( argnames="cif_data", diff --git a/tests/requirements.in b/tests/requirements.in index ad3103c9..30fa2a72 100644 --- a/tests/requirements.in +++ b/tests/requirements.in @@ -1,5 +1,6 @@ ase gemmi -pycifrw<5.0.0 +pycifrw!=5.0.0 pytest pytest-doctestplus +sympy diff --git a/tests/sample_data/no42.cif b/tests/sample_data/no42.cif new file mode 100644 index 00000000..e073c9d7 --- /dev/null +++ b/tests/sample_data/no42.cif @@ -0,0 +1,63 @@ +data_ATO +_cell_length_a 20.9140(0) +_cell_length_b 20.9140(0) +_cell_length_c 5.0610(0) +_cell_angle_alpha 90.0000(0) +_cell_angle_beta 90.0000(0) +_cell_angle_gamma 120.0000(0) + +_symmetry_space_group_name_H-M 'R -3 m' +_symmetry_Int_Tables_number 166 +_symmetry_cell_setting trigonal + +loop_ +_symmetry_equiv_pos_as_xyz +'+x,+y,+z' +'2/3+x,1/3+y,1/3+z' +'1/3+x,2/3+y,2/3+z' +'-y,+x-y,+z' +'2/3-y,1/3+x-y,1/3+z' +'1/3-y,2/3+x-y,2/3+z' +'-x+y,-x,+z' +'2/3-x+y,1/3-x,1/3+z' +'1/3-x+y,2/3-x,2/3+z' +'-y,-x,+z' +'2/3-y,1/3-x,1/3+z' +'1/3-y,2/3-x,2/3+z' +'-x+y,+y,+z' +'2/3-x+y,1/3+y,1/3+z' +'1/3-x+y,2/3+y,2/3+z' +'+x,+x-y,+z' +'2/3+x,1/3+x-y,1/3+z' +'1/3+x,2/3+x-y,2/3+z' +'-x,-y,-z' +'2/3-x,1/3-y,1/3-z' +'1/3-x,2/3-y,2/3-z' +'+y,-x+y,-z' +'2/3+y,1/3-x+y,1/3-z' +'1/3+y,2/3-x+y,2/3-z' +'+x-y,+x,-z' +'2/3+x-y,1/3+x,1/3-z' +'1/3+x-y,2/3+x,2/3-z' +'+y,+x,-z' +'2/3+y,1/3+x,1/3-z' +'1/3+y,2/3+x,2/3-z' +'+x-y,-y,-z' +'2/3+x-y,1/3-y,1/3-z' +'1/3+x-y,2/3-y,2/3-z' +'-x,-x+y,-z' +'2/3-x,1/3-x+y,1/3-z' +'1/3-x,2/3-x+y,2/3-z' + +loop_ +_atom_site_label +_atom_site_type_symbol +_atom_site_fract_x +_atom_site_fract_y +_atom_site_fract_z +_atom_site_occupancy +O1 O 0.6667 0.1347 0.8333 1.000 +O2 O 0.5760 0.0000 0.0000 1.000 +O3 O 0.5544 0.1089 0.1382 1.000 +O4 O 0.6667 0.1006 0.3333 1.000 +T1 Si 0.6160 0.0861 0.0763 1.000 diff --git a/tests/test_table_reader.py b/tests/test_table_reader.py index 7f394295..155688ca 100644 --- a/tests/test_table_reader.py +++ b/tests/test_table_reader.py @@ -64,7 +64,9 @@ def test_read_atom_sites(cif_data): np.testing.assert_array_equal(parsnip_data, gemmi_data) assert (key in cif_data.file.loop_labels for key in cif_data.atom_site_keys) - if not any(s in cif_data.filename for s in ["CCDC", "PDB", "AMCSD", "zeolite"]): + if not any( + s in cif_data.filename for s in ["CCDC", "PDB", "AMCSD", "zeolite", "no42"] + ): import sys if sys.version_info < (3, 8): diff --git a/tests/test_unitcells.py b/tests/test_unitcells.py index 43677b6e..27fa2fed 100644 --- a/tests/test_unitcells.py +++ b/tests/test_unitcells.py @@ -2,6 +2,7 @@ import warnings from contextlib import nullcontext from glob import glob +from importlib.util import find_spec import numpy as np import pytest @@ -66,8 +67,19 @@ def test_read_symmetry_operations(cif_data): np.testing.assert_array_equal(parsnip_data, gemmi_data) +@cif_files_mark +def test_build_unit_cell_errors(cif_data): + cif_data.file.__class__._SYMPY_AVAILABLE = False + with pytest.raises(ImportError, match="Sympy is not available!"): + cif_data.file.build_unit_cell(parse_mode="sympy") + cif_data.file.__class__._SYMPY_AVAILABLE = find_spec("sympy") is not None + with pytest.raises(ValueError, match="Parse mode 'asdf'"): + cif_data.file.build_unit_cell(parse_mode="asdf") + + @cif_files_mark @pytest.mark.parametrize("n_decimal_places", [3, 4, 6, 9]) +@pytest.mark.parametrize("parse_mode", ["python_float", "sympy"]) @pytest.mark.parametrize( "cols", [ @@ -76,10 +88,12 @@ def test_read_symmetry_operations(cif_data): ["_atom_site_type_symbol", "_atom_site_occupancy"], ], ) -def test_build_unit_cell(cif_data, n_decimal_places, cols): +def test_build_unit_cell(cif_data, n_decimal_places, parse_mode, cols): warnings.filterwarnings("ignore", "crystal system", category=UserWarning) - if "PDB_4INS_head.cif" in cif_data.filename: + if "PDB_4INS_head.cif" in cif_data.filename or ( + "no42.cif" in cif_data.filename and n_decimal_places > 3 + ): return should_raise = cols is not None and any( @@ -92,7 +106,9 @@ def test_build_unit_cell(cif_data, n_decimal_places, cols): else nullcontext() ): read_data = cif_data.file.build_unit_cell( - n_decimal_places=n_decimal_places, additional_columns=cols + n_decimal_places=n_decimal_places, + additional_columns=cols, + parse_mode=parse_mode, ) if read_data is None: @@ -134,8 +150,8 @@ def test_build_unit_cell(cif_data, n_decimal_places, cols): ) np.testing.assert_equal(che_symbols[mask], ase_symbols[mask]) - if "zeolite" in cif_data.filename: - return # Four decimal places not sufficient to reconstruct this structure + if "zeolite" in cif_data.filename or "no42" in cif_data.filename: + return # Reconstructed with different wrapping? np.testing.assert_allclose(parsnip_positions, ase_positions, atol=1e-12) @@ -159,11 +175,26 @@ def test_invalid_unit_cell(cif_data): @pytest.mark.skipif(ADDITIONAL_TEST_FILES_PATH == "", reason="No test path provided.") @pytest.mark.parametrize("filename", glob(ADDITIONAL_TEST_FILES_PATH)) -def test_build_accuracy(filename): - def n_from_pearson(p: str) -> int: - return int(re.sub(r"[^\w]", "", p)[2:]) +@pytest.mark.parametrize("n_decimal_places", [3, 4]) +def test_build_accuracy(filename, n_decimal_places): + if "A5B10C8D4_mC108_15_a2ef_5f_4f_2f.cif" in filename or ( + "A12B36CD12_cF488_210" in filename and n_decimal_places == 4 + ): + pytest.xfail(reason="Known failing structure found.") + + def parse_pearson(p) -> tuple[bool, int]: + if p is None: + return (False, -1) + return (p.strip("'")[:2] == "hR", int(re.sub(r"[^\w]", "", p)[2:])) cif = CifFile(filename) - n, uc = n_from_pearson(cif["*Pearson"]), cif.build_unit_cell() + (is_rhombohedral, n), uc = ( + parse_pearson(cif["*Pearson"]), + cif.build_unit_cell(n_decimal_places=n_decimal_places, parse_mode="sympy"), + ) uc = np.array(sorted(uc, key=lambda x: tuple(x))) - np.testing.assert_equal(len(uc), n, err_msg="cell does not match Pearson symbol!") + msg = "cell does not match Pearson symbol!" + if not is_rhombohedral: + np.testing.assert_equal(len(uc), n, err_msg=msg) + else: # AFlow rhombohedral structures include data for hexagonal setting + np.testing.assert_equal(len(uc), 3 * n, err_msg=msg)