Repository: pyiron/structuretoolkit (language: python)

Issue:
Updates to pyxtal



Discussion / hints:

Fix patch:
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


Test patch:
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")