diff --git a/.ci_support/environment.yml b/.ci_support/environment.yml index 387e1a493..956106fb4 100644 --- a/.ci_support/environment.yml +++ b/.ci_support/environment.yml @@ -8,12 +8,13 @@ dependencies: - codacy-coverage - matplotlib-base =3.7.2 - nglview =3.0.6 -- numpy =1.25.1 +- numpy =1.24.3 - phonopy =2.20.0 - plotly =5.15.0 -- pymatgen =2023.7.17 +- pymatgen =2023.7.20 - pyscal =2.10.18 - scikit-learn =1.3.0 - scipy =1.11.1 - spglib =2.0.2 - sqsgenerator =0.2 +- pyxtal =0.5.8 diff --git a/.ci_support/environment_mini.yml b/.ci_support/environment_mini.yml index 838121d8f..887013ec6 100644 --- a/.ci_support/environment_mini.yml +++ b/.ci_support/environment_mini.yml @@ -7,4 +7,4 @@ dependencies: - codacy-coverage - matplotlib-base =3.7.2 - numpy =1.25.1 -- scipy =1.11.1 +- scipy =1.11.1 \ No newline at end of file diff --git a/setup.py b/setup.py index e4560ec50..4d97f4d72 100644 --- a/setup.py +++ b/setup.py @@ -32,18 +32,19 @@ install_requires=[ 'ase==3.22.1', 'matplotlib==3.7.2', # ase already requires matplotlib - 'numpy==1.25.1', # ase already requires numpy + 'numpy==1.24.3', # ase already requires numpy 'scipy==1.11.1', # ase already requires scipy ], extras_require={ - "grainboundary": ['aimsgb==1.0.1', 'pymatgen==2023.7.17'], + "grainboundary": ['aimsgb==1.0.1', 'pymatgen==2023.7.20'], "pyscal": ['pyscal2==2.10.18'], "nglview": ['nglview==3.0.6'], "plotly": ['plotly==5.15.0'], "clusters": ['scikit-learn==1.3.0'], "symmetry": ['spglib==2.0.2'], - "surface": ['spglib==2.0.2', 'pymatgen==2023.7.17'], + "surface": ['spglib==2.0.2', 'pymatgen==2023.7.20'], "phonopy": ['phonopy==2.20.0', 'spglib==2.0.2'], + "pyxtal": ['pyxtal==0.5.8'] }, cmdclass=versioneer.get_cmdclass(), ) diff --git a/structuretoolkit/build/__init__.py b/structuretoolkit/build/__init__.py index fdc7eebf3..27251a9a5 100644 --- a/structuretoolkit/build/__init__.py +++ b/structuretoolkit/build/__init__.py @@ -3,6 +3,7 @@ get_grainboundary_info ) from structuretoolkit.build.compound import B2, C14, C15, C36, D03 +from structuretoolkit.build.random import pyxtal from structuretoolkit.build.sqs import sqs_structures from structuretoolkit.build.surface import ( high_index_surface, diff --git a/structuretoolkit/build/random.py b/structuretoolkit/build/random.py new file mode 100644 index 000000000..c84289171 --- /dev/null +++ b/structuretoolkit/build/random.py @@ -0,0 +1,107 @@ +# coding: utf-8 +# Copyright (c) Max-Planck-Institut für Eisenforschung GmbH - Computational Materials Design (CM) Department +# Distributed under the terms of "New BSD License", see the LICENSE file. + +from typing import Union, List, Tuple +import warnings + +try: + from tqdm.auto import tqdm +except ImportError: + tqdm = lambda x: x + +from ase import Atoms +from structuretoolkit.common.helper import center_coordinates_in_unit_cell + + +def pyxtal( + group: Union[int, List[int]], + species: Tuple[str], + num_ions: Tuple[int], + dim=3, + repeat=1, + allow_exceptions=True, + **kwargs, +) -> Union[Atoms, List[dict]]: + """ + Generate random crystal structures with PyXtal. + + `group` must be between 1 and the largest possible value for the given dimensionality: + dim=3 => 1 - 230 (space groups) + dim=2 => 1 - 80 (layer groups) + dim=1 => 1 - 75 (rod groups) + dim=0 => 1 - 58 (point groups) + + When `group` is passed as a list of integers or `repeat>1`, generate multiple structures and return them in a list + of dicts containing the keys `atoms`, `symmetry` and `repeat` for the ASE structure, the symmetry group + number and which iteration it is, respectively. + + Args: + group (list of int, or int): the symmetry group to generate or a list of them + species (tuple of str): which species to include, defines the stoichiometry together with `num_ions` + num_ions (tuple of int): how many of each species to include, defines the stoichiometry together with `species` + dim (int): dimensionality of the symmetry group, 0 is point groups, 1 is rod groups, 2 is layer groups and 3 is space groups + repeat (int): how many random structures to generate + allow_exceptions (bool): when generating multiple structures, silence errors when the requested stoichiometry and symmetry group are incompatible + **kwargs: passed to `pyxtal.pyxtal` function verbatim + + Returns: + :class:`~.Atoms`: the generated structure, if repeat==1 and only one symmetry group is requested + list of dict of all generated structures, if repeat>1 or multiple symmetry groups are requested + + Raises: + ValueError: if `species` and `num_ions` are not of the same length + ValueError: if stoichiometry and symmetry group are incompatible and allow_exceptions==False or only one structure is requested + """ + from pyxtal import pyxtal as _pyxtal + from pyxtal.msg import Comp_CompatibilityError + + if len(species) != len(num_ions): + raise ValueError( + "species and num_ions must be of same length, " + f"not {species} and {num_ions}!" + ) + stoich = "".join(f"{s}{n}" for s, n in zip(species, num_ions)) + + def generate(group): + s = _pyxtal() + try: + s.from_random( + dim=dim, group=group, species=species, numIons=num_ions, **kwargs + ) + except Comp_CompatibilityError as e: + if not allow_exceptions: + raise ValueError( + f"Symmetry group {group} incompatible with stoichiometry {stoich}!" + ) from None + else: + return None + s = s.to_ase() + s = center_coordinates_in_unit_cell(structure=s) + return s + + # return a single structure + if repeat == 1 and isinstance(group, int): + allow_exceptions = False + return generate(group) + else: + structures = [] + if isinstance(group, int): + group = [group] + failed_groups = [] + for g in tqdm(group, desc="Spacegroups"): + for i in range(repeat): + s = generate(g) + if s is None: + failed_groups.append(g) + continue + structures.append({ + "atoms": s, + "symmetry": g, + "repeat": i + }) + if len(failed_groups) > 0: + warnings.warn( + f'Groups [{", ".join(map(str,failed_groups))}] could not be generated with stoichiometry {stoich}!' + ) + return structures diff --git a/tests/test_pyxtal.py b/tests/test_pyxtal.py new file mode 100644 index 000000000..b0f9ff9aa --- /dev/null +++ b/tests/test_pyxtal.py @@ -0,0 +1,45 @@ +from unittest import TestCase, skipIf +from ase import Atoms +import structuretoolkit as stk + + +try: + import pyxtal + skip_pyxtal_test = False +except ImportError: + skip_pyxtal_test = True + + +@skipIf(skip_pyxtal_test, "pyxtal is not installed, so the pyxtal tests are skipped.") +class TestPyxtal(TestCase): + + def test_args_raised(self): + """pyxtal should raise appropriate errors when called with wrong arguments""" + + with self.assertRaises(ValueError, msg="No error raised when num_ions and species do not match!"): + stk.build.pyxtal(1, species=['Fe'], num_ions=[1,2]) + + with self.assertRaises(ValueError, msg="No error raised when num_ions and species do not match!"): + stk.build.pyxtal(1, species=['Fe', 'Cr'], num_ions=[1]) + + try: + stk.build.pyxtal([193, 194], ['Mg'], num_ions=[1], allow_exceptions=True) + except ValueError: + self.fail("Error raised even though allow_exceptions=True was passed!") + + with self.assertRaises(ValueError, msg="No error raised even though allow_exceptions=False was passed!"): + stk.build.pyxtal(194, ['Mg'], num_ions=[1], allow_exceptions=False) + + def test_return_value(self): + """pyxtal should either return Atoms or list of dict, depending on arguments""" + + self.assertIsInstance(stk.build.pyxtal(1, species=['Fe'], num_ions=[1]), Atoms, + "returned not an Atoms with scalar arguments") + self.assertIsInstance(stk.build.pyxtal([1, 2], species=['Fe'], num_ions=[1]), list, + "returned not a StructureStorage with multiple groups") + self.assertIsInstance(stk.build.pyxtal(1, species=['Fe'], num_ions=[1], repeat=5), list, + "returned not a StructureStorage with repeat given") + self.assertEqual(len(stk.build.pyxtal(1, species=['Fe'], num_ions=[1], repeat=5)), 5, + "returned number of structures did not match given repeat") + self.assertTrue(all(isinstance(d, dict) for d in stk.build.pyxtal(1, species=['Fe'], num_ions=[1], repeat=5)), + "returned list should contain only dicts")