From be14d74482dfab3ee1833f0048dc78360cf8fa96 Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 14:47:50 +0200 Subject: [PATCH 01/17] chore: update repository configurations, licenses, and README metadata --- .gitignore | 8 ++-- LICENSE | 112 ++++++++++++++++++++++++++++++++++++++++++---------- LICENSE.txt | 24 +++++++++++ README.md | 4 +- 4 files changed, 119 insertions(+), 29 deletions(-) create mode 100644 LICENSE.txt diff --git a/.gitignore b/.gitignore index 2dd0c03..70551dd 100644 --- a/.gitignore +++ b/.gitignore @@ -173,7 +173,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. @@ -186,7 +186,7 @@ cython_debug/ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder -# .vscode/ +.vscode/ # Ruff stuff: .ruff_cache/ @@ -209,6 +209,4 @@ __marimo__/ # MyST documentation build docs/_build -# Jupytext sync is a tool for writing notebooks in `.ipynb` format, and syncing them to a `.md` file -# https://marketplace.visualstudio.com/items?itemName=caenrigen.jupytext-sync -.jupytext-sync-ipynb +dev/ diff --git a/LICENSE b/LICENSE index fe1b966..4593a22 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,91 @@ -MIT License - -Copyright (c) 2025 Marnik Bercx - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +Non-Commercial, End-User Software License Agreement for AiiDA-Supercon + +Copyright (c), 2021-2025, University of Bremen, Germany (U Bremen Excellence Chair), +École Polytechnique Fédérale de Lausanne (Theory and Simulation of Materials (THEOS) +and National Centre for Computational Design and Discovery of Novel Materials +(NCCR MARVEL)), Switzerland and Paul Scherrer Institut (Laboratory for Materials + Simulations (LMS)), Switzerland. All rights reserved. + +INTRODUCTION + +- This license agreement sets forth the terms and conditions under which the + Authors and their Institutions (hereafter "LICENSORS") of the program +"AiiDA-Supercon" (hereafter "PROGRAM"), will grant you (hereafter +"LICENSEE") a fully-paid, non-exclusive, and non-transferable license for +academic, non-commercial purposes only (hereafter "LICENSE") to use the PROGRAM +computer software and associated documentation furnished hereunder. + +- LICENSEE acknowledges that the PROGRAM is a research tool still in the + development stage, that is being supplied "as is", without any related +services, improvements or warranties from LICENSORS and that this license is +entered into in order to enable others to utilize the PROGRAM in their academic +activities. + +TERMS AND CONDITIONS OF THE LICENSE + +1. LICENSORS grant to LICENSEE a fully-paid up, non-exclusive, and +non-transferable license to use the PROGRAM for academic, non-commercial +purposes, upon the terms and conditions hereinafter set out and until +termination of this license as set forth below. + +2. LICENSEE acknowledges that the PROGRAM is a research tool still in the +development stage. The PROGRAM is provided "as is", without any related +services or improvements from LICENSORS and that the LICENSE is entered into in +order to enable others to utilize the PROGRAM in their academic activities. + +3. LICENSORS MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, +INCLUDING WITHOUT LIMITATION ANY REPRESENTATIONS OR WARRANTIES OF +MERCHANTABILITY OR FITNESS FOR PARTICULAR PURPOSE OR THAT THE USE OF THE +PROGRAM WILL NOT INFRINGE ANY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS. +LICENSORS shall not be liable for any direct, indirect or consequential damages +with respect to any claim by LICENSEE or any third party arising from this +Agreement or use of the PROGRAM. + +4. LICENSEE agrees that it will use the PROGRAM, and any modifications, +improvements, or derivatives to PROGRAM that LICENSEE may create (collectively, +"IMPROVEMENTS") solely for academic, non-commercial purposes and shall not +distribute or transfer the PROGRAM or any IMPROVEMENTS to any person without +prior written permission from LICENSORS. The terms "academic, non-commercial", +as used in this Agreement, mean academic or other scholarly research which (a) +is not undertaken for profit, or (b) is not intended to produce works, +services, or data for commercial use, or (c) is neither conducted, nor funded, +by a person or an entity engaged in the commercial use, application or +exploitation of works similar to the PROGRAM. + +5. LICENSEE agrees that he/she shall make the following acknowledgement in +publications resulting from the use of the PROGRAM: "Sebastiaan. P. Huber, Spyros Zoupanos, +Martin Uhrin, Leopold Talirz, Leonid Kahle, Rico Häuselmann, Dominik Gresch, +Tiziano Müller, Aliaksandr V. Yakutovich, Casper W. Andersen, Francisco F. +Ramirez, Carl S. Adorf, Fernando Gargiulo, Snehal Kumbhar, Elsa Passaro, +Conrad Johnston, Andrius Merkys, Andrea Cepellotti, Nicolas Mounet, Nicola +Marzari, Boris Kozinsky, and Giovanni Pizzi, AiiDA 1.0, a scalable computational +infrastructure for automated reproducible workflows and data provenance, +Scientific Data 7, 300 (2020), DOI: 10.1038/s41597-020-00638-4" +(http://www.aiida.net), plus any additional reference explicitly mentioned in +the custom workflow used. Except for the above-mentioned acknowledgment, +LICENSEE shall not use the PROGRAM title or the names or logos of LICENSORS, +nor any adaptation thereof, nor the names of any of its employees or +laboratories, in any advertising, promotional or sales material without prior +written consent obtained from LICENSORS in each case. + +6. Ownership of all rights, including copyright in the PROGRAM and in any +material associated therewith, shall at all times remain with LICENSORS, and +LICENSEE agrees to preserve the same. LICENSEE agrees not to use any portion of +the PROGRAM or of any IMPROVEMENTS in any machine-readable form outside the +PROGRAM, nor to make any copies except for its internal use, without prior +written consent of LICENSORS. LICENSEE agrees to place the following copyright +notice on any such copies: © All rights reserved. Paul Scherrer Institut, Switzerland, +Laboratory for Materials Simulations (LMS), 2025; Authors: Marnik Bercx and Nicola +Marzari. + +7. The LICENSE shall not be construed to confer any rights upon LICENSEE by +implication or otherwise except as specifically set forth herein. + +8. This Agreement shall be governed by the material laws of Switzerland and any +dispute arising out of this Agreement or use of the PROGRAM shall be brought +before the courts of Lausanne, Switzerland. + +9. This Agreement and the LICENSE shall remain effective until expiration of +the copyrights of the PROGRAM except that upon any breach of this Agreement by +LICENSEE, LICENSORS shall have the right to terminate the LICENSE immediately +upon notice to LICENSEE. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..855d1f2 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,24 @@ +The MIT License (MIT) + +Copyright (c), 2015-2020, ECOLE POLYTECHNIQUE FEDERALE DE LAUSANNE +(Theory and Simulation of Materials (THEOS) and National Centre for +Computational Design and Discovery of Novel Materials (NCCR MARVEL)). +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index edb57c4..1ee7a60 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -[![Templated from python-copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/mbercx/python-copier/refs/heads/main/docs/img/badge.json)](https://github.com/mbercx/python-copier) - # `aiida-thermo_pw` -AiiDA plugin for thermo_pw software +AiiDA plugin package with workflows for thermo_pw From 364cbd218c736e1b5e953156b0b171ffdc8e4a30 Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 14:48:01 +0200 Subject: [PATCH 02/17] chore: port package dependency definitions and AiiDA entry points in pyproject.toml --- pyproject.toml | 152 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 143 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 31956a8..6aac787 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,31 +5,57 @@ build-backend = "hatchling.build" [project] name = "aiida-thermo_pw" dynamic = ["version"] -description = '' +description = "The official AiiDA plugin for thermo_pw" readme = "README.md" requires-python = ">=3.9" -license = {file = "LICENSE"} -keywords = [] -authors = [] +license = {file = "LICENSE.txt"} +keywords = ["aiida", "workflows"] +authors = [{name = "Yiming Zhang", email = "zhangyiming1998@gmail.com"}] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", + "Framework :: AiiDA", + "License :: OSI Approved :: MIT License", "Programming Language :: Python", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", +] +dependencies = [ + "aiida_core[atomic_tools]~=2.6", + "aiida-quantumespresso>=4.11", + "aiida-pseudo~=1.1", + "click~=8.0", + "importlib_resources", + "jsonschema", + "numpy", + "pydantic~=2.0", + "packaging", + "qe-tools~=2.0", + "xmlschema~=2.0", ] [project.optional-dependencies] docs = [ -"mystmd" + "sphinx~=6.2.1", + "sphinx-copybutton~=0.5.2", + "sphinx-book-theme~=1.0.1", + "sphinx-click~=4.4.0", + "sphinx-design~=0.4.1", + "sphinxcontrib-details-directive~=0.1.0", + "sphinx-autoapi~=3.0.0", + "myst_parser~=1.0.0", ] tests = [ - "pytest" + "pgtest~=1.3", + "pytest>=6.0", + "pytest-regressions~=2.3", ] pre-commit = [ - "pre-commit" + "pre-commit~=2.17", + "pylint~=2.17.2", + "pylint-aiida~=0.1.1", + "toml", ] [dependency-groups] @@ -73,3 +99,111 @@ lint.ignore = [ ] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["INP001", "S101"] + +[project.entry-points.'aiida.calculations'] +"thermo_pw" = "aiida_thermo_pw.calculations.thermo_pw:Thermo_pwCalculation" + +[project.entry-points.'aiida.parsers'] +"thermo_pw" = "aiida_thermo_pw.parsers.thermo_pw:Thermo_pwParser" + +[project.entry-points.'aiida.workflows'] +"thermo_pw.base" = "aiida_thermo_pw.workflows.base:Thermo_pwBaseWorkChain" + +[tool.flynt] +line-length = 120 +fail-on-change = true + +[tool.isort] +force_sort_within_sections = true +include_trailing_comma = true +line_length = 120 +multi_line_output = 3 # this configuration is compatible with yapf + +[tool.pydocstyle] +ignore = [ + 'D104', + 'D202', + 'D203', + 'D213' +] + +[tool.pylint.master] +load-plugins = ['pylint_aiida','pylint.extensions.no_self_use'] + +[tool.pylint.format] +max-line-length = 120 + +[tool.pylint.tool-check] +generated-members = 'self.exit_codes.*' + +[tool.pylint.messages_control] +disable = [ + 'duplicate-code', + 'fixme', + 'inconsistent-return-statements', + 'import-outside-toplevel', + 'locally-disabled', + 'logging-format-interpolation', + 'no-else-raise', + 'too-many-arguments', + 'too-many-ancestors', + 'too-many-branches', + 'too-many-locals', + 'use-dict-literal', +] + +[tool.pylint.basic] +good-names = [ + 'i', + 'j', + 'k', + 'SsspFamily', + 'StructureData', + 'SinglefileData', + 'UpfData', + 'HpCalculation', + 'PwCalculation', +] + +[tool.pytest.ini_options] +minversion = '6.0' +testpaths = [ + 'tests', +] +filterwarnings = [ + 'ignore:Object of type .* not in session, .* operation along .* will not proceed:sqlalchemy.exc.SAWarning', + 'ignore:Creating AiiDA configuration folder.*:UserWarning', + 'ignore::DeprecationWarning:frozendict:', + 'ignore::DeprecationWarning:pkg_resources:', + 'ignore::DeprecationWarning:sqlalchemy_utils:', +] + +[tool.yapf] +align_closing_bracket_with_visual_indent = true +based_on_style = 'google' +coalesce_brackets = true +column_limit = 120 +dedent_closing_brackets = true +indent_dictionary_value = false +split_arguments_when_comma_terminated = true + +[tool.tox] +legacy_tox_ini = """ +[tox] +envlist = + py{38,39,310,311} + py38-pre-commit + +[testenv] +description = Run the pytest tests +usedevelop=True +extras = tests +commands = pytest {posargs} + +[testenv:py38-pre-commit] +description = Run the pre-commit checks +extras = + tests + pre-commit +commands = pre-commit run {posargs} +""" From d5a90d9603d34d9304f4b9019e097d51e5096435 Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 14:48:15 +0200 Subject: [PATCH 03/17] feat: port utility scripts and example structure files --- examples/example_01.py | 54 ++++++++++++++++++++++++++++++++++++++ examples/structures/Pb.xsf | 8 ++++++ examples/structures/Si.xsf | 9 +++++++ utils/plot.py | 14 ++++++++++ 4 files changed, 85 insertions(+) create mode 100644 examples/example_01.py create mode 100644 examples/structures/Pb.xsf create mode 100644 examples/structures/Si.xsf create mode 100644 utils/plot.py diff --git a/examples/example_01.py b/examples/example_01.py new file mode 100644 index 0000000..ee81eed --- /dev/null +++ b/examples/example_01.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +"""Run a ``Thermo_pwBaseWorkChain`` for a scf calculation. + +Usage: ./example_01.py +""" +import click + +from aiida import cmdline, orm + +from aiida_thermo_pw.workflows.thermo_pw import Thermo_pwBaseWorkChain + +from aiida_thermo_pw.cli.params import RUN +from aiida_thermo_pw.utils.structure import read_structure +from aiida_thermo_pw.utils.workflows.builder.serializer import print_builder +from aiida_thermo_pw.utils.workflows.builder.setter import set_parallelization +from aiida_thermo_pw.utils.workflows.builder.submit import ( + submit_and_add_group, +) + + +def submit( + code: orm.Code, + structure: orm.StructureData, + group: orm.Group = None, + run: bool = False, +): + """Submit a ``Thermo_pwBaseWorkChain`` to calculate the thermodynamic properties.""" + builder = Thermo_pwBaseWorkChain.get_builder_from_protocol(code, structure=structure) + + # You can change parallelization here + parallelization = { + "num_mpiprocs_per_machine": 8, + "npool": 4, + } + set_parallelization(builder, parallelization, process_class=PwBandsWorkChain) + + print_builder(builder) + + if run: + submit_and_add_group(builder, group) + +@click.command() +@cmdline.utils.decorators.with_dbenv() +@cmdline.params.options.CODE(help="The pw.x code identified by its ID, UUID or label.") +@cmdline.params.options.GROUP(help="The group to add the submitted workchain.") +@click.argument("filename", type=click.Path(exists=True)) +@RUN() +def cli(filename, code, group, run): + """Run a ``Thermo_pwBaseWorkChain`` to calculate the scf. + + FILENAME: a crystal structure file, e.g., ``structures/Si.xsf``. + """ + struct = read_structure(filename, store=True) + submit(code, struct, group, run) diff --git a/examples/structures/Pb.xsf b/examples/structures/Pb.xsf new file mode 100644 index 0000000..6a30268 --- /dev/null +++ b/examples/structures/Pb.xsf @@ -0,0 +1,8 @@ +CRYSTAL +PRIMVEC 1 + 0.0000000000 2.4595782679 2.4595782679 + 2.4595782679 0.0000000000 2.4595782679 + 2.4595782679 2.4595782679 0.0000000000 +PRIMCOORD 1 +1 1 +82 0.0000000000 0.0000000000 0.0000000000 diff --git a/examples/structures/Si.xsf b/examples/structures/Si.xsf new file mode 100644 index 0000000..f7da8ca --- /dev/null +++ b/examples/structures/Si.xsf @@ -0,0 +1,9 @@ +CRYSTAL +PRIMVEC 1 + 0.0000000000 2.7164586260 2.7164586260 + 2.7164586260 0.0000000000 2.7164586260 + 2.7164586260 2.7164586260 0.0000000000 +PRIMCOORD 1 +2 1 +14 1.3582293131 4.0746879391 1.3582293131 +14 -0.0000000000 -0.0000000000 2.7164586260 diff --git a/utils/plot.py b/utils/plot.py new file mode 100644 index 0000000..a36846d --- /dev/null +++ b/utils/plot.py @@ -0,0 +1,14 @@ +def plot_group( + ax, + group: orm.Group|str, + keys: list[str], + **kwargs, +): + if isinstance(group, str): + group = orm.Group.collection.get(group) + + for node in group.nodes: + for key in keys: + if key in node.extras: + ax.plot(node.extras[key], **kwargs) + return ax From 5efc2db5d80b725e714f8c24bbda13f5337e75bf Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 14:48:22 +0200 Subject: [PATCH 04/17] doc: sync docstring in calculations/thermo_pw.py --- src/aiida_thermo_pw/calculations/thermo_pw.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiida_thermo_pw/calculations/thermo_pw.py b/src/aiida_thermo_pw/calculations/thermo_pw.py index 88caf3a..9abb4fb 100644 --- a/src/aiida_thermo_pw/calculations/thermo_pw.py +++ b/src/aiida_thermo_pw/calculations/thermo_pw.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""`CalcJob` implementation for the thermo_pw.x code of Quantum ESPRESSO.""" +"""`CalcJob` implementation for the pw.x code of Quantum ESPRESSO.""" from aiida import orm from aiida.common import exceptions From 3b5f120adc482d37243256b4872cdd7db6308edf Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 15:03:13 +0200 Subject: [PATCH 05/17] feat: port updates to structural tools --- src/aiida_thermo_pw/tools/structures.py | 84 +++++++++++++------------ 1 file changed, 44 insertions(+), 40 deletions(-) diff --git a/src/aiida_thermo_pw/tools/structures.py b/src/aiida_thermo_pw/tools/structures.py index f962126..13f0284 100644 --- a/src/aiida_thermo_pw/tools/structures.py +++ b/src/aiida_thermo_pw/tools/structures.py @@ -59,6 +59,15 @@ 'TRI': 14, } +crystal_system_to_space_group_number = { + 'triclinic': [1, 2], + 'monoclinic': [3, 15], + 'orthorhombic': [16, 74], + 'tetragonal': [75, 142], + 'trigonal': [143, 167], + 'hexagonal': [149, 194], + 'cubic': [195, 230], +} def is_identity(left_matrix, right_matrix, atol=1e-6): return np.allclose(left_matrix, right_matrix, atol=atol) @@ -585,6 +594,11 @@ def get_standardized_structure_pymatgen(structure, eps=1e-6): return primitive_standard_structure +def get_parameters_from_pymatgen_structure(pym_structure, eps=1e-6): + """ + Get the cell parameters and the cartesian coordinates of the atoms in the Quantum ESPRESSO convention from the pymatgen structure. + """ + def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): """ Convert the standardized structure in pymatgen to the Quantum ESPRESSO convention. @@ -1027,38 +1041,32 @@ def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): ]) else: sinab = np.sin(np.arccos(cosab)) - if cell[0][0] < 0: - transformation_matrix = [ - [-1, 0, 0], - [0, 1, 0], - [0, 0, 1], - ] - pym_structure.make_supercell(transformation_matrix) - cell = pym_structure.lattice.matrix - if cell[1][1] < 0: - transformation_matrix = [ - [1, 0, 0], - [0, -1, 0], - [0, 0, 1], - ] - pym_structure.make_supercell(transformation_matrix) - cell = pym_structure.lattice.matrix - - # if cell[2][0] < 0: - # transformation_matrix = [ - # [1, 0, 0], - # [0, 1, 0], - # [0, 0, -1], - # ] - # pym_structure.make_supercell(transformation_matrix) - # cell = pym_structure.lattice.matrix - - if np.allclose(cell, np.array([ + cell_qe = np.array([ [a, 0, 0], [b*cosab, b*sinab, 0], [c*cosac, c*(cosbc-cosac*cosab)/sinab, c*np.sqrt( 1 + 2*cosbc*cosac*cosab- cosbc**2-cosac**2-cosab**2 )/sinab], - ]), atol=eps): + ]) + transformation_matrix =la.inv(cell) @ cell_qe + transformation_matrix_rounded = np.round(transformation_matrix) + if np.allclose(transformation_matrix, transformation_matrix_rounded, atol=eps): + transformation_matrix = transformation_matrix_rounded.astype(int) + else: + cell_str = np.array2string(cell, precision=6, separator=',', suppress_small=True) + pattern_str = np.array2string(cell_qe, precision=6, separator=',', suppress_small=True) + transformation_matrix_str = np.array2string(transformation_matrix_rounded, precision=6, separator=',', suppress_small=True) + raise ValueError(f"Cell \n{cell_str} can not be converted to the Quantum ESPRESSO convention \n{pattern_str} using integer transformation matrix. Detected non-integer transformation matrix: \n{transformation_matrix_str}. Please check the cell parameters or use ibrav = 0.") + + if not abs(np.linalg.det(transformation_matrix) - 1) < eps: + raise ValueError(f"Transformation matrix \n{transformation_matrix_str} is not a valid rotation matrix. Please check the cell parameters or use ibrav = 0.") + + pym_structure.apply_operation(SymmOp.from_rotation_and_translation( + rotation_matrix=transformation_matrix, + translation_vec=[0, 0, 0], + )) + + cell = pym_structure.lattice.matrix + if np.allclose(cell, cell_qe, atol=eps): parameters['ibrav'] = 14 parameters['a'] = a @@ -1067,24 +1075,20 @@ def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): parameters['cosab'] = cosab parameters['cosbc'] = cosbc parameters['cosac'] = cosac - cell_qe = np.array([ - [a, 0, 0], - [b*cosab, b*sinab, 0], - [c*cosac, c*(cosab-cosac*cosab)/sinab, c*np.sqrt( 1 + 2*cosbc*cosac*cosab- cosbc**2-cosac**2-cosab**2 )/sinab], - ]) + transformation_matrix = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ] else: cell_str = np.array2string(cell, precision=6, separator=',', suppress_small=True) - pattern_str = np.array2string(np.array([ - [a, 0, 0], - [b*cosab, b*sinab, 0], - [c*cosac, c*(cosbc-cosac*cosab)/sinab, c*np.sqrt( 1 + 2*cosbc*cosac*cosab- cosbc**2-cosac**2-cosab**2 )/sinab], - ]), precision=6, separator=',', suppress_small=True) + pattern_str = np.array2string(cell_qe, precision=6, separator=',', suppress_small=True) raise ValueError(f"Cell \n{cell_str} does not match the expected pattern \n{pattern_str}. Please check the cell parameters or use ibrav = 0.") - new_pym_structure = pym_structure.make_supercell(transformation_matrix) + pym_structure.make_supercell(transformation_matrix) - return new_pym_structure, parameters + return pym_structure, parameters def check_conversion(structure, log=print): cell = np.array(structure.cell) From 39e31583b274625b5d0de372cf51d8372fe339ee Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 15:03:22 +0200 Subject: [PATCH 06/17] style: consolidate analyser imports in tools/__init__.py --- src/aiida_thermo_pw/tools/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/aiida_thermo_pw/tools/__init__.py b/src/aiida_thermo_pw/tools/__init__.py index a37fbf3..3766ac0 100644 --- a/src/aiida_thermo_pw/tools/__init__.py +++ b/src/aiida_thermo_pw/tools/__init__.py @@ -4,10 +4,7 @@ delete_nodes_and_remote_folder, check_process_state ) -from .analyser import ( - ThermoPwBaseAnalyser, - ThermoPwBaseWorkChainState -) +from .analyser import ThermoPwBaseAnalyser, ThermoPwBaseWorkChainState from .structures import ( get_ibrav_ase, get_parameters_from_structure, From 6852fae88bd767f7ad351d0e8302329bb2fd23a9 Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 15:03:35 +0200 Subject: [PATCH 07/17] feat: port updates to process analyzer --- src/aiida_thermo_pw/tools/analyser.py | 274 +++++++------------------- 1 file changed, 71 insertions(+), 203 deletions(-) diff --git a/src/aiida_thermo_pw/tools/analyser.py b/src/aiida_thermo_pw/tools/analyser.py index ff596b1..a41ebd6 100644 --- a/src/aiida_thermo_pw/tools/analyser.py +++ b/src/aiida_thermo_pw/tools/analyser.py @@ -1,5 +1,4 @@ from re import S -from tkinter import N from aiida import orm from aiida.common.links import LinkType from aiida.engine import ProcessState @@ -7,9 +6,6 @@ from collections import OrderedDict from abc import ABC, abstractmethod from rich import print as rprint -import numpy -from aiida_analyser import BaseWorkChainAnalyser - class ThermoPwBaseWorkChainState(Enum): """ Analyser for the ThermoPwBaseWorkChain. @@ -24,7 +20,7 @@ class ThermoPwBaseWorkChainState(Enum): ERROR_NSTEP = 7 UNKNOWN = 999 -class ThermoPwBaseAnalyser(BaseWorkChainAnalyser): +class ThermoPwBaseAnalyser: """ Analyser for the ThermoPwBaseWorkChain. """ @@ -33,7 +29,7 @@ class ThermoPwBaseAnalyser(BaseWorkChainAnalyser): ]) def __init__(self, workchain: orm.WorkChainNode): - super().__init__(workchain) + self.node = workchain self.state = ThermoPwBaseWorkChainState.UNKNOWN self.descendants = {} for link_label, _ in self._all_descendants.items(): @@ -50,209 +46,81 @@ def get_iterations(self): iterations.append(node) return iterations - # def check_process_state(self): + def check_process_state(self): - # from collections import deque - # from io import StringIO + from collections import deque + from io import StringIO - # state = ThermoPwBaseWorkChainState.UNKNOWN - # message = '' + state = ThermoPwBaseWorkChainState.UNKNOWN + message = '' - # source_db, source_id = self.node.inputs.thermo_pw.structure.base.extras.get_many(('source_db', 'source_id')) - # source = f"{source_db}-{source_id}" - - # if self.node.process_state == ProcessState.WAITING: - # state = ThermoPwBaseWorkChainState.WAITING - # message = f"{self.node.process_state}" - # elif self.node.process_state == ProcessState.RUNNING: - # state = ThermoPwBaseWorkChainState.RUNNING - # message = f"{self.node.process_state}" - # elif self.node.process_state == ProcessState.EXCEPTED: - # state = ThermoPwBaseWorkChainState.EXCEPTED - # message = f"{self.node.process_state}" - # elif self.node.process_state == ProcessState.FINISHED: - # if self.node.exit_status != 0: - # final_iteration = self.get_iterations()[-1] - # if final_iteration.is_killed: - # state = ThermoPwBaseWorkChainState.KILLED - # message = f"{self.node.process_state} killed in final iteration [{final_iteration.pk}]" - # return state, message - # if final_iteration.is_excepted: - # state = ThermoPwBaseWorkChainState.EXCEPTED - # message = f"{self.node.process_state} excepted in final iteration [{final_iteration.pk}]" - # return state, message - # stderr = final_iteration.get_scheduler_stderr() - # aiida_out = final_iteration.outputs.retrieved.get_object_content("aiida.out") - # for error, error_message in ( - # (ThermoPwBaseWorkChainState.ERROR_TOO_MANY_PROCESSES, 'there are processes with no planes.'), - # (ThermoPwBaseWorkChainState.ERROR_TIME_LIMIT, 'TIME LIMIT'), - # ): - # if error_message in stderr: - # state = error - # message = f"{error_message} in final iteration [{final_iteration.pk}]" - # return state, message - # for error, error_message in ( - # (ThermoPwBaseWorkChainState.ERROR_NSTEP, 'Incorrect nstep, check elastic_algorithm'), - # ): - # if error_message in aiida_out: - # state = error - # message = f"{error_message} in final iteration [{final_iteration.pk}]" - # return state, message - # state = ThermoPwBaseWorkChainState.FINISHED_OK - # message = f"{self.node.process_state} finished with exit status {self.node.exit_status} and final iteration [{final_iteration.pk}]" - # return state, message - # else: - # state = ThermoPwBaseWorkChainState.FINISHED_OK - # message = f"{self.node.process_state} finished with exit status {self.node.exit_status}" - # return state, message - # else: - # state = ThermoPwBaseWorkChainState.UNKNOWN - # message = f"{self.node.process_state} " - # return state, message - - # return state, message - - def get_source(self): - """Get the source of the workchain.""" - source = super().get_source() - if source is None: - try: - source_db, source_id = self.node.inputs.thermo_pw.structure.base.extras.get_many(('source_db', 'source_id')) - source = f"{source_db}-{source_id}" - except Exception: - print('Source is not set') - return None - return source - - def print_state(self): - """Print the state of the workchain.""" - result = self.get_state() - if not result: - print(f"Can't check the state of ThermoPwBaseWorkChain<{self.node.pk}>.") - return - path, process_state = result - print(f"ThermoPwBaseWorkChain<{self.node.pk}> is now {process_state} at {path}.") - - def get_moduli(self, modulus_type: str): - """Get the moduli of the workchain.""" - if not self.node.is_finished_ok: - return None - moduli = { - average: self.node.outputs.output_parameters.get('moduli').get(average).get(modulus_type) - for average in ['voigt', 'reuss', 'vrh'] - } - return moduli - - @property - def code(self): - """Get the code of the workchain.""" - return self.node.inputs.thermo_pw.code - - @property - def elastic_constants(self): - """Get the elastic constants of the workchain.""" - if not self.node.is_finished_ok: - return None - return self.node.outputs.elastic_constants.get_array('elastic_constants') - - @property - def bulk_modulus(self): - """Get the moduli of the workchain.""" - return self.get_moduli('bulk_modulus_B') - - @property - def young_modulus(self): - """Get the Young modulus of the workchain.""" - return self.get_moduli('young_modulus_E') - - @property - def shear_modulus(self): - """Get the Shear modulus of the workchain.""" - return self.get_moduli('shear_modulus_G') - - @property - def poisson_ratio(self): - """Get the Poisson ratio of the workchain.""" - return self.get_moduli('poisson_ratio_n') - - @property - def pugh_ratio(self): - """Get the Pugh ratio of the workchain.""" - return self.get_moduli('pugh_ratio_r') - - @property - def pettifor_ratio(self): - """Get the Pettifor ratio of the workchain.""" - bulk_modulus = self.bulk_modulus - # Note that both the elastic constants and the bulk modulus are in kbar. - elastic_constants = self.elastic_constants - if bulk_modulus is None or elastic_constants is None: - return None - return { - average: (elastic_constants[0][1] - elastic_constants[2][2]) / bulk_modulus[average] for average in ['voigt', 'reuss', 'vrh'] - } - - @property - def modified_pettifor_ratio(self): - """Get the modified Pettifor ratio of the workchain.""" - young_modulus = self.young_modulus - elastic_constants = self.elastic_constants - if young_modulus is None or elastic_constants is None: - return None - return { - average: (elastic_constants[0][1] - elastic_constants[2][2]) / young_modulus[average] for average in ['voigt', 'reuss', 'vrh'] - } - - def clean_workchain(self, exempted_states, dry_run: bool = True): - """Clean the workchain.""" - super().clean_workchain(exempted_states, dry_run) + source_db, source_id = self.node.inputs.thermo_pw.structure.base.extras.get_many(('source_db', 'source_id')) + source = f"{source_db}-{source_id}" + + if self.node.process_state == ProcessState.WAITING: + state = ThermoPwBaseWorkChainState.WAITING + message = f"{self.node.process_state}" + elif self.node.process_state == ProcessState.RUNNING: + state = ThermoPwBaseWorkChainState.RUNNING + message = f"{self.node.process_state}" + elif self.node.process_state == ProcessState.EXCEPTED: + state = ThermoPwBaseWorkChainState.EXCEPTED + message = f"{self.node.process_state}" + elif self.node.process_state == ProcessState.FINISHED: + if self.node.exit_status != 0: + final_iteration = self.get_iterations()[-1] + if final_iteration.is_killed: + state = ThermoPwBaseWorkChainState.KILLED + message = f"{self.node.process_state} killed in final iteration [{final_iteration.pk}]" + return state, message + if final_iteration.is_excepted: + state = ThermoPwBaseWorkChainState.EXCEPTED + message = f"{self.node.process_state} excepted in final iteration [{final_iteration.pk}]" + return state, message + stderr = final_iteration.get_scheduler_stderr() + aiida_out = final_iteration.outputs.retrieved.get_object_content("aiida.out") + for error, error_message in ( + (ThermoPwBaseWorkChainState.ERROR_TOO_MANY_PROCESSES, 'there are processes with no planes.'), + (ThermoPwBaseWorkChainState.ERROR_TIME_LIMIT, 'TIME LIMIT'), + ): + if error_message in stderr: + state = error + message = f"{error_message} in final iteration [{final_iteration.pk}]" + return state, message + for error, error_message in ( + (ThermoPwBaseWorkChainState.ERROR_NSTEP, 'Incorrect nstep, check elastic_algorithm'), + ): + if error_message in aiida_out: + state = error + message = f"{error_message} in final iteration [{final_iteration.pk}]" + return state, message + state = ThermoPwBaseWorkChainState.FINISHED_OK + message = f"{self.node.process_state} finished with exit status {self.node.exit_status} and final iteration [{final_iteration.pk}]" + return state, message + else: + state = ThermoPwBaseWorkChainState.FINISHED_OK + message = f"{self.node.process_state} finished with exit status {self.node.exit_status}" + return state, message + else: + state = ThermoPwBaseWorkChainState.UNKNOWN + message = f"{self.node.process_state} " + return state, message - def get_fitting_coefficients(self): - """Get the fitting coefficients of the workchain.""" - if not self.node.is_finished_ok: - return None - return self.node.outputs.output_parameters.get('elastic_constants_fitting') + return state, message - def plot_elastic_fitting(self, axis=None): - """Plot the elastic fitting of the workchain.""" - if not self.node.is_finished_ok: - return None - fitting_coefficients = self.get_fitting_coefficients() - if not axis: - from matplotlib import pyplot as plt - fig, ax = plt.subplots(1, 1, figsize=(6, 8)) - else: - ax = axis - for x, x_info in fitting_coefficients.items(): - for y, y_info in x_info.items(): - strains = numpy.array(y_info.get('strains')) - stresses = numpy.array(y_info.get('stresses')) - coefficients = numpy.array(y_info.get('coefficients')) - ax.scatter(strains, stresses, color='blue') - # ax.plot(strains, stresses, color='blue', label=f'{x}-{y}') - polynomial = numpy.poly1d(coefficients[::-1]) - ax.plot(strains, 147100*polynomial(strains), color='red', label=f'{x}-{y} fitting') - ax.legend(loc='best') - return ax + def delete_nodes_and_remote_folder( + self, + ): + from aiida.common import NotExistentAttributeError + from aiida.tools import delete_nodes - def get_RMS_error(self): - """Get the RMS error of the workchain.""" - if not self.node.is_finished_ok: - return None + for called_descendant in self.node.called_descendants: + if isinstance(called_descendant, orm.CalcJobNode): + try: + called_descendant.outputs.remote_folder._clean() # pylint: disable=protected-access + except (IOError, OSError, KeyError, NotExistentAttributeError): + pass - RMS_errors = {} - fitting_coefficients = self.get_fitting_coefficients() - for x, x_info in fitting_coefficients.items(): - RMS_errors[x] = {} - for y, y_info in x_info.items(): - strains = numpy.array(y_info.get('strains')) - stresses = numpy.array(y_info.get('stresses')) - coefficients = numpy.array(y_info.get('coefficients')) - polynomial = numpy.poly1d(coefficients[::-1]) - errors = stresses - 147100*polynomial(strains) - RMS_error = numpy.sqrt(numpy.mean(errors**2)) - # print(f'RMS error for {x}-{y} is {RMS_error}') - RMS_errors[x][y] = RMS_error - return RMS_errors \ No newline at end of file + delete_nodes([self.node.pk], dry_run=False) \ No newline at end of file From 2edea566e81f615c2658d16490a05388179fe681 Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 15:03:56 +0200 Subject: [PATCH 08/17] feat: port updates to base workchain --- src/aiida_thermo_pw/workflows/base.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/aiida_thermo_pw/workflows/base.py b/src/aiida_thermo_pw/workflows/base.py index 35c6705..fe8476b 100644 --- a/src/aiida_thermo_pw/workflows/base.py +++ b/src/aiida_thermo_pw/workflows/base.py @@ -115,7 +115,9 @@ def get_builder_from_protocol( builder.thermo_pw['thermo_control'] = inputs['thermo_pw']['thermo_control'] builder.clean_workdir = orm.Bool(inputs['clean_workdir']) - if 'kpoints_distance' in inputs: + if 'kpoints' in inputs: + builder.kpoints = inputs['kpoints'] + else: builder.kpoints_distance = orm.Float(inputs['kpoints_distance']) builder.kpoints_force_parity = orm.Bool(inputs['kpoints_force_parity']) builder.max_iterations = orm.Int(inputs['max_iterations']) @@ -150,13 +152,7 @@ def setup(self): super().setup() self.ctx.inputs = AttributeDict(self.exposed_inputs(Thermo_pwCalculation, 'thermo_pw')) - inputs = { - 'structure': self.ctx.inputs.structure, - 'metadata': { - 'call_link_label': 'format_structure' - } - } - results = self.format_structure(**inputs) + results = self.format_structure(self.ctx.inputs.structure) self.ctx.inputs.structure = results['formated_structure'] parameters = self.ctx.inputs.parameters.get_dict() From 05436327c61dc7f52f825c6c75ae38b73b5840fd Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 15:16:40 +0200 Subject: [PATCH 09/17] refactor: rename package and imports to aiida-mechanical --- .copier-answers.yml | 4 ++-- README.md | 4 ++-- docs/developer.md | 2 +- docs/index.md | 4 ++-- docs/myst.yml | 4 ++-- examples/example_01.py | 12 ++++++------ pyproject.toml | 14 +++++++------- .../__about__.py | 0 .../__init__.py | 0 .../calculations/__init__.py | 0 .../calculations/thermo_pw.py | 0 .../data/__init__.py | 0 .../data/printer.py | 0 .../parsers/__init__.py | 0 .../parsers/thermo_pw.py | 2 +- .../tools/__init__.py | 0 .../tools/analyser.py | 0 .../tools/plots.py | 0 .../tools/structures.py | 0 .../tools/workchain.py | 0 .../workflows/__init__.py | 0 .../workflows/base.py | 6 +++--- .../workflows/protocols/__init__.py | 0 .../workflows/protocols/base.yaml | 0 24 files changed, 26 insertions(+), 26 deletions(-) rename src/{aiida_thermo_pw => aiida_mechanical}/__about__.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/__init__.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/calculations/__init__.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/calculations/thermo_pw.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/data/__init__.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/data/printer.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/parsers/__init__.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/parsers/thermo_pw.py (99%) rename src/{aiida_thermo_pw => aiida_mechanical}/tools/__init__.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/tools/analyser.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/tools/plots.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/tools/structures.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/tools/workchain.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/workflows/__init__.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/workflows/base.py (97%) rename src/{aiida_thermo_pw => aiida_mechanical}/workflows/protocols/__init__.py (100%) rename src/{aiida_thermo_pw => aiida_mechanical}/workflows/protocols/base.yaml (100%) diff --git a/.copier-answers.yml b/.copier-answers.yml index 0ea6122..fbad553 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,7 +1,7 @@ # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY _commit: v0.11.1 _src_path: https://github.com/mbercx/python-copier -description: AiiDA plugin for thermo_pw software +description: AiiDA plugin for mechanical properties doc_deploy: github docs: myst -package_name: aiida-thermo_pw +package_name: aiida-mechanical diff --git a/README.md b/README.md index 1ee7a60..15abe14 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ -# `aiida-thermo_pw` +# `aiida-mechanical` -AiiDA plugin package with workflows for thermo_pw +AiiDA plugin package with workflows for mechanical properties diff --git a/docs/developer.md b/docs/developer.md index 120b57a..6957513 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -11,7 +11,7 @@ First clone the repository from GitHub and install the package locally in **editable** mode (`-e`): - cd aiida-thermo_pw + cd aiida-mechanical pip install -e . :::{note} diff --git a/docs/index.md b/docs/index.md index fbc29e2..65f0dec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,5 @@ # Introduction -This is the documentation of the `aiida-thermo_pw` Python package. +This is the documentation of the `aiida-mechanical` Python package. -AiiDA plugin for thermo_pw software +AiiDA plugin for mechanical properties diff --git a/docs/myst.yml b/docs/myst.yml index 246d55f..ebe7926 100644 --- a/docs/myst.yml +++ b/docs/myst.yml @@ -1,8 +1,8 @@ # See docs at: https://mystmd.org/guide/frontmatter version: 1 project: - title: aiida-thermo_pw - description: AiiDA plugin for thermo_pw software + title: aiida-mechanical + description: AiiDA plugin for mechanical properties # keywords: [] # authors: [] # github: https://github.com/ diff --git a/examples/example_01.py b/examples/example_01.py index ee81eed..15c452a 100644 --- a/examples/example_01.py +++ b/examples/example_01.py @@ -7,13 +7,13 @@ from aiida import cmdline, orm -from aiida_thermo_pw.workflows.thermo_pw import Thermo_pwBaseWorkChain +from aiida_mechanical.workflows.base import Thermo_pwBaseWorkChain -from aiida_thermo_pw.cli.params import RUN -from aiida_thermo_pw.utils.structure import read_structure -from aiida_thermo_pw.utils.workflows.builder.serializer import print_builder -from aiida_thermo_pw.utils.workflows.builder.setter import set_parallelization -from aiida_thermo_pw.utils.workflows.builder.submit import ( +from aiida_mechanical.cli.params import RUN +from aiida_mechanical.utils.structure import read_structure +from aiida_mechanical.utils.workflows.builder.serializer import print_builder +from aiida_mechanical.utils.workflows.builder.setter import set_parallelization +from aiida_mechanical.utils.workflows.builder.submit import ( submit_and_add_group, ) diff --git a/pyproject.toml b/pyproject.toml index 6aac787..65e9657 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "aiida-thermo_pw" +name = "aiida-mechanical" dynamic = ["version"] -description = "The official AiiDA plugin for thermo_pw" +description = "The official AiiDA plugin for mechanical properties" readme = "README.md" requires-python = ">=3.9" license = {file = "LICENSE.txt"} @@ -60,11 +60,11 @@ pre-commit = [ [dependency-groups] dev = [ - "aiida-thermo_pw[docs,tests,pre-commit]", + "aiida-mechanical[docs,tests,pre-commit]", ] [tool.hatch.version] -path = "src/aiida_thermo_pw/__about__.py" +path = "src/aiida_mechanical/__about__.py" [tool.hatch.envs.default] installer = 'uv' @@ -101,13 +101,13 @@ lint.ignore = [ "tests/**/*.py" = ["INP001", "S101"] [project.entry-points.'aiida.calculations'] -"thermo_pw" = "aiida_thermo_pw.calculations.thermo_pw:Thermo_pwCalculation" +"mechanical.thermo_pw" = "aiida_mechanical.calculations.thermo_pw:Thermo_pwCalculation" [project.entry-points.'aiida.parsers'] -"thermo_pw" = "aiida_thermo_pw.parsers.thermo_pw:Thermo_pwParser" +"mechanical.thermo_pw" = "aiida_mechanical.parsers.thermo_pw:Thermo_pwParser" [project.entry-points.'aiida.workflows'] -"thermo_pw.base" = "aiida_thermo_pw.workflows.base:Thermo_pwBaseWorkChain" +"mechanical.thermo_pw.base" = "aiida_mechanical.workflows.base:Thermo_pwBaseWorkChain" [tool.flynt] line-length = 120 diff --git a/src/aiida_thermo_pw/__about__.py b/src/aiida_mechanical/__about__.py similarity index 100% rename from src/aiida_thermo_pw/__about__.py rename to src/aiida_mechanical/__about__.py diff --git a/src/aiida_thermo_pw/__init__.py b/src/aiida_mechanical/__init__.py similarity index 100% rename from src/aiida_thermo_pw/__init__.py rename to src/aiida_mechanical/__init__.py diff --git a/src/aiida_thermo_pw/calculations/__init__.py b/src/aiida_mechanical/calculations/__init__.py similarity index 100% rename from src/aiida_thermo_pw/calculations/__init__.py rename to src/aiida_mechanical/calculations/__init__.py diff --git a/src/aiida_thermo_pw/calculations/thermo_pw.py b/src/aiida_mechanical/calculations/thermo_pw.py similarity index 100% rename from src/aiida_thermo_pw/calculations/thermo_pw.py rename to src/aiida_mechanical/calculations/thermo_pw.py diff --git a/src/aiida_thermo_pw/data/__init__.py b/src/aiida_mechanical/data/__init__.py similarity index 100% rename from src/aiida_thermo_pw/data/__init__.py rename to src/aiida_mechanical/data/__init__.py diff --git a/src/aiida_thermo_pw/data/printer.py b/src/aiida_mechanical/data/printer.py similarity index 100% rename from src/aiida_thermo_pw/data/printer.py rename to src/aiida_mechanical/data/printer.py diff --git a/src/aiida_thermo_pw/parsers/__init__.py b/src/aiida_mechanical/parsers/__init__.py similarity index 100% rename from src/aiida_thermo_pw/parsers/__init__.py rename to src/aiida_mechanical/parsers/__init__.py diff --git a/src/aiida_thermo_pw/parsers/thermo_pw.py b/src/aiida_mechanical/parsers/thermo_pw.py similarity index 99% rename from src/aiida_thermo_pw/parsers/thermo_pw.py rename to src/aiida_mechanical/parsers/thermo_pw.py index d96ee35..50448b1 100644 --- a/src/aiida_thermo_pw/parsers/thermo_pw.py +++ b/src/aiida_mechanical/parsers/thermo_pw.py @@ -3,7 +3,7 @@ from aiida import orm import numpy -from aiida_thermo_pw.calculations.thermo_pw import Thermo_pwCalculation +from aiida_mechanical.calculations.thermo_pw import Thermo_pwCalculation from aiida_quantumespresso.parsers.base import BaseParser diff --git a/src/aiida_thermo_pw/tools/__init__.py b/src/aiida_mechanical/tools/__init__.py similarity index 100% rename from src/aiida_thermo_pw/tools/__init__.py rename to src/aiida_mechanical/tools/__init__.py diff --git a/src/aiida_thermo_pw/tools/analyser.py b/src/aiida_mechanical/tools/analyser.py similarity index 100% rename from src/aiida_thermo_pw/tools/analyser.py rename to src/aiida_mechanical/tools/analyser.py diff --git a/src/aiida_thermo_pw/tools/plots.py b/src/aiida_mechanical/tools/plots.py similarity index 100% rename from src/aiida_thermo_pw/tools/plots.py rename to src/aiida_mechanical/tools/plots.py diff --git a/src/aiida_thermo_pw/tools/structures.py b/src/aiida_mechanical/tools/structures.py similarity index 100% rename from src/aiida_thermo_pw/tools/structures.py rename to src/aiida_mechanical/tools/structures.py diff --git a/src/aiida_thermo_pw/tools/workchain.py b/src/aiida_mechanical/tools/workchain.py similarity index 100% rename from src/aiida_thermo_pw/tools/workchain.py rename to src/aiida_mechanical/tools/workchain.py diff --git a/src/aiida_thermo_pw/workflows/__init__.py b/src/aiida_mechanical/workflows/__init__.py similarity index 100% rename from src/aiida_thermo_pw/workflows/__init__.py rename to src/aiida_mechanical/workflows/__init__.py diff --git a/src/aiida_thermo_pw/workflows/base.py b/src/aiida_mechanical/workflows/base.py similarity index 97% rename from src/aiida_thermo_pw/workflows/base.py rename to src/aiida_mechanical/workflows/base.py index fe8476b..8451115 100644 --- a/src/aiida_thermo_pw/workflows/base.py +++ b/src/aiida_mechanical/workflows/base.py @@ -8,8 +8,8 @@ from aiida_quantumespresso.common.types import ElectronicType, RestartType, SpinType from aiida_quantumespresso.workflows.protocols.utils import ProtocolMixin from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import create_kpoints_from_distance -from aiida_thermo_pw.tools.structures import get_standardized_structure_pymatgen, convert_standardized_structure_pymatgen_to_qe -from aiida_thermo_pw.calculations.thermo_pw import Thermo_pwCalculation +from aiida_mechanical.tools.structures import get_standardized_structure_pymatgen, convert_standardized_structure_pymatgen_to_qe +from aiida_mechanical.calculations.thermo_pw import Thermo_pwCalculation class Thermo_pwBaseWorkChain(ProtocolMixin, BaseRestartWorkChain): @@ -75,7 +75,7 @@ def get_protocol_filepath(cls): """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" from importlib_resources import files - import aiida_thermo_pw.workflows.protocols as thermo_pw_protocols + import aiida_mechanical.workflows.protocols as thermo_pw_protocols return files(thermo_pw_protocols) / 'base.yaml' @classmethod diff --git a/src/aiida_thermo_pw/workflows/protocols/__init__.py b/src/aiida_mechanical/workflows/protocols/__init__.py similarity index 100% rename from src/aiida_thermo_pw/workflows/protocols/__init__.py rename to src/aiida_mechanical/workflows/protocols/__init__.py diff --git a/src/aiida_thermo_pw/workflows/protocols/base.yaml b/src/aiida_mechanical/workflows/protocols/base.yaml similarity index 100% rename from src/aiida_thermo_pw/workflows/protocols/base.yaml rename to src/aiida_mechanical/workflows/protocols/base.yaml From 631ffd5e6c1adb4800676f2cf1a0ae6cd541855f Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 16:07:08 +0200 Subject: [PATCH 10/17] ci: configure automated pre-commit & tests and format code with Ruff --- .github/workflows/{.ci.yml => ci.yml} | 0 examples/example_01.py | 8 +- pyproject.toml | 1 + src/aiida_mechanical/calculations/__init__.py | 2 +- .../calculations/thermo_pw.py | 92 +- src/aiida_mechanical/data/__init__.py | 2 +- src/aiida_mechanical/data/printer.py | 18 +- src/aiida_mechanical/parsers/__init__.py | 2 +- src/aiida_mechanical/parsers/thermo_pw.py | 292 +-- src/aiida_mechanical/tools/__init__.py | 38 +- src/aiida_mechanical/tools/analyser.py | 65 +- src/aiida_mechanical/tools/plots.py | 19 +- src/aiida_mechanical/tools/structures.py | 1618 ++++++++++------- src/aiida_mechanical/tools/workchain.py | 85 +- src/aiida_mechanical/workflows/__init__.py | 4 +- src/aiida_mechanical/workflows/base.py | 87 +- utils/plot.py | 5 +- 17 files changed, 1346 insertions(+), 992 deletions(-) rename .github/workflows/{.ci.yml => ci.yml} (100%) diff --git a/.github/workflows/.ci.yml b/.github/workflows/ci.yml similarity index 100% rename from .github/workflows/.ci.yml rename to .github/workflows/ci.yml diff --git a/examples/example_01.py b/examples/example_01.py index 15c452a..561046b 100644 --- a/examples/example_01.py +++ b/examples/example_01.py @@ -3,6 +3,7 @@ Usage: ./example_01.py """ + import click from aiida import cmdline, orm @@ -25,20 +26,23 @@ def submit( run: bool = False, ): """Submit a ``Thermo_pwBaseWorkChain`` to calculate the thermodynamic properties.""" - builder = Thermo_pwBaseWorkChain.get_builder_from_protocol(code, structure=structure) + builder = Thermo_pwBaseWorkChain.get_builder_from_protocol( + code, structure=structure + ) # You can change parallelization here parallelization = { "num_mpiprocs_per_machine": 8, "npool": 4, } - set_parallelization(builder, parallelization, process_class=PwBandsWorkChain) + set_parallelization(builder, parallelization, process_class=Thermo_pwBaseWorkChain) print_builder(builder) if run: submit_and_add_group(builder, group) + @click.command() @cmdline.utils.decorators.with_dbenv() @cmdline.params.options.CODE(help="The pw.x code identified by its ID, UUID or label.") diff --git a/pyproject.toml b/pyproject.toml index 65e9657..f287ae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ lint.ignore = [ "PLR2004", # https://docs.astral.sh/ruff/rules/magic-value-comparison/ "FBT002", # https://docs.astral.sh/ruff/rules/boolean-default-value-positional-argument/ "TID252", # https://docs.astral.sh/ruff/rules/relative-imports/ + "F841", # ignore unused variables ] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["INP001", "S101"] diff --git a/src/aiida_mechanical/calculations/__init__.py b/src/aiida_mechanical/calculations/__init__.py index 9b93d51..30e2e1a 100644 --- a/src/aiida_mechanical/calculations/__init__.py +++ b/src/aiida_mechanical/calculations/__init__.py @@ -3,4 +3,4 @@ from .thermo_pw import Thermo_pwCalculation -__all__ = ['Thermo_pwCalculation'] \ No newline at end of file +__all__ = ["Thermo_pwCalculation"] diff --git a/src/aiida_mechanical/calculations/thermo_pw.py b/src/aiida_mechanical/calculations/thermo_pw.py index 9abb4fb..7fa58f2 100644 --- a/src/aiida_mechanical/calculations/thermo_pw.py +++ b/src/aiida_mechanical/calculations/thermo_pw.py @@ -9,6 +9,7 @@ from aiida_quantumespresso.utils.convert import convert_input_to_namelist_entry + class Thermo_pwCalculation(PwCalculation): """ Base class for Thermo_pw calculations. @@ -16,57 +17,68 @@ class Thermo_pwCalculation(PwCalculation): We directly reuse the definition of PwCalculation with an extra thermo_control input. """ - _DEFAULT_THERMO_CONTROL = 'thermo_control' - _OUTPUT_ELASTIC_CONSTANTS_SUBFOLDER = './elastic_constants/' - _OUTPUT_GNUPLOT_FILES_SUBFOLDER = './gnuplot_files/' - _OUTPUT_THERM_FILES_SUBFOLDER = './therm_files/' - _OUTPUT_ELASTIC_CONSTANTS_FILE = 'output_el_cons.dat.g1' - _OUTPUT_THERM_DEBYE_FILE = 'output_therm.dat_debye.g1' + _DEFAULT_THERMO_CONTROL = "thermo_control" + _OUTPUT_ELASTIC_CONSTANTS_SUBFOLDER = "./elastic_constants/" + _OUTPUT_GNUPLOT_FILES_SUBFOLDER = "./gnuplot_files/" + _OUTPUT_THERM_FILES_SUBFOLDER = "./therm_files/" + _OUTPUT_ELASTIC_CONSTANTS_FILE = "output_el_cons.dat.g1" + _OUTPUT_THERM_DEBYE_FILE = "output_therm.dat_debye.g1" _COMPULSORY_NAMELISTS = [ - 'INPUT_THERMO', - ] + "INPUT_THERMO", + ] _ENABLED_KEYWORDS = [ - ('INPUT_THERMO', 'what'), - ('INPUT_THERMO', 'find_ibrav'), - ('INPUT_THERMO', 'frozen_ions'), - ] + ("INPUT_THERMO", "what"), + ("INPUT_THERMO", "find_ibrav"), + ("INPUT_THERMO", "frozen_ions"), + ] @classmethod def define(cls, spec): super().define(spec) spec.input( - 'code', + "code", valid_type=orm.Code, - help='The thermo_pw.x code to run the calculation.' - ) + help="The thermo_pw.x code to run the calculation.", + ) spec.input( - 'thermo_control', + "thermo_control", valid_type=orm.Dict, - help='The parameters for thermo_control file.' - ) - - spec.inputs['metadata']['options']['parser_name'].default = 'thermo_pw' - spec.output('elastic_constants', valid_type=orm.ArrayData, required=False, - help='The elastic constants.') - - spec.output('therm_dat_debye', valid_type=orm.XyData, required=False, - help='The thermal properties.') + help="The parameters for thermo_control file.", + ) + + spec.inputs["metadata"]["options"]["parser_name"].default = "thermo_pw" + spec.output( + "elastic_constants", + valid_type=orm.ArrayData, + required=False, + help="The elastic constants.", + ) + + spec.output( + "therm_dat_debye", + valid_type=orm.XyData, + required=False, + help="The thermal properties.", + ) # exit codes reported by thermo_pw.x starts from 8 - spec.exit_code(801, 'ERROR_LATGEN', - message=('This error usually happens when ibrav = 0')) + spec.exit_code( + 801, "ERROR_LATGEN", message=("This error usually happens when ibrav = 0") + ) def prepare_for_submission(self, folder): # Reuse the prepare_for_submission method of PwCalculation calcinfo = super().prepare_for_submission(folder) - if 'settings' in self.inputs: - settings = _uppercase_dict(self.inputs.settings.get_dict(), dict_name='settings') + if "settings" in self.inputs: + settings = _uppercase_dict( + self.inputs.settings.get_dict(), dict_name="settings" + ) else: settings = {} @@ -78,17 +90,23 @@ def prepare_for_submission(self, folder): f"'{flag}' flag is not enabled for now." ) - with folder.open(self._DEFAULT_THERMO_CONTROL, 'w') as handle: - handle.write('&INPUT_THERMO\n') + with folder.open(self._DEFAULT_THERMO_CONTROL, "w") as handle: + handle.write("&INPUT_THERMO\n") for key, value in thermo_control.items(): - handle.write(convert_input_to_namelist_entry( key, value)) - handle.write('/\n') - - cmdline_params = self._add_parallelization_flags_to_cmdline_params(cmdline_params=settings.pop('CMDLINE', [])) + handle.write(convert_input_to_namelist_entry(key, value)) + handle.write("/\n") + cmdline_params = self._add_parallelization_flags_to_cmdline_params( + cmdline_params=settings.pop("CMDLINE", []) + ) calcinfo.retrieve_list.append(self._DEFAULT_THERMO_CONTROL) - calcinfo.retrieve_list.append(self._OUTPUT_ELASTIC_CONSTANTS_SUBFOLDER + self._OUTPUT_ELASTIC_CONSTANTS_FILE) - calcinfo.retrieve_list.append(self._OUTPUT_THERM_FILES_SUBFOLDER + self._OUTPUT_THERM_DEBYE_FILE) + calcinfo.retrieve_list.append( + self._OUTPUT_ELASTIC_CONSTANTS_SUBFOLDER + + self._OUTPUT_ELASTIC_CONSTANTS_FILE + ) + calcinfo.retrieve_list.append( + self._OUTPUT_THERM_FILES_SUBFOLDER + self._OUTPUT_THERM_DEBYE_FILE + ) return calcinfo diff --git a/src/aiida_mechanical/data/__init__.py b/src/aiida_mechanical/data/__init__.py index 4346f41..0b190a0 100644 --- a/src/aiida_mechanical/data/__init__.py +++ b/src/aiida_mechanical/data/__init__.py @@ -1,3 +1,3 @@ from .printer import Printer -__all__ = ('Printer',) \ No newline at end of file +__all__ = ("Printer",) diff --git a/src/aiida_mechanical/data/printer.py b/src/aiida_mechanical/data/printer.py index 5f645d2..3761d23 100644 --- a/src/aiida_mechanical/data/printer.py +++ b/src/aiida_mechanical/data/printer.py @@ -1,14 +1,14 @@ from collections.abc import Mapping, Iterable -from rich import print as rprint class Printer: - prefix_item = '├── ' - prefix_last_item = '└── ' - prefix_indent = ' ' - prefix_parent = '│ ' + prefix_item = "├── " + prefix_last_item = "└── " + prefix_indent = " " + prefix_parent = "│ " _print = print + def __init__(self, data: dict | list): """ Args: @@ -32,7 +32,7 @@ def _print_recursive(self, data, prefix: str = ""): items = list(data.items()) if is_mapping else list(enumerate(data)) for i, item in enumerate(items): - is_last = (i == len(items) - 1) + is_last = i == len(items) - 1 connector = self.prefix_last_item if is_last else self.prefix_item if is_mapping: @@ -43,11 +43,13 @@ def _print_recursive(self, data, prefix: str = ""): self._print(f"{prefix}{connector}[{index}]") # prepare prefix for next level - new_prefix = prefix + (self.prefix_indent if is_last else self.prefix_parent) + new_prefix = prefix + ( + self.prefix_indent if is_last else self.prefix_parent + ) # recursive condition: value is a map or non-string iterable if isinstance(value, (Mapping, Iterable)) and not isinstance(value, str): self._print_recursive(value, new_prefix) else: # if value is a leaf node (basic type), print it directly - self._print(f"{new_prefix}{self.prefix_last_item}{repr(value)}") \ No newline at end of file + self._print(f"{new_prefix}{self.prefix_last_item}{repr(value)}") diff --git a/src/aiida_mechanical/parsers/__init__.py b/src/aiida_mechanical/parsers/__init__.py index d479157..7f76ad5 100644 --- a/src/aiida_mechanical/parsers/__init__.py +++ b/src/aiida_mechanical/parsers/__init__.py @@ -1,3 +1,3 @@ from .thermo_pw import Thermo_pwParser -__all__ = ['Thermo_pwParser'] \ No newline at end of file +__all__ = ["Thermo_pwParser"] diff --git a/src/aiida_mechanical/parsers/thermo_pw.py b/src/aiida_mechanical/parsers/thermo_pw.py index 50448b1..f029ba9 100644 --- a/src/aiida_mechanical/parsers/thermo_pw.py +++ b/src/aiida_mechanical/parsers/thermo_pw.py @@ -1,4 +1,5 @@ """Parser for the thermo_pw.x calculation.""" + import re from aiida import orm import numpy @@ -9,6 +10,7 @@ from aiida_quantumespresso.utils.mapping import get_logging_container + class Thermo_pwParser(BaseParser): """Parser for the thermo_pw.x calculation.""" @@ -16,15 +18,20 @@ class Thermo_pwParser(BaseParser): _PREFIX = Thermo_pwCalculation._PREFIX _OUTPUT_SUBFOLDER = Thermo_pwCalculation._OUTPUT_SUBFOLDER - _OUTPUT_ELASTIC_CONSTANTS_SUBFOLDER = Thermo_pwCalculation._OUTPUT_ELASTIC_CONSTANTS_SUBFOLDER - _OUTPUT_GNUPLOT_FILES_SUBFOLDER = Thermo_pwCalculation._OUTPUT_GNUPLOT_FILES_SUBFOLDER + _OUTPUT_ELASTIC_CONSTANTS_SUBFOLDER = ( + Thermo_pwCalculation._OUTPUT_ELASTIC_CONSTANTS_SUBFOLDER + ) + _OUTPUT_GNUPLOT_FILES_SUBFOLDER = ( + Thermo_pwCalculation._OUTPUT_GNUPLOT_FILES_SUBFOLDER + ) _OUTPUT_THERM_FILES_SUBFOLDER = Thermo_pwCalculation._OUTPUT_THERM_FILES_SUBFOLDER _OUTPUT_ELASTIC_CONSTANTS_FILE = Thermo_pwCalculation._OUTPUT_ELASTIC_CONSTANTS_FILE _OUTPUT_THERM_DEBYE_FILE = Thermo_pwCalculation._OUTPUT_THERM_DEBYE_FILE class_error_map = { - 'Error in routine latgen': 'ERROR_LATGEN', + "Error in routine latgen": "ERROR_LATGEN", } + def parse(self, **kwargs): """Parse the retrieved files of a completed ``EpwCalculation`` into output nodes.""" logs = get_logging_container() @@ -40,16 +47,35 @@ def parse(self, **kwargs): parsed_data.update(parsed_thermo_pw) # --- elastic constants --- - if self._OUTPUT_ELASTIC_CONSTANTS_FILE in self.retrieved.base.repository.list_object_names(): - elastic_constants_contents = self.retrieved.base.repository.get_object_content(self._OUTPUT_ELASTIC_CONSTANTS_FILE) - self.out('elastic_constants', self.parse_elastic_constants(elastic_constants_contents)) + if ( + self._OUTPUT_ELASTIC_CONSTANTS_FILE + in self.retrieved.base.repository.list_object_names() + ): + elastic_constants_contents = ( + self.retrieved.base.repository.get_object_content( + self._OUTPUT_ELASTIC_CONSTANTS_FILE + ) + ) + self.out( + "elastic_constants", + self.parse_elastic_constants(elastic_constants_contents), + ) # --- thermal properties --- - if self._OUTPUT_THERM_DEBYE_FILE in self.retrieved.base.repository.list_object_names(): - therm_dat_debye_contents = self.retrieved.base.repository.get_object_content(self._OUTPUT_THERM_DEBYE_FILE) - self.out('therm_dat_debye', self.parse_therm_dat_debye(therm_dat_debye_contents)) + if ( + self._OUTPUT_THERM_DEBYE_FILE + in self.retrieved.base.repository.list_object_names() + ): + therm_dat_debye_contents = ( + self.retrieved.base.repository.get_object_content( + self._OUTPUT_THERM_DEBYE_FILE + ) + ) + self.out( + "therm_dat_debye", self.parse_therm_dat_debye(therm_dat_debye_contents) + ) - self.out('output_parameters', orm.Dict(parsed_data)) + self.out("output_parameters", orm.Dict(parsed_data)) for exit_code in list(self.get_error_map().values()): if exit_code in logs.error: @@ -57,12 +83,13 @@ def parse(self, **kwargs): # return self.exit(logs=logs) - if 'ERROR_OUTPUT_STDOUT_INCOMPLETE' in logs.error: - return self.exit(self.exit_codes.get('ERROR_OUTPUT_STDOUT_INCOMPLETE'), logs) + if "ERROR_OUTPUT_STDOUT_INCOMPLETE" in logs.error: + return self.exit( + self.exit_codes.get("ERROR_OUTPUT_STDOUT_INCOMPLETE"), logs + ) return self.exit(logs=logs) - @staticmethod def parse_stdout(stdout, logs): """Parse the ``stdout``.""" @@ -74,27 +101,33 @@ def parse_stdout(stdout, logs): data_type_regex = [ # --- general information --- - ('space_group_number', int, re.compile(r'Space group number\s+(\d+)')), - ('space_group_symbol', str, re.compile(r'Space group\s+([Ff][m\-3m]+)')), - ('laue_class', str, re.compile(r'The Laue class is\s+(.*)')), - ('required_strains', str, re.compile(r'It requires .* strains:\s*(.*)')), - ('total_scf_calculations', int, re.compile(r'for a total of\s+(\d+)\s+scf calculations')), - + ("space_group_number", int, re.compile(r"Space group number\s+(\d+)")), + ("space_group_symbol", str, re.compile(r"Space group\s+([Ff][m\-3m]+)")), + ("laue_class", str, re.compile(r"The Laue class is\s+(.*)")), + ("required_strains", str, re.compile(r"It requires .* strains:\s*(.*)")), + ( + "total_scf_calculations", + int, + re.compile(r"for a total of\s+(\d+)\s+scf calculations"), + ), # --- Voigt approximation --- - # --- Reuss approximation --- - # --- Voigt-Reuss-Hill average --- - # --- Voigt-Reuss-Hill sound velocities --- - # --- Debye temperatures and miscellaneous --- - ('approx_debye_temp', float, re.compile(r"The approximate Debye temperature is\s*" + FLOAT_NUM)), - ('avg_debye_velocity', float, re.compile(r"Average Debye sound velocity\s*=\s*" + FLOAT_NUM)), - ('debye_temp', float, re.compile(r"Debye temperature\s*=\s*" + FLOAT_NUM)), - + ( + "approx_debye_temp", + float, + re.compile(r"The approximate Debye temperature is\s*" + FLOAT_NUM), + ), + ( + "avg_debye_velocity", + float, + re.compile(r"Average Debye sound velocity\s*=\s*" + FLOAT_NUM), + ), + ("debye_temp", float, re.compile(r"Debye temperature\s*=\s*" + FLOAT_NUM)), ] - stdout_lines = stdout.split('\n') + stdout_lines = stdout.split("\n") for line_number, line in enumerate(stdout_lines): for data_key, data_type, re_pattern in data_type_regex: @@ -106,15 +139,17 @@ def parse_stdout(stdout, logs): def parse_elastic_constants_type(stdout): single_row_pattern = r"\s*\(\s*(?:c\d{2}|\.)(?:\s+(?:c\d{2}|\.)){5}\s*\)" re_pattern = re.compile( - r'In this class the elastic tensor is\s*\n\s*' - r'(' - r'(?:' + single_row_pattern + r'[\r\n\s]*){6}' - r')' - ) + r"In this class the elastic tensor is\s*\n\s*" + r"(" + r"(?:" + single_row_pattern + r"[\r\n\s]*){6}" + r")" + ) match = re_pattern.search(stdout) if match: elastic_tensor_raw = str(match.group(1)) - elastic_constants_type = [line.split()[1:-1] for line in elastic_tensor_raw.split('\n')[:6]] + elastic_constants_type = [ + line.split()[1:-1] for line in elastic_tensor_raw.split("\n")[:6] + ] return elastic_constants_type else: @@ -123,16 +158,14 @@ def parse_elastic_constants_type(stdout): elastic_constants_type = parse_elastic_constants_type(stdout) if elastic_constants_type: - parsed_data['elastic_constants_type'] = elastic_constants_type + parsed_data["elastic_constants_type"] = elastic_constants_type # --- elastic constants fitting information --- def parse_elastic_constants_fitting(stdout): block_pattern = re.compile( r"Elastic constant\s+(\d+)\s+(\d+)\s*" - r"strain\s+stress \(kbar\)\s*" r"((?:\s*-?\d+\.\d+\s+[-.\dE+]+\s*)+)" - r"\s*Polynomial coefficients\s*" r"a1=\s*([-\d\.E+]+)\s*" r"a2=\s*([-\d\.E+]+)\s*" @@ -148,7 +181,9 @@ def parse_elastic_constants_fitting(stdout): strains = [] stresses = [] - strain_stress_pairs = re.findall(r'(-?\d+\.\d+)\s+([-\d\.E+]+)', table_raw_string) + strain_stress_pairs = re.findall( + r"(-?\d+\.\d+)\s+([-\d\.E+]+)", table_raw_string + ) for strain, stress in strain_stress_pairs: strains.append(float(strain)) stresses.append(float(stress)) @@ -156,98 +191,132 @@ def parse_elastic_constants_fitting(stdout): coefficients = [ float(match.group(4)), float(match.group(5)), - float(match.group(6)) + float(match.group(6)), ] if i not in elastic_constants_fitting: elastic_constants_fitting[i] = {} elastic_constants_fitting[i][j] = { - 'strains': strains, - 'stresses': stresses, - 'coefficients': coefficients + "strains": strains, + "stresses": stresses, + "coefficients": coefficients, } return elastic_constants_fitting elastic_constants_fitting = parse_elastic_constants_fitting(stdout) - parsed_data['elastic_constants_fitting'] = elastic_constants_fitting + parsed_data["elastic_constants_fitting"] = elastic_constants_fitting def parse_moduli(stdout): import re - results = { - 'moduli': {}, - 'sound_velocities': {} - } + + results = {"moduli": {}, "sound_velocities": {}} # Define all properties and their regex in one structure # CORRECTED: All value-capturing groups now use (-?[\d\.]+) definitions = { - 'voigt': { - 'block_regex': re.compile(r"Voigt approximation:\s*([\s\S]*?)(?=\n\s*Reuss approximation:|\Z)"), - 'target_dict': results['moduli'], - 'properties': { - 'bulk_modulus_B': re.compile(r"Bulk modulus\s+B\s*=\s*(-?[\d\.]+)"), - 'young_modulus_E': re.compile(r"Young modulus\s+E\s*=\s*(-?[\d\.]+)"), - 'shear_modulus_G': re.compile(r"Shear modulus\s+G\s*=\s*(-?[\d\.]+)"), - 'poisson_ratio_n': re.compile(r"Poisson Ratio\s+n\s*=\s*(-?[\d\.]+)"), - 'pugh_ratio_r': re.compile(r"Pugh Ratio\s+r\s*=\s*(-?[\d\.]+)") - } + "voigt": { + "block_regex": re.compile( + r"Voigt approximation:\s*([\s\S]*?)(?=\n\s*Reuss approximation:|\Z)" + ), + "target_dict": results["moduli"], + "properties": { + "bulk_modulus_B": re.compile( + r"Bulk modulus\s+B\s*=\s*(-?[\d\.]+)" + ), + "young_modulus_E": re.compile( + r"Young modulus\s+E\s*=\s*(-?[\d\.]+)" + ), + "shear_modulus_G": re.compile( + r"Shear modulus\s+G\s*=\s*(-?[\d\.]+)" + ), + "poisson_ratio_n": re.compile( + r"Poisson Ratio\s+n\s*=\s*(-?[\d\.]+)" + ), + "pugh_ratio_r": re.compile(r"Pugh Ratio\s+r\s*=\s*(-?[\d\.]+)"), + }, }, - 'reuss': { - 'block_regex': re.compile(r"Reuss approximation:\s*([\s\S]*?)(?=\n\s*Voigt-Reuss-Hill average|\Z)"), - 'target_dict': results['moduli'], - 'properties': { - 'bulk_modulus_B': re.compile(r"Bulk modulus\s+B\s*=\s*(-?[\d\.]+)"), - 'young_modulus_E': re.compile(r"Young modulus\s+E\s*=\s*(-?[\d\.]+)"), - 'shear_modulus_G': re.compile(r"Shear modulus\s+G\s*=\s*(-?[\d\.]+)"), - 'poisson_ratio_n': re.compile(r"Poisson Ratio\s+n\s*=\s*(-?[\d\.]+)"), - 'pugh_ratio_r': re.compile(r"Pugh Ratio\s+r\s*=\s*(-?[\d\.]+)") - } + "reuss": { + "block_regex": re.compile( + r"Reuss approximation:\s*([\s\S]*?)(?=\n\s*Voigt-Reuss-Hill average|\Z)" + ), + "target_dict": results["moduli"], + "properties": { + "bulk_modulus_B": re.compile( + r"Bulk modulus\s+B\s*=\s*(-?[\d\.]+)" + ), + "young_modulus_E": re.compile( + r"Young modulus\s+E\s*=\s*(-?[\d\.]+)" + ), + "shear_modulus_G": re.compile( + r"Shear modulus\s+G\s*=\s*(-?[\d\.]+)" + ), + "poisson_ratio_n": re.compile( + r"Poisson Ratio\s+n\s*=\s*(-?[\d\.]+)" + ), + "pugh_ratio_r": re.compile(r"Pugh Ratio\s+r\s*=\s*(-?[\d\.]+)"), + }, }, - 'vrh': { - 'block_regex': re.compile(r"Voigt-Reuss-Hill average of the two approximations:\s*([\s\S]*?)(?=\n\s*Voigt-Reuss-Hill average; sound velocities:|\Z)"), - 'target_dict': results['moduli'], - 'properties': { - 'bulk_modulus_B': re.compile(r"Bulk modulus\s+B\s*=\s*(-?[\d\.]+)"), - 'young_modulus_E': re.compile(r"Young modulus\s+E\s*=\s*(-?[\d\.]+)"), - 'shear_modulus_G': re.compile(r"Shear modulus\s+G\s*=\s*(-?[\d\.]+)"), - 'longitudinal_modulus_L': re.compile(r"Longitudinal modulus\s+L\s*=\s*(-?[\d\.]+)"), - 'poisson_ratio_n': re.compile(r"Poisson Ratio\s+n\s*=\s*(-?[\d\.]+)"), - 'pugh_ratio_r': re.compile(r"Pugh Ratio\s+r\s*=\s*(-?[\d\.]+)") - } + "vrh": { + "block_regex": re.compile( + r"Voigt-Reuss-Hill average of the two approximations:\s*([\s\S]*?)(?=\n\s*Voigt-Reuss-Hill average; sound velocities:|\Z)" + ), + "target_dict": results["moduli"], + "properties": { + "bulk_modulus_B": re.compile( + r"Bulk modulus\s+B\s*=\s*(-?[\d\.]+)" + ), + "young_modulus_E": re.compile( + r"Young modulus\s+E\s*=\s*(-?[\d\.]+)" + ), + "shear_modulus_G": re.compile( + r"Shear modulus\s+G\s*=\s*(-?[\d\.]+)" + ), + "longitudinal_modulus_L": re.compile( + r"Longitudinal modulus\s+L\s*=\s*(-?[\d\.]+)" + ), + "poisson_ratio_n": re.compile( + r"Poisson Ratio\s+n\s*=\s*(-?[\d\.]+)" + ), + "pugh_ratio_r": re.compile(r"Pugh Ratio\s+r\s*=\s*(-?[\d\.]+)"), + }, + }, + "velocities": { + "block_regex": re.compile( + r"Voigt-Reuss-Hill average; sound velocities:\s*([\s\S]*?)(?=\n\s*\n|\Z)" + ), + "target_dict": results, # The target is the top-level results for velocities + "target_key": "sound_velocities", # The key within the target dict + "properties": { + "compressional_V_P": re.compile( + r"Compressional\s+V_P\s*=\s*(-?[\d\.]+)" + ), + "bulk_V_B": re.compile(r"Bulk\s+V_B\s*=\s*(-?[\d\.]+)"), + "shear_V_G": re.compile(r"Shear\s+V_G\s*=\s*(-?[\d\.]+)"), + }, }, - 'velocities': { - 'block_regex': re.compile(r"Voigt-Reuss-Hill average; sound velocities:\s*([\s\S]*?)(?=\n\s*\n|\Z)"), - 'target_dict': results, # The target is the top-level results for velocities - 'target_key': 'sound_velocities', # The key within the target dict - 'properties': { - 'compressional_V_P': re.compile(r"Compressional\s+V_P\s*=\s*(-?[\d\.]+)"), - 'bulk_V_B': re.compile(r"Bulk\s+V_B\s*=\s*(-?[\d\.]+)"), - 'shear_V_G': re.compile(r"Shear\s+V_G\s*=\s*(-?[\d\.]+)") - } - } } # A single loop to process everything for block_name, definition in definitions.items(): - block_match = definition['block_regex'].search(stdout) + block_match = definition["block_regex"].search(stdout) if not block_match: continue block_text = block_match.group(1) # Determine where to store the results for this block - if 'target_key' in definition: + if "target_key" in definition: # For sound_velocities, which is a direct key in results - storage = definition['target_dict'][definition['target_key']] + storage = definition["target_dict"][definition["target_key"]] else: # For moduli, which are nested under their block name (voigt, reuss, etc.) - definition['target_dict'][block_name] = {} - storage = definition['target_dict'][block_name] + definition["target_dict"][block_name] = {} + storage = definition["target_dict"][block_name] # Parse properties within the block - for prop_name, prop_regex in definition['properties'].items(): + for prop_name, prop_regex in definition["properties"].items(): prop_match = prop_regex.search(block_text) if prop_match: storage[prop_name] = float(prop_match.group(1)) @@ -261,32 +330,33 @@ def parse_moduli(stdout): @staticmethod def parse_elastic_constants(content): - - import io elastic_constants_array = orm.ArrayData() - matrix_blocks = content.strip().split('\n \n') + matrix_blocks = content.strip().split("\n \n") - elastic_constants = numpy.array(matrix_blocks[0].split(), dtype=float).reshape((6, 6)) - elastic_compliances = numpy.array(matrix_blocks[1].split(), dtype=float).reshape((6, 6)) + elastic_constants = numpy.array(matrix_blocks[0].split(), dtype=float).reshape( + (6, 6) + ) + elastic_compliances = numpy.array( + matrix_blocks[1].split(), dtype=float + ).reshape((6, 6)) - elastic_constants_array.set_array('elastic_constants', elastic_constants) - elastic_constants_array.set_array('elastic_compliances', elastic_compliances) + elastic_constants_array.set_array("elastic_constants", elastic_constants) + elastic_constants_array.set_array("elastic_compliances", elastic_compliances) return elastic_constants_array @staticmethod def parse_therm_dat_debye(content): import io + therm_dat_debye_xydata = orm.XyData() therm_dat_debye = numpy.loadtxt( - io.StringIO((content)), - dtype=float, - comments='#' - ) - therm_dat_debye_xydata.set_array('Temperature', therm_dat_debye[:, 0]) - therm_dat_debye_xydata.set_array('Energy', therm_dat_debye[:, 1]) - therm_dat_debye_xydata.set_array('Free_energy', therm_dat_debye[:, 2]) - therm_dat_debye_xydata.set_array('Entropy', therm_dat_debye[:, 3]) - therm_dat_debye_xydata.set_array('Cv', therm_dat_debye[:, 4]) - return therm_dat_debye_xydata \ No newline at end of file + io.StringIO((content)), dtype=float, comments="#" + ) + therm_dat_debye_xydata.set_array("Temperature", therm_dat_debye[:, 0]) + therm_dat_debye_xydata.set_array("Energy", therm_dat_debye[:, 1]) + therm_dat_debye_xydata.set_array("Free_energy", therm_dat_debye[:, 2]) + therm_dat_debye_xydata.set_array("Entropy", therm_dat_debye[:, 3]) + therm_dat_debye_xydata.set_array("Cv", therm_dat_debye[:, 4]) + return therm_dat_debye_xydata diff --git a/src/aiida_mechanical/tools/__init__.py b/src/aiida_mechanical/tools/__init__.py index 3766ac0..fba6420 100644 --- a/src/aiida_mechanical/tools/__init__.py +++ b/src/aiida_mechanical/tools/__init__.py @@ -1,9 +1,5 @@ from .plots import plot_moduli_group -from .workchain import ( - get_workdirs, - delete_nodes_and_remote_folder, - check_process_state - ) +from .workchain import get_workdirs, delete_nodes_and_remote_folder, check_process_state from .analyser import ThermoPwBaseAnalyser, ThermoPwBaseWorkChainState from .structures import ( get_ibrav_ase, @@ -18,19 +14,19 @@ ) __all__ = [ - 'plot_moduli_group', - 'get_workdirs', - 'delete_nodes_and_remote_folder', - 'check_process_state', - 'ThermoPwBaseAnalyser', - 'ThermoPwBaseWorkChainState', - 'get_ibrav_ase', - 'get_parameters_from_structure', - 'get_cell_qe_convention', - 'get_cellpar', - 'get_standardized_structure_pymatgen', - 'convert_standardized_structure_pymatgen_to_qe', - 'base_transformation', - 'ibrav_bravais_lattice_map_qe', - 'ibrav_bravais_lattice_map_ase', -] \ No newline at end of file + "plot_moduli_group", + "get_workdirs", + "delete_nodes_and_remote_folder", + "check_process_state", + "ThermoPwBaseAnalyser", + "ThermoPwBaseWorkChainState", + "get_ibrav_ase", + "get_parameters_from_structure", + "get_cell_qe_convention", + "get_cellpar", + "get_standardized_structure_pymatgen", + "convert_standardized_structure_pymatgen_to_qe", + "base_transformation", + "ibrav_bravais_lattice_map_qe", + "ibrav_bravais_lattice_map_ase", +] diff --git a/src/aiida_mechanical/tools/analyser.py b/src/aiida_mechanical/tools/analyser.py index a41ebd6..16d629f 100644 --- a/src/aiida_mechanical/tools/analyser.py +++ b/src/aiida_mechanical/tools/analyser.py @@ -1,15 +1,14 @@ -from re import S from aiida import orm -from aiida.common.links import LinkType from aiida.engine import ProcessState from enum import Enum from collections import OrderedDict -from abc import ABC, abstractmethod -from rich import print as rprint + + class ThermoPwBaseWorkChainState(Enum): """ Analyser for the ThermoPwBaseWorkChain. """ + FINISHED_OK = 0 WAITING = 1 RUNNING = 2 @@ -20,20 +19,26 @@ class ThermoPwBaseWorkChainState(Enum): ERROR_NSTEP = 7 UNKNOWN = 999 + class ThermoPwBaseAnalyser: """ Analyser for the ThermoPwBaseWorkChain. """ - _all_descendants = OrderedDict([ - ('thermo_pw', None), - ]) + + _all_descendants = OrderedDict( + [ + ("thermo_pw", None), + ] + ) def __init__(self, workchain: orm.WorkChainNode): self.node = workchain self.state = ThermoPwBaseWorkChainState.UNKNOWN self.descendants = {} for link_label, _ in self._all_descendants.items(): - descendants = workchain.base.links.get_outgoing(link_label_filter=link_label).all_nodes() + descendants = workchain.base.links.get_outgoing( + link_label_filter=link_label + ).all_nodes() if descendants != []: self.descendants[link_label] = descendants @@ -41,21 +46,20 @@ def get_iterations(self): """Get the iterations of the workchain.""" iterations = [] - for (node, link_type, link_label) in self.node.base.links.get_outgoing().all(): - if link_label.startswith('iteration'): + for node, link_type, link_label in self.node.base.links.get_outgoing().all(): + if link_label.startswith("iteration"): iterations.append(node) return iterations def check_process_state(self): - - from collections import deque - from io import StringIO - state = ThermoPwBaseWorkChainState.UNKNOWN - message = '' - + message = "" - source_db, source_id = self.node.inputs.thermo_pw.structure.base.extras.get_many(('source_db', 'source_id')) + source_db, source_id = ( + self.node.inputs.thermo_pw.structure.base.extras.get_many( + ("source_db", "source_id") + ) + ) source = f"{source_db}-{source_id}" if self.node.process_state == ProcessState.WAITING: @@ -79,21 +83,33 @@ def check_process_state(self): message = f"{self.node.process_state} excepted in final iteration [{final_iteration.pk}]" return state, message stderr = final_iteration.get_scheduler_stderr() - aiida_out = final_iteration.outputs.retrieved.get_object_content("aiida.out") + aiida_out = final_iteration.outputs.retrieved.get_object_content( + "aiida.out" + ) for error, error_message in ( - (ThermoPwBaseWorkChainState.ERROR_TOO_MANY_PROCESSES, 'there are processes with no planes.'), - (ThermoPwBaseWorkChainState.ERROR_TIME_LIMIT, 'TIME LIMIT'), + ( + ThermoPwBaseWorkChainState.ERROR_TOO_MANY_PROCESSES, + "there are processes with no planes.", + ), + (ThermoPwBaseWorkChainState.ERROR_TIME_LIMIT, "TIME LIMIT"), ): if error_message in stderr: state = error - message = f"{error_message} in final iteration [{final_iteration.pk}]" + message = ( + f"{error_message} in final iteration [{final_iteration.pk}]" + ) return state, message for error, error_message in ( - (ThermoPwBaseWorkChainState.ERROR_NSTEP, 'Incorrect nstep, check elastic_algorithm'), + ( + ThermoPwBaseWorkChainState.ERROR_NSTEP, + "Incorrect nstep, check elastic_algorithm", + ), ): if error_message in aiida_out: state = error - message = f"{error_message} in final iteration [{final_iteration.pk}]" + message = ( + f"{error_message} in final iteration [{final_iteration.pk}]" + ) return state, message state = ThermoPwBaseWorkChainState.FINISHED_OK message = f"{self.node.process_state} finished with exit status {self.node.exit_status} and final iteration [{final_iteration.pk}]" @@ -109,7 +125,6 @@ def check_process_state(self): return state, message - def delete_nodes_and_remote_folder( self, ): @@ -123,4 +138,4 @@ def delete_nodes_and_remote_folder( except (IOError, OSError, KeyError, NotExistentAttributeError): pass - delete_nodes([self.node.pk], dry_run=False) \ No newline at end of file + delete_nodes([self.node.pk], dry_run=False) diff --git a/src/aiida_mechanical/tools/plots.py b/src/aiida_mechanical/tools/plots.py index 313013a..e8bd09f 100644 --- a/src/aiida_mechanical/tools/plots.py +++ b/src/aiida_mechanical/tools/plots.py @@ -1,23 +1,24 @@ from aiida import orm + def plot_moduli_group( ax, group: str, keys: list[str], - formula: str = 'vrh', + formula: str = "vrh", **kwargs, ): if isinstance(group, str): qb = orm.QueryBuilder() qb.append( orm.Group, - filters={'label': group}, - tag='group', + filters={"label": group}, + tag="group", ).append( orm.WorkChainNode, - with_group='group', + with_group="group", filters={"attributes.exit_status": 0}, - tag='wc', + tag="wc", ) else: raise ValueError("Group name must be str") @@ -27,6 +28,10 @@ def plot_moduli_group( x = [] y = [] for node in qb.all(flat=True): - x.append(node.outputs.output_parameters.get('moduli').get(formula).get(keys[0])) - y.append(node.outputs.output_parameters.get('moduli').get(formula).get(keys[1])) + x.append( + node.outputs.output_parameters.get("moduli").get(formula).get(keys[0]) + ) + y.append( + node.outputs.output_parameters.get("moduli").get(formula).get(keys[1]) + ) ax.scatter(x, y, **kwargs) diff --git a/src/aiida_mechanical/tools/structures.py b/src/aiida_mechanical/tools/structures.py index 13f0284..fe1d090 100644 --- a/src/aiida_mechanical/tools/structures.py +++ b/src/aiida_mechanical/tools/structures.py @@ -1,81 +1,118 @@ -from re import A import numpy as np import numpy.linalg as la from pymatgen.core.operations import SymmOp _rotation_matrices_cartesian = { - 'identity': [[1, 0, 0], [0, 1, 0], [0, 0, 1]], - 'rot.60 deg, x-axis': [[1, 0, 0], [0, 0.5, -np.sqrt(3)/2], [0, np.sqrt(3)/2, 0.5]], - 'rot.60 deg, y-axis': [[0.5, 0, np.sqrt(3)/2], [0, 1, 0], [-np.sqrt(3)/2, 0, 0.5]], - 'rot.60 deg, z-axis': [[0.5, -np.sqrt(3)/2, 0], [np.sqrt(3)/2, 0.5, 0], [0, 0, 1]], - 'rot.90 deg, x-axis': [[1, 0, 0], [0, 0, -1], [0, 1, 0]], - 'rot.90 deg, y-axis': [[0, 0, 1], [0, 1, 0], [-1, 0, 0]], - 'rot.90 deg, z-axis': [[0, -1, 0], [1, 0, 0], [0, 0, 1]], - 'rot.120 deg, x-axis': [[1, 0, 0], [0, -0.5, -np.sqrt(3)/2], [0, np.sqrt(3)/2, -0.5]], - 'rot.120 deg, y-axis': [[-0.5, 0, np.sqrt(3)/2], [0, 1, 0], [-0.5, 0, -np.sqrt(3)/2]], - 'rot.120 deg, z-axis': [[-0.5, -np.sqrt(3)/2, 0], [np.sqrt(3)/2, -0.5, 0], [0, 0, 1]], - 'rot.180 deg, x-axis': [[1, 0, 0], [0, -1, 0], [0, 0, -1]], - 'rot.180 deg, y-axis': [[-1, 0, 0], [0, 1, 0], [0, 0, -1]], - 'rot.180 deg, z-axis': [[-1, 0, 0], [0, -1, 0], [0, 0, 1]], - 'rot.-60 deg, x-axis': [[1, 0, 0], [0, 0.5, np.sqrt(3)/2], [0, -np.sqrt(3)/2, 0.5]], - 'rot.-60 deg, y-axis': [[0.5, 0, -np.sqrt(3)/2], [0, 1, 0], [np.sqrt(3)/2, 0, 0.5]], - 'rot.-60 deg, z-axis': [[0.5, np.sqrt(3)/2, 0], [-np.sqrt(3)/2, 0.5, 0], [0, 0, 1]], - } + "identity": [[1, 0, 0], [0, 1, 0], [0, 0, 1]], + "rot.60 deg, x-axis": [ + [1, 0, 0], + [0, 0.5, -np.sqrt(3) / 2], + [0, np.sqrt(3) / 2, 0.5], + ], + "rot.60 deg, y-axis": [ + [0.5, 0, np.sqrt(3) / 2], + [0, 1, 0], + [-np.sqrt(3) / 2, 0, 0.5], + ], + "rot.60 deg, z-axis": [ + [0.5, -np.sqrt(3) / 2, 0], + [np.sqrt(3) / 2, 0.5, 0], + [0, 0, 1], + ], + "rot.90 deg, x-axis": [[1, 0, 0], [0, 0, -1], [0, 1, 0]], + "rot.90 deg, y-axis": [[0, 0, 1], [0, 1, 0], [-1, 0, 0]], + "rot.90 deg, z-axis": [[0, -1, 0], [1, 0, 0], [0, 0, 1]], + "rot.120 deg, x-axis": [ + [1, 0, 0], + [0, -0.5, -np.sqrt(3) / 2], + [0, np.sqrt(3) / 2, -0.5], + ], + "rot.120 deg, y-axis": [ + [-0.5, 0, np.sqrt(3) / 2], + [0, 1, 0], + [-0.5, 0, -np.sqrt(3) / 2], + ], + "rot.120 deg, z-axis": [ + [-0.5, -np.sqrt(3) / 2, 0], + [np.sqrt(3) / 2, -0.5, 0], + [0, 0, 1], + ], + "rot.180 deg, x-axis": [[1, 0, 0], [0, -1, 0], [0, 0, -1]], + "rot.180 deg, y-axis": [[-1, 0, 0], [0, 1, 0], [0, 0, -1]], + "rot.180 deg, z-axis": [[-1, 0, 0], [0, -1, 0], [0, 0, 1]], + "rot.-60 deg, x-axis": [ + [1, 0, 0], + [0, 0.5, np.sqrt(3) / 2], + [0, -np.sqrt(3) / 2, 0.5], + ], + "rot.-60 deg, y-axis": [ + [0.5, 0, -np.sqrt(3) / 2], + [0, 1, 0], + [np.sqrt(3) / 2, 0, 0.5], + ], + "rot.-60 deg, z-axis": [ + [0.5, np.sqrt(3) / 2, 0], + [-np.sqrt(3) / 2, 0.5, 0], + [0, 0, 1], + ], +} ibrav_bravais_lattice_map_qe = { - 1: 'CUB', - 2: 'FCC', - 3: 'BCC', - 4: 'HEX', - 5: 'RHL', - 6: 'TET', - 7: 'BCT', - 8: 'ORC', - 9: 'ORCC', - 91: 'ORCC-A', - 10: 'ORCF', - 11: 'ORCI', - 12: 'MCL-A', - -12: 'MCL-B', - 13: 'MCLC-C', - -13: 'MCLC-B', - 14: 'TRI', + 1: "CUB", + 2: "FCC", + 3: "BCC", + 4: "HEX", + 5: "RHL", + 6: "TET", + 7: "BCT", + 8: "ORC", + 9: "ORCC", + 91: "ORCC-A", + 10: "ORCF", + 11: "ORCI", + 12: "MCL-A", + -12: "MCL-B", + 13: "MCLC-C", + -13: "MCLC-B", + 14: "TRI", } ibrav_bravais_lattice_map_ase = { - 'CUB': 1, - 'FCC': 2, - 'BCC': 3, - 'HEX': 4, - 'RHL': 5, - 'TET': 6, - 'BCT': 7, - 'ORC': 8, - 'ORCC': 9, - 'ORCF': 10, - 'ORCI': 11, - 'MCL': 12, - 'MCLC': 13, - 'TRI': 14, + "CUB": 1, + "FCC": 2, + "BCC": 3, + "HEX": 4, + "RHL": 5, + "TET": 6, + "BCT": 7, + "ORC": 8, + "ORCC": 9, + "ORCF": 10, + "ORCI": 11, + "MCL": 12, + "MCLC": 13, + "TRI": 14, } crystal_system_to_space_group_number = { - 'triclinic': [1, 2], - 'monoclinic': [3, 15], - 'orthorhombic': [16, 74], - 'tetragonal': [75, 142], - 'trigonal': [143, 167], - 'hexagonal': [149, 194], - 'cubic': [195, 230], + "triclinic": [1, 2], + "monoclinic": [3, 15], + "orthorhombic": [16, 74], + "tetragonal": [75, 142], + "trigonal": [143, 167], + "hexagonal": [149, 194], + "cubic": [195, 230], } + def is_identity(left_matrix, right_matrix, atol=1e-6): return np.allclose(left_matrix, right_matrix, atol=atol) + def get_ibrav_ase( structure, eps=1e-6, - ): +): """Get the ibrav of the structure.""" ase_atoms = structure.get_ase() @@ -91,11 +128,8 @@ def get_ibrav_ase( return ibrav -def base_transformation( - V_initial, - V_final, - eps=1e-6 - ) -> tuple: + +def base_transformation(V_initial, V_final, eps=1e-6) -> tuple: """ decompose the transformation from V_initial to V_final into a physical rotation (R) and a basis change (T). @@ -126,10 +160,11 @@ def base_transformation( else: raise ValueError("The cell matrix is not a pure basis change.") + def get_cell_qe_convention( cell, eps=1e-6, - ): +): """ Get the basis vectors and cell parameters in the Quantum ESPRESSO convention. This method will check all the possible cases where the given cell is equivalent to cell in Quantum ESPRESSO convention modulo a rotation or linear transformation. @@ -150,62 +185,68 @@ def get_cell_qe_convention( a, b, c, cosab, cosac, cosbc = get_cellpar(cell) - parameters = { - 'ibrav': 0 - } + parameters = {"ibrav": 0} cell_qe = None # --- CUBIC CELL --- # # cubic cell if three vectors have the same length and are mutually orthogonal # Note: the cell is allowed to have arbitrary rotation. - if ( - (max(a, b, c) - min(a, b, c)) <= eps - and - max(abs(cosab), abs(cosac), abs(cosbc)) <= eps - ): - parameters['ibrav'] = 1 - parameters['a'] = a - cell_qe = a * np.array([ - [1, 0, 0], - [0, 1, 0], - [0, 0, 1], - ]) + if (max(a, b, c) - min(a, b, c)) <= eps and max( + abs(cosab), abs(cosac), abs(cosbc) + ) <= eps: + parameters["ibrav"] = 1 + parameters["a"] = a + cell_qe = a * np.array( + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ] + ) # --- FCC CELL --- # # FCC cell if three vectors have the same length and there angles are either 60° or 120° # Note: there are 12 nearest neighbours to the origin. All of them are equivalent to the origin. # Note: the cell is allowed to have arbitrary rotation. - elif ( - (max(a, b, c) - min(a, b, c)) <= eps - and - max(abs(abs(cosab) - 1/2), abs(abs(cosac) - 1/2), abs(abs(cosbc) - 1/2)) <= eps - ): - a = a*2**0.5 - parameters['ibrav'] = 2 - parameters['a'] = a - cell_qe = a / 2 * np.array([ - [-1, 0, 1], - [ 0, 1, 1], - [-1, 1, 0], - ]) + elif (max(a, b, c) - min(a, b, c)) <= eps and max( + abs(abs(cosab) - 1 / 2), abs(abs(cosac) - 1 / 2), abs(abs(cosbc) - 1 / 2) + ) <= eps: + a = a * 2**0.5 + parameters["ibrav"] = 2 + parameters["a"] = a + cell_qe = ( + a + / 2 + * np.array( + [ + [-1, 0, 1], + [0, 1, 1], + [-1, 1, 0], + ] + ) + ) # --- BCC CELL --- # # BCC cell if three vectors have the same length and there angles are either arccos(1/9) or arccos(-1/9) # Note: there are 8 nearest neighbours to the origin. All of them are equivalent to the origin. # Note: the cell is allowed to have arbitrary rotation. - elif ( - (max(a, b, c) - min(a, b, c)) <= eps - and - max(abs(abs(cosab) - 1/3), abs(abs(cosac) - 1/3), abs(abs(cosbc) - 1/3)) <= eps - ): - a = a*2/3**0.5 - parameters['ibrav'] = 3 - parameters['a'] = a - cell_qe = a / 2* np.array([ - [ 1, 1, 1], - [-1, 1, 1], - [-1, -1, 1], - ]) + elif (max(a, b, c) - min(a, b, c)) <= eps and max( + abs(abs(cosab) - 1 / 3), abs(abs(cosac) - 1 / 3), abs(abs(cosbc) - 1 / 3) + ) <= eps: + a = a * 2 / 3**0.5 + parameters["ibrav"] = 3 + parameters["a"] = a + cell_qe = ( + a + / 2 + * np.array( + [ + [1, 1, 1], + [-1, 1, 1], + [-1, -1, 1], + ] + ) + ) # --- HEXAGONAL CELL --- # # hexagonal cell if have the same length and angle 60° or 120° and v3 is perpendicular to v1 and v2 @@ -213,58 +254,59 @@ def get_cell_qe_convention( # Note: the cell is allowed to have arbitrary rotation. elif ( abs(a - b) <= eps - and - max(abs(abs(cosab) - 1/2), abs(cosac), abs(cosbc)) <= eps + and max(abs(abs(cosab) - 1 / 2), abs(cosac), abs(cosbc)) <= eps ): - parameters['ibrav'] = 4 - parameters['a'] = a - parameters['c'] = c - cell_qe = a * np.array([ - [1, 0, 0], - [-1/2, np.sqrt(3)/2, 0], - [0, 0, c/a], - ]) + parameters["ibrav"] = 4 + parameters["a"] = a + parameters["c"] = c + cell_qe = a * np.array( + [ + [1, 0, 0], + [-1 / 2, np.sqrt(3) / 2, 0], + [0, 0, c / a], + ] + ) # --- Rhombohedral CELL --- # # rhombohedral cell if three vectors have the same length and there angles are the same. # Note: unlike Quantum ESPRESSO, thermo_pw.x only accept definition of cosab although in rhombohedral cell although cosab == cosac == cosbc. # Note: there are 6 nearest neighbours to the origin. All of them are equivalent to the origin. # Note: the cell is allowed to have arbitrary rotation. - elif ( - (max(a, b, c) - min(a, b, c)) <= eps - and - max(cosab, cosac, cosbc) - min(cosab, cosac, cosbc) < eps - ): - parameters['ibrav'] = 5 - parameters['a'] = a - parameters['cosab'] = cosab - - tx = np.sqrt((1-cosab)/2) - ty = np.sqrt((1-cosab)/6) - tz = np.sqrt((1+2*cosab)/3) - cell_qe = a * np.array([ - [tx, -ty, tz], - [0, 2*ty, tz], - [-tx, -ty, tz], - ]) + elif (max(a, b, c) - min(a, b, c)) <= eps and max(cosab, cosac, cosbc) - min( + cosab, cosac, cosbc + ) < eps: + parameters["ibrav"] = 5 + parameters["a"] = a + parameters["cosab"] = cosab + + tx = np.sqrt((1 - cosab) / 2) + ty = np.sqrt((1 - cosab) / 6) + tz = np.sqrt((1 + 2 * cosab) / 3) + cell_qe = a * np.array( + [ + [tx, -ty, tz], + [0, 2 * ty, tz], + [-tx, -ty, tz], + ] + ) # --- TETRAGONAL CELL --- # # tetragonal cell if two vectors have the same length and are perpendicular to each other. The third vector is perpendicular to the plane of the first two vectors. # Note: there are 4 nearest neighbours to the origin. All of them are equivalent to the origin. # Note: the cell is allowed to have arbitrary rotation. elif ( - abs(a - b) <= eps - and - max(cosab, cosac, cosbc) - min(cosab, cosac, cosbc) < eps - ): - parameters['ibrav'] = 6 - parameters['a'] = a - parameters['c'] = c - cell_qe = a * np.array([ - [1, 0, 0], - [0, 1, 0], - [0, 0, c/a], - ]) + abs(a - b) <= eps and max(cosab, cosac, cosbc) - min(cosab, cosac, cosbc) < eps + ): + parameters["ibrav"] = 6 + parameters["a"] = a + parameters["c"] = c + cell_qe = a * np.array( + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, c / a], + ] + ) # --- BODY-CENTRED TETRAGONAL CELL --- # # body-centred tetragonal cell if three vectors have the following pattern: @@ -273,136 +315,141 @@ def get_cell_qe_convention( # 1. a == b == c --> 4 degrees of freedom # 2. |cos(ab)| + |cos(bc)| + |cos(ac)| == 1 --> 3 degrees of freedom # 3. either cos(ab) == cos(bc) or cos(ab) == cos(ac) or cos(bc) == cos(ac) --> 2 degrees of freedom - elif ( - max(a, b, c) - min(a, b, c) < eps - and - ( - abs(cosab - cosbc) < eps and abs(abs(cosab) + abs(cosbc) - cosac - 1) < eps - or - abs(cosab - cosac) < eps and abs(abs(cosab) + abs(cosac) - cosbc - 1) < eps - or - abs(cosbc - cosac) < eps and abs(abs(cosbc) + abs(cosac) - cosab - 1) < eps - ) + elif max(a, b, c) - min(a, b, c) < eps and ( + abs(cosab - cosbc) < eps + and abs(abs(cosab) + abs(cosbc) - cosac - 1) < eps + or abs(cosab - cosac) < eps + and abs(abs(cosab) + abs(cosac) - cosbc - 1) < eps + or abs(cosbc - cosac) < eps + and abs(abs(cosbc) + abs(cosac) - cosab - 1) < eps ): # We should switch the special axis to v1 if abs(cosab - cosac) < eps: - a = ((a**2 - a*b*abs(cosab))*2)**0.5 - c = (a*b*abs(cosab)*4)**0.5 + a = ((a**2 - a * b * abs(cosab)) * 2) ** 0.5 + c = (a * b * abs(cosab) * 4) ** 0.5 elif abs(cosab - cosbc) < eps: - a = ((a**2 - a*b*abs(cosab))*2)**0.5 - c = (a*b*abs(cosab)*4)**0.5 + a = ((a**2 - a * b * abs(cosab)) * 2) ** 0.5 + c = (a * b * abs(cosab) * 4) ** 0.5 elif abs(cosbc - cosac) < eps: - a = ((a**2 - b*c*abs(cosbc))*2)**0.5 - c = (b*c*abs(cosbc)*4)**0.5 - - parameters['ibrav'] = 7 - parameters['a'] = a - parameters['c'] = c - cell_qe = a / 2 * np.array([ - [1, -1, c/a], - [1, 1, c/a], - [-1, -1, c/a], - ]) + a = ((a**2 - b * c * abs(cosbc)) * 2) ** 0.5 + c = (b * c * abs(cosbc) * 4) ** 0.5 + + parameters["ibrav"] = 7 + parameters["a"] = a + parameters["c"] = c + cell_qe = ( + a + / 2 + * np.array( + [ + [1, -1, c / a], + [1, 1, c / a], + [-1, -1, c / a], + ] + ) + ) # --- ORTHORHOMBIC CELL --- # # Orthorhombic cell if three vectors are mutually orthogonal. # Note: there are 4 nearest neighbours to the origin. All of them are equivalent to the origin. # Note: the cell is allowed to have arbitrary rotation. - elif ( - max(cosab, cosac, cosbc) - min(cosab, cosac, cosbc) < eps - ): - parameters['ibrav'] = 8 - parameters['a'] = a - parameters['b'] = b - parameters['c'] = c - cell_qe = np.array([ - [a, 0, 0], - [0, b, 0], - [0, 0, c], - ]) + elif max(cosab, cosac, cosbc) - min(cosab, cosac, cosbc) < eps: + parameters["ibrav"] = 8 + parameters["a"] = a + parameters["b"] = b + parameters["c"] = c + cell_qe = np.array( + [ + [a, 0, 0], + [0, b, 0], + [0, 0, c], + ] + ) # --- BASE-CENTRED ORTHORHOMBIC CELL --- # # base-centred orthorhombic cell if three vectors have the following pattern: # v1 = (a/2, b/2,0), v2 = (-a/2,b/2,0), v3 = (0,0,c) # So v1 and v2 should have equal length and v3 should be perpendicular to v1 - v2. # Note: the cell is allowed to have arbitrary rotation. + elif max(abs(a - b), abs(cosac), abs(cosbc)) < eps: + x = (2 * (a**2 - a**2 * cosab)) ** 0.5 + y = (2 * (a**2 + a**2 * cosab)) ** 0.5 + parameters["ibrav"] = 9 + parameters["a"] = x + parameters["b"] = y + parameters["c"] = c + cell_qe = np.array( + [ + [x / 2, y / 2, 0], + [-x / 2, y / 2, 0], + [0, 0, c], + ] + ) elif ( - max(abs(a - b), abs(cosac), abs(cosbc)) < eps - ): - x = (2*(a**2 - a**2*cosab))**0.5 - y = (2*(a**2 + a**2*cosab))**0.5 - parameters['ibrav'] = 9 - parameters['a'] = x - parameters['b'] = y - parameters['c'] = c - cell_qe = np.array([ - [x/2, y/2, 0], - [-x/2, y/2, 0], - [0, 0, c], - ]) - elif ( - abs(a**2-a*b*cosab-a*c*cosac) < eps - and - abs(b**2-a*b*cosab-b*c*cosbc) < eps - and - abs(c**2-a*c*cosac-b*c*cosbc) < eps - ): - parameters['ibrav'] = 10 - parameters['a'] = (4*a*b*cosab)**0.5 - parameters['b'] = (4*b*c*cosac)**0.5 - parameters['c'] = (4*a*c*cosbc)**0.5 - cell_qe = 1/2 * np.array([ - [a, 0, c], - [a, b, 0], - [0, b, c], - ]) - elif ( - max(a, b, c) - min(a, b, c) < eps - and - abs(cosab+cosbc+cosac+1) < eps + abs(a**2 - a * b * cosab - a * c * cosac) < eps + and abs(b**2 - a * b * cosab - b * c * cosbc) < eps + and abs(c**2 - a * c * cosac - b * c * cosbc) < eps ): - parameters['ibrav'] = 11 - a = a*((1+cosbc)*2)**0.5 - b = b*((1+cosac)*2)**0.5 - c = c*((1+cosab)*2)**0.5 - parameters['a'] = a - parameters['b'] = b - parameters['c'] = c - cell_qe = 0.5* np.array([ - [ a, b, c], - [-a, b, c], - [-a, -b, c], - ]) + parameters["ibrav"] = 10 + parameters["a"] = (4 * a * b * cosab) ** 0.5 + parameters["b"] = (4 * b * c * cosac) ** 0.5 + parameters["c"] = (4 * a * c * cosbc) ** 0.5 + cell_qe = ( + 1 + / 2 + * np.array( + [ + [a, 0, c], + [a, b, 0], + [0, b, c], + ] + ) + ) + elif max(a, b, c) - min(a, b, c) < eps and abs(cosab + cosbc + cosac + 1) < eps: + parameters["ibrav"] = 11 + a = a * ((1 + cosbc) * 2) ** 0.5 + b = b * ((1 + cosac) * 2) ** 0.5 + c = c * ((1 + cosab) * 2) ** 0.5 + parameters["a"] = a + parameters["b"] = b + parameters["c"] = c + cell_qe = 0.5 * np.array( + [ + [a, b, c], + [-a, b, c], + [-a, -b, c], + ] + ) # --- MONOCLINIC CELL --- # # Monoclinic cell if one axis is perpendicular to the plane of the other two axes. # Note: the cell is allowed to have arbitrary rotation. - elif ( - max(abs(cosac), abs(cosbc)) < eps - ): - parameters['ibrav'] = 12 - parameters['a'] = a - parameters['b'] = b - parameters['c'] = c - parameters['cosab'] = cosab - cell_qe = np.array([ - [a, 0, 0], - [b*cosab, b*np.sqrt(1-cosab**2), 0], - [0, 0, c], - ]) - elif ( - max(abs(cosab), abs(cosbc)) < eps - ): - parameters['ibrav'] = -12 - parameters['a'] = a - parameters['b'] = b - parameters['c'] = c - parameters['cosac'] = cosac - cell_qe = np.array([ - [a, 0, 0], - [b*cosab, b*np.sqrt(1-cosab**2), 0], - [0, 0, c], - ]) + elif max(abs(cosac), abs(cosbc)) < eps: + parameters["ibrav"] = 12 + parameters["a"] = a + parameters["b"] = b + parameters["c"] = c + parameters["cosab"] = cosab + cell_qe = np.array( + [ + [a, 0, 0], + [b * cosab, b * np.sqrt(1 - cosab**2), 0], + [0, 0, c], + ] + ) + elif max(abs(cosab), abs(cosbc)) < eps: + parameters["ibrav"] = -12 + parameters["a"] = a + parameters["b"] = b + parameters["c"] = c + parameters["cosac"] = cosac + cell_qe = np.array( + [ + [a, 0, 0], + [b * cosab, b * np.sqrt(1 - cosab**2), 0], + [0, 0, c], + ] + ) # --- BASE-CENTRED MONOCLINIC CELL --- # # base-centred monoclinic cesll should follow the following pattern: @@ -411,120 +458,165 @@ def get_cell_qe_convention( # v3 = ( a/2, 0, c/2), # So v1 and v3 should have equal length and v2 should be perpendicular to v1 - v3. # We allow rotation in {v1, v3} plane. - elif ( - abs(a - c) < eps - ): - if abs(cosab+cosbc) < eps: - print('Found a base-centred monoclinic cell, special axis: v2') - parameters['ibrav'] = 13 - _c = a*((1+cosac)*2)**0.5 - _b = 2*b - _a = a*((1-cosac)*2)**0.5 - parameters['a'] = _a - parameters['b'] = _b - parameters['c'] = _c - parameters['cosab'] = cosab - cell_qe = 1/2 * np.array([ - [_a, 0, -_c], - [_b*cosab, _b*np.sqrt(1-cosab**2), 0], - [_a, 0, _c], - ]) - if abs(cosab-cosbc) < eps: - print('Found a base-centred monoclinic cell, special axis: v2') - parameters['ibrav'] = 13 - _a = a*((1+cosac)*2)**0.5 - _b = 2*b - _c = a*((1-cosac)*2)**0.5 - parameters['a'] = _a - parameters['b'] = _b - parameters['c'] = _c - parameters['cosab'] = cosab - cell_qe = 1/2 * np.array([ - [_a, 0, -_c], - [_b*cosab, _b*np.sqrt(1-cosab**2), 0], - [_a, 0, _c], - ]) + elif abs(a - c) < eps: + if abs(cosab + cosbc) < eps: + print("Found a base-centred monoclinic cell, special axis: v2") + parameters["ibrav"] = 13 + _c = a * ((1 + cosac) * 2) ** 0.5 + _b = 2 * b + _a = a * ((1 - cosac) * 2) ** 0.5 + parameters["a"] = _a + parameters["b"] = _b + parameters["c"] = _c + parameters["cosab"] = cosab + cell_qe = ( + 1 + / 2 + * np.array( + [ + [_a, 0, -_c], + [_b * cosab, _b * np.sqrt(1 - cosab**2), 0], + [_a, 0, _c], + ] + ) + ) + if abs(cosab - cosbc) < eps: + print("Found a base-centred monoclinic cell, special axis: v2") + parameters["ibrav"] = 13 + _a = a * ((1 + cosac) * 2) ** 0.5 + _b = 2 * b + _c = a * ((1 - cosac) * 2) ** 0.5 + parameters["a"] = _a + parameters["b"] = _b + parameters["c"] = _c + parameters["cosab"] = cosab + cell_qe = ( + 1 + / 2 + * np.array( + [ + [_a, 0, -_c], + [_b * cosab, _b * np.sqrt(1 - cosab**2), 0], + [_a, 0, _c], + ] + ) + ) elif abs(a - b) < eps: - if abs(cosac+cosbc) < eps: - print('Found a B-type base-centred monoclinic cell, special axis: v3') - parameters['ibrav'] = -13 - _a = a*((1+cosab)*2)**0.5 - _b = a*((1-cosab)*2)**0.5 - _cosac = (a / (1/2*_a)) * cosac - _c = 2*c - parameters['a'] = _a - parameters['b'] = c - parameters['c'] = _b - parameters['cosac'] = cosac - cell_qe = 1/2 * np.array([ - [_a, _b, 0], - [-_a, _b, 0], - [_c*_cosac, 0, _c*np.sqrt(1-_cosac**2)], - ]) - if abs(cosac-cosbc) < eps: - print('Found a A-type base-centred monoclinic cell, special axis: v3') - parameters['ibrav'] = 13 - _a = a*((1+cosab)*2)**0.5 - _b = a*((1-cosab)*2)**0.5 - _c = 2*c - _cosac = (a / (1/2*_a)) * cosac - parameters['a'] = _a - parameters['b'] = _c - parameters['c'] = _b - parameters['cosab'] = cosac - cell_qe = 1/2 * np.array([ - [_a, 0, -_b], - [_c*_cosac, _c*np.sqrt(1-_cosac**2), 0], - [_a, 0, _b], - ]) + if abs(cosac + cosbc) < eps: + print("Found a B-type base-centred monoclinic cell, special axis: v3") + parameters["ibrav"] = -13 + _a = a * ((1 + cosab) * 2) ** 0.5 + _b = a * ((1 - cosab) * 2) ** 0.5 + _cosac = (a / (1 / 2 * _a)) * cosac + _c = 2 * c + parameters["a"] = _a + parameters["b"] = c + parameters["c"] = _b + parameters["cosac"] = cosac + cell_qe = ( + 1 + / 2 + * np.array( + [ + [_a, _b, 0], + [-_a, _b, 0], + [_c * _cosac, 0, _c * np.sqrt(1 - _cosac**2)], + ] + ) + ) + if abs(cosac - cosbc) < eps: + print("Found a A-type base-centred monoclinic cell, special axis: v3") + parameters["ibrav"] = 13 + _a = a * ((1 + cosab) * 2) ** 0.5 + _b = a * ((1 - cosab) * 2) ** 0.5 + _c = 2 * c + _cosac = (a / (1 / 2 * _a)) * cosac + parameters["a"] = _a + parameters["b"] = _c + parameters["c"] = _b + parameters["cosab"] = cosac + cell_qe = ( + 1 + / 2 + * np.array( + [ + [_a, 0, -_b], + [_c * _cosac, _c * np.sqrt(1 - _cosac**2), 0], + [_a, 0, _b], + ] + ) + ) elif abs(b - c) < eps: - if abs(cosab+cosac) < eps: - parameters['ibrav'] = 13 - _a = a*2 - _b = b*((1+cosbc)*2)**0.5 - _c = b*((1-cosbc)*2)**0.5 - parameters['a'] = _b - parameters['b'] = _a - parameters['c'] = _c - parameters['cosab'] = cosac - cell_qe = 1/2 * np.array([ - [_a, 0, -_c], - [_b*cosab, _b*np.sqrt(1-cosab**2), 0], - [_a, 0, _c], - ]) - if abs(cosac-cosbc) < eps: - parameters['ibrav'] = 13 - _a = a*2 - _c = b*((1+cosbc)*2)**0.5 - _b = b*((1-cosbc)*2)**0.5 - parameters['a'] = _b - parameters['b'] = _a - parameters['c'] = _c - cell_qe = 1/2 * np.array([ - [_a, 0, -_c], - [_b*cosab, _b*np.sqrt(1-cosab**2), 0], - [_a, 0, _c], - ]) + if abs(cosab + cosac) < eps: + parameters["ibrav"] = 13 + _a = a * 2 + _b = b * ((1 + cosbc) * 2) ** 0.5 + _c = b * ((1 - cosbc) * 2) ** 0.5 + parameters["a"] = _b + parameters["b"] = _a + parameters["c"] = _c + parameters["cosab"] = cosac + cell_qe = ( + 1 + / 2 + * np.array( + [ + [_a, 0, -_c], + [_b * cosab, _b * np.sqrt(1 - cosab**2), 0], + [_a, 0, _c], + ] + ) + ) + if abs(cosac - cosbc) < eps: + parameters["ibrav"] = 13 + _a = a * 2 + _c = b * ((1 + cosbc) * 2) ** 0.5 + _b = b * ((1 - cosbc) * 2) ** 0.5 + parameters["a"] = _b + parameters["b"] = _a + parameters["c"] = _c + cell_qe = ( + 1 + / 2 + * np.array( + [ + [_a, 0, -_c], + [_b * cosab, _b * np.sqrt(1 - cosab**2), 0], + [_a, 0, _c], + ] + ) + ) # --- TRICLINIC CELL --- # # triclinic cell if none of the above cases are satisfied. else: - parameters['ibrav'] = 14 - parameters['a'] = a - parameters['b'] = b - parameters['c'] = c - parameters['cosab'] = cosab - parameters['cosac'] = cosac - parameters['cosbc'] = cosbc - sinab = np.sqrt(1-cosab**2) - cell_qe = np.array([ - [a, 0, 0], - [b*cosab, b*sinab, 0], - [c*cosac, c*cosbc*cosac*cosab/sinab, c*np.sqrt(1+2*cosbc*cosac*cosab-cosbc**2-cosac**2-cosab**2)/sinab], - ]) + parameters["ibrav"] = 14 + parameters["a"] = a + parameters["b"] = b + parameters["c"] = c + parameters["cosab"] = cosab + parameters["cosac"] = cosac + parameters["cosbc"] = cosbc + sinab = np.sqrt(1 - cosab**2) + cell_qe = np.array( + [ + [a, 0, 0], + [b * cosab, b * sinab, 0], + [ + c * cosac, + c * cosbc * cosac * cosab / sinab, + c + * np.sqrt( + 1 + 2 * cosbc * cosac * cosab - cosbc**2 - cosac**2 - cosab**2 + ) + / sinab, + ], + ] + ) return cell_qe, parameters + def get_cellpar(cell): """ Get the cell parameters from the cell according to the following order: @@ -533,10 +625,15 @@ def get_cellpar(cell): v1, v2, v3 = [np.array(v) for v in cell] a, b, c = [la.norm(v) for v in [v1, v2, v3]] - cosab, cosac, cosbc = [np.dot(v1, v2) / (a * b), np.dot(v1, v3) / (a * c), np.dot(v2, v3) / (b * c)] + cosab, cosac, cosbc = [ + np.dot(v1, v2) / (a * b), + np.dot(v1, v3) / (a * c), + np.dot(v2, v3) / (b * c), + ] return a, b, c, cosab, cosac, cosbc + def get_parameters_from_structure(structure, eps=1e-6): """ Get the cell parameters and the cartesian coordinates of the atoms in the Quantum ESPRESSO convention. @@ -556,18 +653,19 @@ def get_parameters_from_structure(structure, eps=1e-6): bravais_lattice_name = ase_atoms.cell.get_bravais_lattice(eps=eps).name - v1, v2, v3 = [np.array(v) for v in cell] a, b, c = [la.norm(v) for v in [v1, v2, v3]] - cosab, cosac, cosbc = [np.dot(v1, v2) / (a * b), np.dot(v1, v3) / (a * c), np.dot(v2, v3) / (b * c)] - + cosab, cosac, cosbc = [ + np.dot(v1, v2) / (a * b), + np.dot(v1, v3) / (a * c), + np.dot(v2, v3) / (b * c), + ] if max(a, b, c) < eps or abs(np.dot(np.cross(v1, v2), v3)) < eps: raise ValueError("Invalid cell") cell_qe, parameters = get_cell_qe_convention(cell, eps=eps) - # decompose the transformation from cell to cell_qe into a physical rotation (R) and a basis change (T) # and apply the rotation to the cartesian coordinates of the atoms # The basis change will not affect the cartesian coordinates of the atoms since it just make the atoms @@ -576,11 +674,14 @@ def get_parameters_from_structure(structure, eps=1e-6): T = base_transformation(cell, cell_qe) ibrav_ase = ibrav_bravais_lattice_map_ase[bravais_lattice_name] - if parameters['ibrav'] != ibrav_ase: - raise Warning(f"Found bravais lattice {parameters['ibrav']} is different from ase: {bravais_lattice_name}({ibrav_ase})") + if parameters["ibrav"] != ibrav_ase: + raise Warning( + f"Found bravais lattice {parameters['ibrav']} is different from ase: {bravais_lattice_name}({ibrav_ase})" + ) return parameters, positions + def get_standardized_structure_pymatgen(structure, eps=1e-6): """ Get the standardized structure in the Quantum ESPRESSO convention. @@ -588,93 +689,94 @@ def get_standardized_structure_pymatgen(structure, eps=1e-6): from pymatgen.symmetry.analyzer import SpacegroupAnalyzer - spg_analyzer = SpacegroupAnalyzer(structure.get_pymatgen(), symprec=eps, angle_tolerance=eps) + spg_analyzer = SpacegroupAnalyzer( + structure.get_pymatgen(), symprec=eps, angle_tolerance=eps + ) primitive_standard_structure = spg_analyzer.get_primitive_standard_structure() return primitive_standard_structure + def get_parameters_from_pymatgen_structure(pym_structure, eps=1e-6): """ Get the cell parameters and the cartesian coordinates of the atoms in the Quantum ESPRESSO convention from the pymatgen structure. """ + def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): """ Convert the standardized structure in pymatgen to the Quantum ESPRESSO convention. """ - cell = pym_structure.lattice.matrix a, b, c, cosab, cosac, cosbc = get_cellpar(cell) - - parameters = { - 'ibrav': 0 - } + parameters = {"ibrav": 0} cell_qe = None transformation_matrix = [ - [1, 0, 0], - [0, 1, 0], - [0, 0, 1], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], ] # --- CUBIC CELL --- # # cubic cell if three vectors have the same length and are mutually orthogonal # Note: the cell is allowed to have arbitrary rotation. - if ( - (max(a, b, c) - min(a, b, c)) <= eps - and - max(abs(cosab), abs(cosac), abs(cosbc)) <= eps + if (max(a, b, c) - min(a, b, c)) <= eps and max( + abs(cosab), abs(cosac), abs(cosbc) + ) <= eps: + if not np.allclose( + cell / a, np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]), atol=eps ): - if not np.allclose(cell/a, np.array([ - [1, 0, 0], - [0, 1, 0], - [0, 0, 1] - ]), atol=eps): raise ValueError("Invalid cell for cubic") transformation_matrix = [ - [1, 0, 0], - [0, 1, 0], - [0, 0, 1], - ] - parameters['ibrav'] = 1 - parameters['a'] = a - cell_qe = a * np.array([ [1, 0, 0], [0, 1, 0], [0, 0, 1], - ]) + ] + parameters["ibrav"] = 1 + parameters["a"] = a + cell_qe = a * np.array( + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ] + ) # --- FCC CELL --- # # FCC cell if three vectors have the same length and there angles are either 60° or 120° # Note: there are 12 nearest neighbours to the origin. All of them are equivalent to the origin. # Note: the cell is allowed to have arbitrary rotation. - elif ( - (max(a, b, c) - min(a, b, c)) <= eps - and - max(abs(abs(cosab) - 1/2), abs(abs(cosac) - 1/2), abs(abs(cosbc) - 1/2)) <= eps - ): - cell_pattern = cell / (a/2**0.5) - - if not np.allclose(cell_pattern, np.array( - [ - [ 0, 1, 1], - [ 1, 0, 1], - [ 1, 1, 0], - ]), atol=eps): + elif (max(a, b, c) - min(a, b, c)) <= eps and max( + abs(abs(cosab) - 1 / 2), abs(abs(cosac) - 1 / 2), abs(abs(cosbc) - 1 / 2) + ) <= eps: + cell_pattern = cell / (a / 2**0.5) + + if not np.allclose( + cell_pattern, + np.array( + [ + [0, 1, 1], + [1, 0, 1], + [1, 1, 0], + ] + ), + atol=eps, + ): raise ValueError("Invalid cell for FCC") transformation_matrix = [ - [1, 0, -1], - [1, 0, 0], - [1, -1, 0], + [1, 0, -1], + [1, 0, 0], + [1, -1, 0], ] - _a = a*2**0.5 - parameters['ibrav'] = 2 - parameters['a'] = a + _a = a * 2**0.5 + parameters["ibrav"] = 2 + parameters["a"] = a # pymatgen convention is linked to the QE convention by a following transformation: # [1, 0, -1] @@ -682,67 +784,92 @@ def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): # [1, -1, 0] # This won't affect the cartesian coordinates of the atoms. - cell_qe = _a / 2 * np.array([ - [-1, 0, 1], - [ 0, 1, 1], - [-1, 1, 0], - ]) + cell_qe = ( + _a + / 2 + * np.array( + [ + [-1, 0, 1], + [0, 1, 1], + [-1, 1, 0], + ] + ) + ) # --- BCC CELL --- # # BCC cell if three vectors have the same length and there angles are either arccos(1/9) or arccos(-1/9) # Note: there are 8 nearest neighbours to the origin. All of them are equivalent to the origin. # Note: the cell is allowed to have arbitrary rotation. - elif ( - (max(a, b, c) - min(a, b, c)) <= eps - and - max(abs(abs(cosab) - 1/3), abs(abs(cosac) - 1/3), abs(abs(cosbc) - 1/3)) <= eps - ): - cell_pattern = cell / (a/3**0.5) - - if not np.allclose(cell_pattern, np.array( - [ - [-1, 1, 1], - [ 1, -1, 1], - [ 1, 1, -1], - ]), atol=eps): + elif (max(a, b, c) - min(a, b, c)) <= eps and max( + abs(abs(cosab) - 1 / 3), abs(abs(cosac) - 1 / 3), abs(abs(cosbc) - 1 / 3) + ) <= eps: + cell_pattern = cell / (a / 3**0.5) + + if not np.allclose( + cell_pattern, + np.array( + [ + [-1, 1, 1], + [1, -1, 1], + [1, 1, -1], + ] + ), + atol=eps, + ): raise ValueError("Invalid cell for BCC") transformation_matrix = [ - [1, 1, 1], - [1, 0, 0], - [0, 0, -1], + [1, 1, 1], + [1, 0, 0], + [0, 0, -1], ] # pymatgen convention is the same as QE convention for ibrav = -3 - _a = a*2/3**0.5 - parameters['ibrav'] = 3 - parameters['a'] = _a - cell_qe_A = _a / 2* np.array([ - [ -1, 1, 1], - [ 1, -1, 1], - [ 1, 1, -1], - ]) - cell_qe_B = _a / 2* np.array([ - [ 1, 1, 1], - [ -1, 1, 1], - [ -1, -1, 1], - ]) + _a = a * 2 / 3**0.5 + parameters["ibrav"] = 3 + parameters["a"] = _a + cell_qe_A = ( + _a + / 2 + * np.array( + [ + [-1, 1, 1], + [1, -1, 1], + [1, 1, -1], + ] + ) + ) + cell_qe_B = ( + _a + / 2 + * np.array( + [ + [1, 1, 1], + [-1, 1, 1], + [-1, -1, 1], + ] + ) + ) # --- HEXAGONAL CELL --- # # Hexagonal cell if two of the three vectors have the same length and the angle between them is 120° # Note: the cell is allowed to have arbitrary rotation. elif ( abs(a - b) <= eps - and - max(abs(abs(cosab) - 1/2), abs(cosac), abs(cosbc)) <= eps + and max(abs(abs(cosab) - 1 / 2), abs(cosac), abs(cosbc)) <= eps ): cell_pattern = cell / a - if not np.allclose(cell_pattern, np.array( - [ - [ 1/2, -np.sqrt(3)/2, 0 ], - [1/2, np.sqrt(3)/2, 0], - [ 0, 0, c/a], - ]), atol=eps): + if not np.allclose( + cell_pattern, + np.array( + [ + [1 / 2, -np.sqrt(3) / 2, 0], + [1 / 2, np.sqrt(3) / 2, 0], + [0, 0, c / a], + ] + ), + atol=eps, + ): raise ValueError("Invalid cell for HEX") # pymatgen convention is linked to the QE convention by a following transformation: # [0, 1, 0] @@ -750,115 +877,128 @@ def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): # [0, 0, 1] # This won't affect the cartesian coordinates of the atoms. - rotation_matrix = SymmOp.from_axis_angle_and_translation( - axis=[0,0,1], angle=60 - ) + axis=[0, 0, 1], angle=60 + ) pym_structure.apply_operation(rotation_matrix) - parameters['ibrav'] = 4 - parameters['a'] = a - parameters['c'] = c - cell_qe = a * np.array([ - [1, 0, 0], - [-1/2, np.sqrt(3)/2, 0], - [0, 0, c/a], - ]) + parameters["ibrav"] = 4 + parameters["a"] = a + parameters["c"] = c + cell_qe = a * np.array( + [ + [1, 0, 0], + [-1 / 2, np.sqrt(3) / 2, 0], + [0, 0, c / a], + ] + ) # --- Rhombohedral CELL --- # # rhombohedral cell if three vectors have the same length and there angles are the same. # Note: unlike Quantum ESPRESSO, thermo_pw.x only accept definition of cosab although in rhombohedral cell although cosab == cosac == cosbc. # Note: there are 6 nearest neighbours to the origin. All of them are equivalent to the origin. # Note: the cell is allowed to have arbitrary rotation. - elif ( - (max(a, b, c) - min(a, b, c)) <= eps - and - max(cosab, cosac, cosbc) - min(cosab, cosac, cosbc) < eps - ): + elif (max(a, b, c) - min(a, b, c)) <= eps and max(cosab, cosac, cosbc) - min( + cosab, cosac, cosbc + ) < eps: cell_pattern = cell / a - tx = np.sqrt((1-cosab)/2) - ty = np.sqrt((1-cosab)/6) - tz = np.sqrt((1+2*cosab)/3) - - if not np.allclose(cell_pattern, np.array( - [ - [0, 2*ty, tz], - [tx, -ty, tz], - [-tx, -ty, tz], - ]), atol=eps): + tx = np.sqrt((1 - cosab) / 2) + ty = np.sqrt((1 - cosab) / 6) + tz = np.sqrt((1 + 2 * cosab) / 3) + + if not np.allclose( + cell_pattern, + np.array( + [ + [0, 2 * ty, tz], + [tx, -ty, tz], + [-tx, -ty, tz], + ] + ), + atol=eps, + ): raise ValueError("Invalid cell for HEX") transformation_matrix = [ - [0, 1, 0], - [1, 0, 0], - [0, 0, 1], + [0, 1, 0], + [1, 0, 0], + [0, 0, 1], ] - parameters['ibrav'] = 5 - parameters['a'] = a - parameters['cosab'] = cosab + parameters["ibrav"] = 5 + parameters["a"] = a + parameters["cosab"] = cosab - cell_qe = a * np.array([ - [tx, -ty, tz], - [0, 2*ty, tz], - [-tx, -ty, tz], - ]) + cell_qe = a * np.array( + [ + [tx, -ty, tz], + [0, 2 * ty, tz], + [-tx, -ty, tz], + ] + ) # --- TETRAGONAL CELL --- # # Tetragonal cell if two of the three vectors have the same length and the angle between them is 90° # Note: the cell is allowed to have arbitrary rotation. elif ( - abs(a - b) <= eps - and - max(cosab, cosac, cosbc) - min(cosab, cosac, cosbc) < eps - ): + abs(a - b) <= eps and max(cosab, cosac, cosbc) - min(cosab, cosac, cosbc) < eps + ): cell_pattern = cell / a - if not np.allclose(cell_pattern, np.array( + if not np.allclose( + cell_pattern, + np.array( + [ + [1, 0, 0], + [0, 1, 0], + [0, 0, c / a], + ] + ), + atol=eps, + ): + raise ValueError("Invalid cell for TET") + + parameters["ibrav"] = 6 + parameters["a"] = a + parameters["c"] = c + cell_qe = a * np.array( [ [1, 0, 0], [0, 1, 0], - [0, 0, c/a], - ]), atol=eps): - raise ValueError("Invalid cell for TET") - - parameters['ibrav'] = 6 - parameters['a'] = a - parameters['c'] = c - cell_qe = a * np.array([ - [1, 0, 0], - [0, 1, 0], - [0, 0, c/a], - ]) + [0, 0, c / a], + ] + ) # --- BODY-CENTRED TETRAGONAL CELL --- # # body-centred tetragonal cell if three vectors have the following pattern: # [1, 0, 0] # [0, 1, 0] # [0, 0, c/a] - elif ( - max(a, b, c) - min(a, b, c) < eps - and - ( - abs(cosab - cosbc) < eps and abs(abs(cosab) + abs(cosbc) - cosac - 1) < eps - or - abs(cosab - cosac) < eps and abs(abs(cosab) + abs(cosac) - cosbc - 1) < eps - or - abs(cosbc - cosac) < eps and abs(abs(cosbc) + abs(cosac) - cosab - 1) < eps - ) + elif max(a, b, c) - min(a, b, c) < eps and ( + abs(cosab - cosbc) < eps + and abs(abs(cosab) + abs(cosbc) - cosac - 1) < eps + or abs(cosab - cosac) < eps + and abs(abs(cosab) + abs(cosac) - cosbc - 1) < eps + or abs(cosbc - cosac) < eps + and abs(abs(cosbc) + abs(cosac) - cosab - 1) < eps ): - _a = ((a*a - a*b*cosab)/4)**0.5 - _c = ((a*a + a*b*cosab)/2)**0.5 + _a = ((a * a - a * b * cosab) / 4) ** 0.5 + _c = ((a * a + a * b * cosab) / 2) ** 0.5 cell_pattern = cell / _a - if not np.allclose(cell_pattern, np.array( - [ - [-1, 1, _c/_a], - [1, -1, _c/_a], - [1, 1, -_c/_a], - ]), atol=eps): + if not np.allclose( + cell_pattern, + np.array( + [ + [-1, 1, _c / _a], + [1, -1, _c / _a], + [1, 1, -_c / _a], + ] + ), + atol=eps, + ): raise ValueError("Invalid cell for BCT") # pymatgen convention is linked to the QE convention by a following transformation: @@ -872,48 +1012,60 @@ def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): [1, 1, 1], [0, 0, -1], ] - parameters['ibrav'] = 7 - parameters['a'] = _a*2 - parameters['c'] = _c*2 - cell_qe = _a * np.array([ - [1, -1, _c/_a], - [1, 1, _c/_a], - [-1, -1, _c/_a], - ]) + parameters["ibrav"] = 7 + parameters["a"] = _a * 2 + parameters["c"] = _c * 2 + cell_qe = _a * np.array( + [ + [1, -1, _c / _a], + [1, 1, _c / _a], + [-1, -1, _c / _a], + ] + ) # --- ORTHORHOMBIC CELL --- # # orthorhombic cell if three vectors have the same length and are mutually orthogonal # Note: the cell is allowed to have arbitrary rotation. - elif ( - max(abs(cosab), abs(cosac), abs(cosbc)) <= eps - ): - if not np.allclose(cell, np.array([ - [a, 0, 0], - [0, b, 0], - [0, 0, c], - ]), atol=eps): + elif max(abs(cosab), abs(cosac), abs(cosbc)) <= eps: + if not np.allclose( + cell, + np.array( + [ + [a, 0, 0], + [0, b, 0], + [0, 0, c], + ] + ), + atol=eps, + ): raise ValueError("Invalid cell for ORC") - parameters['ibrav'] = 8 - parameters['a'] = a - parameters['b'] = b - parameters['c'] = c - cell_qe = np.array([ - [a, 0, 0], - [0, b, 0], - [0, 0, c], - ]) - elif ( - max(abs(a - b), abs(cosac), abs(cosbc)) < eps - ): - _a = ((a**2 + a*b*cosab)/2)**0.5 - _b = ((a**2 - a*b*cosab)/2)**0.5 - - if not np.allclose(cell, np.array([ - [_a,-_b, 0], - [_a, _b, 0], - [ 0, 0, c], - ]), atol=eps): + parameters["ibrav"] = 8 + parameters["a"] = a + parameters["b"] = b + parameters["c"] = c + cell_qe = np.array( + [ + [a, 0, 0], + [0, b, 0], + [0, 0, c], + ] + ) + elif max(abs(a - b), abs(cosac), abs(cosbc)) < eps: + _a = ((a**2 + a * b * cosab) / 2) ** 0.5 + _b = ((a**2 - a * b * cosab) / 2) ** 0.5 + + if not np.allclose( + cell, + np.array( + [ + [_a, -_b, 0], + [_a, _b, 0], + [0, 0, c], + ] + ), + atol=eps, + ): raise ValueError("Invalid cell for ORH") transformation_matrix = [ @@ -921,35 +1073,42 @@ def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): [-1, 0, 0], [0, 0, 1], ] - parameters['ibrav'] = 9 - parameters['a'] = _a*2 - parameters['b'] = _b*2 - parameters['c'] = c + parameters["ibrav"] = 9 + parameters["a"] = _a * 2 + parameters["b"] = _b * 2 + parameters["c"] = c cell_qe = { - 9: np.array([ - [_a, _b, 0], - [-_a, _b, 0], - [ 0, 0, c], - ]), - -9: np.array([ - [_a, -_b, 0], - [_a, _b, 0], - [ 0, 0, -c], - ])} - elif ( - max(a, b, c) - min(a, b, c) < eps - and - abs(cosab+cosbc+cosac+1) < eps - ): - _a = ((b**2 + b*c*cosbc)/2)**0.5 - _b = ((a**2 + a*c*cosac)/2)**0.5 - _c = ((c**2 + a*b*cosab)/2)**0.5 - - if not np.allclose(cell, np.array([ - [-_a, _b, _c], - [_a, -_b, _c], - [_a, _b, -_c], - ]), atol=eps): + 9: np.array( + [ + [_a, _b, 0], + [-_a, _b, 0], + [0, 0, c], + ] + ), + -9: np.array( + [ + [_a, -_b, 0], + [_a, _b, 0], + [0, 0, -c], + ] + ), + } + elif max(a, b, c) - min(a, b, c) < eps and abs(cosab + cosbc + cosac + 1) < eps: + _a = ((b**2 + b * c * cosbc) / 2) ** 0.5 + _b = ((a**2 + a * c * cosac) / 2) ** 0.5 + _c = ((c**2 + a * b * cosab) / 2) ** 0.5 + + if not np.allclose( + cell, + np.array( + [ + [-_a, _b, _c], + [_a, -_b, _c], + [_a, _b, -_c], + ] + ), + atol=eps, + ): raise ValueError("Invalid cell for ORT") transformation_matrix = [ @@ -957,31 +1116,37 @@ def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): [1, 0, 0], [0, 0, -1], ] - parameters['ibrav'] = 11 - parameters['a'] = _a*2 - parameters['b'] = _b*2 - parameters['c'] = _c*2 - cell_qe = np.array([ - [_a, _b, _c], - [-_a, _b, _c], - [-_a, -_b, _c], - ]) - elif ( - max(abs(cosab), abs(cosbc)) < eps + parameters["ibrav"] = 11 + parameters["a"] = _a * 2 + parameters["b"] = _b * 2 + parameters["c"] = _c * 2 + cell_qe = np.array( + [ + [_a, _b, _c], + [-_a, _b, _c], + [-_a, -_b, _c], + ] + ) + elif max(abs(cosab), abs(cosbc)) < eps: + _cy = cosac * c + _cz = -c * np.sqrt(1 - cosac**2) + + if not np.allclose( + cell, + np.array( + [ + [0, a, 0], + [b, 0, 0], + [0, _cy, _cz], + ] + ), + atol=eps, ): - _cy = cosac*c - _cz = -c*np.sqrt(1-cosac**2) - - if not np.allclose(cell, np.array([ - [0, a, 0], - [b, 0, 0], - [0, _cy, _cz], - ]), atol=eps): raise ValueError("Invalid cell for MCL") rotation_matrix = SymmOp.from_axis_angle_and_translation( - axis=[0,0,1], angle=-90 - ) + axis=[0, 0, 1], angle=-90 + ) pym_structure.apply_operation(rotation_matrix) @@ -990,37 +1155,41 @@ def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): [0, -1, 0], [0, 0, -1], ] - parameters['ibrav'] = -12 - parameters['a'] = a - parameters['b'] = b - parameters['c'] = c - parameters['cosac'] = cosac - cell_qe = np.array([ - [a, 0, 0], - [0, b, 0], - [_cy, 0, _cz], - ]) - elif ( - abs(a - b) < eps - and - abs(cosac+cosbc) < eps - ): - _a = ((a**2 + a*b*cosab)/2)**0.5 - _b = ((a**2 - a*b*cosab)/2)**0.5 - _cosac = a*cosac/_b - _cy = c*_cosac - _cz = -c*np.sqrt(1-_cosac**2) - - if not np.allclose(cell, np.array([ - [_a, _b, 0], - [_a, -_b, 0], - [0, _cy, _cz], - ]), atol=eps): + parameters["ibrav"] = -12 + parameters["a"] = a + parameters["b"] = b + parameters["c"] = c + parameters["cosac"] = cosac + cell_qe = np.array( + [ + [a, 0, 0], + [0, b, 0], + [_cy, 0, _cz], + ] + ) + elif abs(a - b) < eps and abs(cosac + cosbc) < eps: + _a = ((a**2 + a * b * cosab) / 2) ** 0.5 + _b = ((a**2 - a * b * cosab) / 2) ** 0.5 + _cosac = a * cosac / _b + _cy = c * _cosac + _cz = -c * np.sqrt(1 - _cosac**2) + + if not np.allclose( + cell, + np.array( + [ + [_a, _b, 0], + [_a, -_b, 0], + [0, _cy, _cz], + ] + ), + atol=eps, + ): raise ValueError("Invalid cell for MCLC") rotation_matrix = SymmOp.from_axis_angle_and_translation( - axis=[0,0,1], angle=90 - ) + axis=[0, 0, 1], angle=90 + ) pym_structure.apply_operation(rotation_matrix) @@ -1029,52 +1198,78 @@ def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): [1, 0, 0], [0, 0, -1], ] - parameters['ibrav'] = -13 - parameters['a'] = _b*2 - parameters['b'] = _a*2 - parameters['c'] = c*2 - parameters['cosac'] = _cosac - cell_qe = np.array([ - [_a, _b, 0], - [-_a, _b, 0], - [_cy, 0, _cz], - ]) + parameters["ibrav"] = -13 + parameters["a"] = _b * 2 + parameters["b"] = _a * 2 + parameters["c"] = c * 2 + parameters["cosac"] = _cosac + cell_qe = np.array( + [ + [_a, _b, 0], + [-_a, _b, 0], + [_cy, 0, _cz], + ] + ) else: sinab = np.sin(np.arccos(cosab)) - cell_qe = np.array([ - [a, 0, 0], - [b*cosab, b*sinab, 0], - [c*cosac, c*(cosbc-cosac*cosab)/sinab, c*np.sqrt( 1 + 2*cosbc*cosac*cosab- cosbc**2-cosac**2-cosab**2 )/sinab], - ]) - transformation_matrix =la.inv(cell) @ cell_qe + cell_qe = np.array( + [ + [a, 0, 0], + [b * cosab, b * sinab, 0], + [ + c * cosac, + c * (cosbc - cosac * cosab) / sinab, + c + * np.sqrt( + 1 + 2 * cosbc * cosac * cosab - cosbc**2 - cosac**2 - cosab**2 + ) + / sinab, + ], + ] + ) + transformation_matrix = la.inv(cell) @ cell_qe transformation_matrix_rounded = np.round(transformation_matrix) if np.allclose(transformation_matrix, transformation_matrix_rounded, atol=eps): transformation_matrix = transformation_matrix_rounded.astype(int) else: - cell_str = np.array2string(cell, precision=6, separator=',', suppress_small=True) - pattern_str = np.array2string(cell_qe, precision=6, separator=',', suppress_small=True) - transformation_matrix_str = np.array2string(transformation_matrix_rounded, precision=6, separator=',', suppress_small=True) - raise ValueError(f"Cell \n{cell_str} can not be converted to the Quantum ESPRESSO convention \n{pattern_str} using integer transformation matrix. Detected non-integer transformation matrix: \n{transformation_matrix_str}. Please check the cell parameters or use ibrav = 0.") + cell_str = np.array2string( + cell, precision=6, separator=",", suppress_small=True + ) + pattern_str = np.array2string( + cell_qe, precision=6, separator=",", suppress_small=True + ) + transformation_matrix_str = np.array2string( + transformation_matrix_rounded, + precision=6, + separator=",", + suppress_small=True, + ) + raise ValueError( + f"Cell \n{cell_str} can not be converted to the Quantum ESPRESSO convention \n{pattern_str} using integer transformation matrix. Detected non-integer transformation matrix: \n{transformation_matrix_str}. Please check the cell parameters or use ibrav = 0." + ) if not abs(np.linalg.det(transformation_matrix) - 1) < eps: - raise ValueError(f"Transformation matrix \n{transformation_matrix_str} is not a valid rotation matrix. Please check the cell parameters or use ibrav = 0.") + raise ValueError( + f"Transformation matrix \n{transformation_matrix_str} is not a valid rotation matrix. Please check the cell parameters or use ibrav = 0." + ) - pym_structure.apply_operation(SymmOp.from_rotation_and_translation( - rotation_matrix=transformation_matrix, - translation_vec=[0, 0, 0], - )) + pym_structure.apply_operation( + SymmOp.from_rotation_and_translation( + rotation_matrix=transformation_matrix, + translation_vec=[0, 0, 0], + ) + ) cell = pym_structure.lattice.matrix if np.allclose(cell, cell_qe, atol=eps): - - parameters['ibrav'] = 14 - parameters['a'] = a - parameters['b'] = b - parameters['c'] = c - parameters['cosab'] = cosab - parameters['cosbc'] = cosbc - parameters['cosac'] = cosac + parameters["ibrav"] = 14 + parameters["a"] = a + parameters["b"] = b + parameters["c"] = c + parameters["cosab"] = cosab + parameters["cosbc"] = cosbc + parameters["cosac"] = cosac transformation_matrix = [ [1, 0, 0], @@ -1082,59 +1277,70 @@ def convert_standardized_structure_pymatgen_to_qe(pym_structure, eps=1e-6): [0, 0, 1], ] else: - cell_str = np.array2string(cell, precision=6, separator=',', suppress_small=True) - pattern_str = np.array2string(cell_qe, precision=6, separator=',', suppress_small=True) - raise ValueError(f"Cell \n{cell_str} does not match the expected pattern \n{pattern_str}. Please check the cell parameters or use ibrav = 0.") + cell_str = np.array2string( + cell, precision=6, separator=",", suppress_small=True + ) + pattern_str = np.array2string( + cell_qe, precision=6, separator=",", suppress_small=True + ) + raise ValueError( + f"Cell \n{cell_str} does not match the expected pattern \n{pattern_str}. Please check the cell parameters or use ibrav = 0." + ) pym_structure.make_supercell(transformation_matrix) return pym_structure, parameters + def check_conversion(structure, log=print): cell = np.array(structure.cell) cellpar = get_cellpar(structure.cell) cell_qe, parameters = get_cell_qe_convention(structure.cell, eps=1e-6) cellpar_qe = get_cellpar(cell_qe) ibrav_ase = get_ibrav_ase(structure) - log(f'Example: {ibrav_bravais_lattice_map_qe[ibrav_ase]} [{parameters["ibrav"]}]') - - log('Cell from StructureData',) - log(np.array2string( - cell, precision=6, separator=',', - suppress_small=True)) - - log('Cell converted to Quantum ESPRESSO convention',) - log(np.array2string( - cell_qe, precision=6, separator=',', - suppress_small=True)) - log(f'Parameters: {parameters}') - log(f'ibrav_ase: {ibrav_ase}') - log(f'Cell parameters: a, b, c, cosab, cosac, cosbc') - log(f' {cellpar}') - log(f'Cell parameters (QE): a, b, c, cosab, cosac, cosbc') - log(f' {cellpar_qe}') - log('-'*100) + log(f"Example: {ibrav_bravais_lattice_map_qe[ibrav_ase]} [{parameters['ibrav']}]") + + log( + "Cell from StructureData", + ) + log(np.array2string(cell, precision=6, separator=",", suppress_small=True)) + + log( + "Cell converted to Quantum ESPRESSO convention", + ) + log(np.array2string(cell_qe, precision=6, separator=",", suppress_small=True)) + log(f"Parameters: {parameters}") + log(f"ibrav_ase: {ibrav_ase}") + log("Cell parameters: a, b, c, cosab, cosac, cosbc") + log(f" {cellpar}") + log("Cell parameters (QE): a, b, c, cosab, cosac, cosbc") + log(f" {cellpar_qe}") + log("-" * 100) T = base_transformation(structure.cell, cell_qe) - log('It requires a basis change to convert the cell to Quantum ESPRESSO convention',) - log(np.array2string( - T, precision=6, separator=',', - suppress_small=True) - ) - log(f'Determinant of T: {np.linalg.det(T)}') + log( + "It requires a basis change to convert the cell to Quantum ESPRESSO convention", + ) + log(np.array2string(T, precision=6, separator=",", suppress_small=True)) + log(f"Determinant of T: {np.linalg.det(T)}") reconstructed_cell_qe = np.dot(T, cell.T).T restored_cell = np.dot(np.linalg.inv(T), cell_qe).T - log('reconstructed cell',) - log(np.array2string( - reconstructed_cell_qe, precision=6, separator=',', - suppress_small=True)) - log('restored cell',) - log(np.array2string( - restored_cell, precision=6, separator=',', - suppress_small=True)) - log(f'Difference between reconstructed cell: {np.linalg.norm(reconstructed_cell_qe - cell_qe)}') - log(f'Difference between restored cell: {np.linalg.norm(restored_cell - cell)}') - + log( + "reconstructed cell", + ) + log( + np.array2string( + reconstructed_cell_qe, precision=6, separator=",", suppress_small=True + ) + ) + log( + "restored cell", + ) + log(np.array2string(restored_cell, precision=6, separator=",", suppress_small=True)) + log( + f"Difference between reconstructed cell: {np.linalg.norm(reconstructed_cell_qe - cell_qe)}" + ) + log(f"Difference between restored cell: {np.linalg.norm(restored_cell - cell)}") diff --git a/src/aiida_mechanical/tools/workchain.py b/src/aiida_mechanical/tools/workchain.py index 997a217..1fd0d81 100644 --- a/src/aiida_mechanical/tools/workchain.py +++ b/src/aiida_mechanical/tools/workchain.py @@ -3,30 +3,31 @@ from aiida.common import NotExistentAttributeError from aiida.tools import delete_nodes from rich import print as rprint -from aiida.common.links import LinkType from aiida.engine import ProcessState + def get_descendants_by_label( - parent_workchain: orm.WorkChainNode, - link_label_filter: str - ) -> orm.WorkChainNode: + parent_workchain: orm.WorkChainNode, link_label_filter: str +) -> orm.WorkChainNode: """Get the descendant workchains of the parent workchain by the link label.""" try: return parent_workchain.base.links.get_outgoing( link_label_filter=link_label_filter - ).all() + ).all() except AttributeError: return None + def get_descendants_by_type( - parent_workchain: orm.WorkChainNode, - link_type: LinkType = LinkType.CALL_WORK - ) -> dict: + parent_workchain: orm.WorkChainNode, link_type: LinkType = LinkType.CALL_WORK +) -> dict: """Get the descendant nodes of the parent workchain.""" descendants = {} try: - for node, link_type, link_label in parent_workchain.base.links.get_outgoing(link_type=link_type).all(): + for node, link_type, link_label in parent_workchain.base.links.get_outgoing( + link_type=link_type + ).all(): if link_label not in descendants: descendants[link_label] = [] descendants[link_label].append(node) @@ -34,24 +35,35 @@ def get_descendants_by_type( except AttributeError: return None + def get_workdirs( workchain: orm.WorkChainNode, ): workdirs = {} - for node, link_type, link_label in workchain.base.links.get_outgoing(link_type=LinkType.CALL_CALC).all(): - if link_label.startswith('iteration_'): # only get the workdirs of the iterations + for node, link_type, link_label in workchain.base.links.get_outgoing( + link_type=LinkType.CALL_CALC + ).all(): + if link_label.startswith( + "iteration_" + ): # only get the workdirs of the iterations workdirs[link_label] = node.get_remote_workdir() return workdirs + def get_iterations( workchain: orm.WorkChainNode, ): iterations = [] - for node, link_type, link_label in workchain.base.links.get_outgoing(link_type=LinkType.CALL_CALC).all(): - if link_label.startswith('iteration_'): # only get the workdirs of the iterations + for node, link_type, link_label in workchain.base.links.get_outgoing( + link_type=LinkType.CALL_CALC + ).all(): + if link_label.startswith( + "iteration_" + ): # only get the workdirs of the iterations iterations.append(node) return iterations + def delete_nodes_and_remote_folder( process: orm.ProcessNode, ): @@ -68,11 +80,10 @@ def delete_nodes_and_remote_folder( def check_process_state( process: orm.ProcessNode, only_report_error: bool = True, - ): - from collections import deque - from io import StringIO - - source_db, source_id = process.inputs.thermo_pw.structure.base.extras.get_many(('source_db', 'source_id')) +): + source_db, source_id = process.inputs.thermo_pw.structure.base.extras.get_many( + ("source_db", "source_id") + ) source = f"{source_db}-{source_id}" if process.process_state == ProcessState.WAITING: @@ -85,30 +96,44 @@ def check_process_state( if process.exit_status != 0: final_iteration = get_iterations(process)[-1] if final_iteration.is_killed: - rprint(f"[bold red] {source}[{process.pk}]: {process.process_state} killed in final iteration [{final_iteration.pk}]") + rprint( + f"[bold red] {source}[{process.pk}]: {process.process_state} killed in final iteration [{final_iteration.pk}]" + ) return source if final_iteration.is_excepted: - rprint(f"[bold red] {source}[{process.pk}]: {process.process_state} excepted in final iteration [{final_iteration.pk}]") + rprint( + f"[bold red] {source}[{process.pk}]: {process.process_state} excepted in final iteration [{final_iteration.pk}]" + ) return source stderr = final_iteration.get_scheduler_stderr() - aiida_out = final_iteration.outputs.retrieved.get_object_content("aiida.out") + aiida_out = final_iteration.outputs.retrieved.get_object_content( + "aiida.out" + ) for error_flag, error_message in ( - ('ERROR_TOO_MANY_PROCESSES', 'there are processes with no planes.'), - ('ERROR_TIME_LIMIT', 'TIME LIMIT'), + ("ERROR_TOO_MANY_PROCESSES", "there are processes with no planes."), + ("ERROR_TIME_LIMIT", "TIME LIMIT"), ): if error_message in stderr: - rprint(f"[bold red] {source}[{process.pk}]: {error_flag} {error_message} in final iteration [{final_iteration.pk}]") + rprint( + f"[bold red] {source}[{process.pk}]: {error_flag} {error_message} in final iteration [{final_iteration.pk}]" + ) return source for error_flag, error_message in ( - ('ERROR_NSTEP', 'Incorrect nstep, check elastic_algorithm'), - ): + ("ERROR_NSTEP", "Incorrect nstep, check elastic_algorithm"), + ): if error_message in aiida_out: - rprint(f"[bold red] {source}[{process.pk}]: {error_flag} {error_message} in final iteration [{final_iteration.pk}]") + rprint( + f"[bold red] {source}[{process.pk}]: {error_flag} {error_message} in final iteration [{final_iteration.pk}]" + ) return source - rprint(f"[bold red] {source}[{process.pk}]: {process.process_state} finished with exit status {process.exit_status} and final iteration [{final_iteration.pk}]") + rprint( + f"[bold red] {source}[{process.pk}]: {process.process_state} finished with exit status {process.exit_status} and final iteration [{final_iteration.pk}]" + ) elif not only_report_error: - rprint(f"[bold green] {source}[{process.pk}]: {process.process_state} finished with exit status {process.exit_status}") + rprint( + f"[bold green] {source}[{process.pk}]: {process.process_state} finished with exit status {process.exit_status}" + ) else: rprint(f"[bold red] {source}[{process.pk}]: {process.process_state} ") - return source \ No newline at end of file + return source diff --git a/src/aiida_mechanical/workflows/__init__.py b/src/aiida_mechanical/workflows/__init__.py index 6dbaed7..6430c2c 100644 --- a/src/aiida_mechanical/workflows/__init__.py +++ b/src/aiida_mechanical/workflows/__init__.py @@ -1,5 +1,3 @@ from .base import Thermo_pwBaseWorkChain -__all__ = [ - 'Thermo_pwBaseWorkChain' - ] \ No newline at end of file +__all__ = ["Thermo_pwBaseWorkChain"] diff --git a/src/aiida_mechanical/workflows/base.py b/src/aiida_mechanical/workflows/base.py index 8451115..e81d148 100644 --- a/src/aiida_mechanical/workflows/base.py +++ b/src/aiida_mechanical/workflows/base.py @@ -1,14 +1,19 @@ # -*- coding: utf-8 -*- """Workchain to run a Quantum ESPRESSO pw.x calculation with automated error handling and restarts.""" + from aiida import orm -from aiida.common import AttributeDict, exceptions +from aiida.common import AttributeDict from aiida.engine import BaseRestartWorkChain, calcfunction, while_ -from aiida.plugins import CalculationFactory, GroupFactory from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain -from aiida_quantumespresso.common.types import ElectronicType, RestartType, SpinType +from aiida_quantumespresso.common.types import ElectronicType, SpinType from aiida_quantumespresso.workflows.protocols.utils import ProtocolMixin -from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import create_kpoints_from_distance -from aiida_mechanical.tools.structures import get_standardized_structure_pymatgen, convert_standardized_structure_pymatgen_to_qe +from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import ( + create_kpoints_from_distance, +) +from aiida_mechanical.tools.structures import ( + get_standardized_structure_pymatgen, + convert_standardized_structure_pymatgen_to_qe, +) from aiida_mechanical.calculations.thermo_pw import Thermo_pwCalculation @@ -76,7 +81,8 @@ def get_protocol_filepath(cls): from importlib_resources import files import aiida_mechanical.workflows.protocols as thermo_pw_protocols - return files(thermo_pw_protocols) / 'base.yaml' + + return files(thermo_pw_protocols) / "base.yaml" @classmethod def get_builder_from_protocol( @@ -89,15 +95,15 @@ def get_builder_from_protocol( spin_type=SpinType.NONE, initial_magnetic_moments=None, options=None, - **_ - ): - + **_, + ): inputs = cls.get_protocol_inputs(protocol, overrides) from copy import deepcopy + pw_overrides = deepcopy(inputs) - pw_overrides['pw'] = pw_overrides.pop('thermo_pw') - pw_overrides['pw'].pop('thermo_control') + pw_overrides["pw"] = pw_overrides.pop("thermo_pw") + pw_overrides["pw"].pop("thermo_control") pw_builder = PwBaseWorkChain.get_builder_from_protocol( code=code, structure=structure, @@ -112,15 +118,15 @@ def get_builder_from_protocol( builder = cls.get_builder() builder.thermo_pw._data = pw_builder.pw._data - builder.thermo_pw['thermo_control'] = inputs['thermo_pw']['thermo_control'] + builder.thermo_pw["thermo_control"] = inputs["thermo_pw"]["thermo_control"] - builder.clean_workdir = orm.Bool(inputs['clean_workdir']) - if 'kpoints' in inputs: - builder.kpoints = inputs['kpoints'] + builder.clean_workdir = orm.Bool(inputs["clean_workdir"]) + if "kpoints" in inputs: + builder.kpoints = inputs["kpoints"] else: - builder.kpoints_distance = orm.Float(inputs['kpoints_distance']) - builder.kpoints_force_parity = orm.Bool(inputs['kpoints_force_parity']) - builder.max_iterations = orm.Int(inputs['max_iterations']) + builder.kpoints_distance = orm.Float(inputs["kpoints_distance"]) + builder.kpoints_force_parity = orm.Bool(inputs["kpoints_force_parity"]) + builder.max_iterations = orm.Int(inputs["max_iterations"]) return builder @@ -131,14 +137,16 @@ def format_structure(structure: orm.StructureData): extras = structure.base.extras.all standardized_pym_structure = get_standardized_structure_pymatgen(structure) - qe_pym_structure, structure_parameters = convert_standardized_structure_pymatgen_to_qe(standardized_pym_structure) + qe_pym_structure, structure_parameters = ( + convert_standardized_structure_pymatgen_to_qe(standardized_pym_structure) + ) formated_structure = orm.StructureData(pymatgen_structure=qe_pym_structure) formated_structure.base.extras.set_many(extras) return { - 'formated_structure': formated_structure, - 'structure_parameters': orm.Dict(dict=structure_parameters) - } + "formated_structure": formated_structure, + "structure_parameters": orm.Dict(dict=structure_parameters), + } def setup(self): """Call the ``setup`` of the ``BaseRestartWorkChain`` and create the inputs dictionary in ``self.ctx.inputs``. @@ -150,17 +158,20 @@ def setup(self): default namelists for the ``parameters`` are set to empty dictionaries if not specified. """ super().setup() - self.ctx.inputs = AttributeDict(self.exposed_inputs(Thermo_pwCalculation, 'thermo_pw')) + self.ctx.inputs = AttributeDict( + self.exposed_inputs(Thermo_pwCalculation, "thermo_pw") + ) results = self.format_structure(self.ctx.inputs.structure) - self.ctx.inputs.structure = results['formated_structure'] + self.ctx.inputs.structure = results["formated_structure"] parameters = self.ctx.inputs.parameters.get_dict() - parameters['SYSTEM']['ibrav'] = results['structure_parameters']['ibrav'] + parameters["SYSTEM"]["ibrav"] = results["structure_parameters"]["ibrav"] self.ctx.inputs.parameters = orm.Dict(dict=parameters) - self.ctx.inputs.settings = self.ctx.inputs.settings.get_dict() if 'settings' in self.ctx.inputs else {} - + self.ctx.inputs.settings = ( + self.ctx.inputs.settings.get_dict() if "settings" in self.ctx.inputs else {} + ) def validate_kpoints(self): """Validate the inputs related to k-points. @@ -169,19 +180,19 @@ def validate_kpoints(self): the case of the latter, the `KpointsData` will be constructed for the input `StructureData` using the `create_kpoints_from_distance` calculation function. """ - if all(key not in self.inputs for key in ['kpoints', 'kpoints_distance']): + if all(key not in self.inputs for key in ["kpoints", "kpoints_distance"]): return self.exit_codes.ERROR_INVALID_INPUT_KPOINTS try: kpoints = self.inputs.kpoints except AttributeError: inputs = { - 'structure': self.inputs.thermo_pw.structure, - 'distance': self.inputs.kpoints_distance, - 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), - 'metadata': { - 'call_link_label': 'create_kpoints_from_distance' - } + "structure": self.inputs.thermo_pw.structure, + "distance": self.inputs.kpoints_distance, + "force_parity": self.inputs.get( + "kpoints_force_parity", orm.Bool(False) + ), + "metadata": {"call_link_label": "create_kpoints_from_distance"}, } kpoints = create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg @@ -190,7 +201,6 @@ def validate_kpoints(self): def prepare_process(self): pass - @staticmethod def _clean_workdir(node): """Clean the working directories of all child calculations if `clean_workdir=True` in the inputs.""" @@ -212,12 +222,13 @@ def on_terminated(self): super().on_terminated() if self.inputs.clean_workdir.value is False: - self.report('remote folders will not be cleaned') + self.report("remote folders will not be cleaned") return if self.node.is_finished_ok: cleaned_calcs = self._clean_workdir(self.node) if cleaned_calcs: - self.report(f"cleaned remote folders of calculations: {' '.join(map(str, cleaned_calcs))}") - + self.report( + f"cleaned remote folders of calculations: {' '.join(map(str, cleaned_calcs))}" + ) diff --git a/utils/plot.py b/utils/plot.py index a36846d..45c418f 100644 --- a/utils/plot.py +++ b/utils/plot.py @@ -1,6 +1,9 @@ +from aiida import orm + + def plot_group( ax, - group: orm.Group|str, + group: orm.Group | str, keys: list[str], **kwargs, ): From 4a296cf46c7db04fd26a1a0c52eec81c9dff23fb Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 16:18:05 +0200 Subject: [PATCH 11/17] ci: fix double matrix error by targeting specific Python version in hatch test --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6388a85..d582b67 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,4 +33,4 @@ jobs: with: python-version: ${{ matrix.python-version }} - run: pip install click==8.1.8 hatch - - run: hatch test + - run: hatch test --py ${{ matrix.python-version }} From 406093e552d34ea66c80eefd1fb13e3a37dfa4d8 Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 16:22:07 +0200 Subject: [PATCH 12/17] chore: add SFE dependencies and register dislocation entry points --- pyproject.toml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f287ae4..953b138 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,13 +25,20 @@ dependencies = [ "aiida_core[atomic_tools]~=2.6", "aiida-quantumespresso>=4.11", "aiida-pseudo~=1.1", + "ase", "click~=8.0", + "deprecated>=1.2.0", "importlib_resources", "jsonschema", + "matplotlib", "numpy", "pydantic~=2.0", "packaging", + "pymatgen", "qe-tools~=2.0", + "pyyaml", + "rich", + "sympy", "xmlschema~=2.0", ] @@ -104,11 +111,21 @@ lint.ignore = [ [project.entry-points.'aiida.calculations'] "mechanical.thermo_pw" = "aiida_mechanical.calculations.thermo_pw:Thermo_pwCalculation" +[project.entry-points.'aiida.data'] +"mechanical.dislocation.cleavaged_structure" = "aiida_mechanical.data.cleavaged_structure:CleavagedStructureData" +"mechanical.dislocation.faulted_structure" = "aiida_mechanical.data.faulted_structure:FaultedStructureData" + [project.entry-points.'aiida.parsers'] "mechanical.thermo_pw" = "aiida_mechanical.parsers.thermo_pw:Thermo_pwParser" [project.entry-points.'aiida.workflows'] "mechanical.thermo_pw.base" = "aiida_mechanical.workflows.base:Thermo_pwBaseWorkChain" +"mechanical.dislocation.gsfe" = "aiida_mechanical.workflows.dislocation.gsfe:GSFEWorkChain" +"mechanical.dislocation.gsfe_relax" = "aiida_mechanical.workflows.dislocation.gsfe_relax:GSFERelaxWorkChain" +"mechanical.dislocation.isfe" = "aiida_mechanical.workflows.dislocation.isfe:ISFEWorkChain" +"mechanical.dislocation.esfe" = "aiida_mechanical.workflows.dislocation.esfe:ESFEWorkChain" +"mechanical.dislocation.usfe" = "aiida_mechanical.workflows.dislocation.usfe:USFEWorkChain" +"mechanical.dislocation.twinning" = "aiida_mechanical.workflows.dislocation.twinning:TwinningWorkChain" [tool.flynt] line-length = 120 From 7ea6a6a7702163b4db6756fd3ac9d83cab3c387c Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 16:25:08 +0200 Subject: [PATCH 13/17] feat: port custom Cleavaged & Faulted data models, gliding systems, and static CIF/XYZ structures --- src/aiida_mechanical/data/__init__.py | 12 +- .../data/cleavaged_structure.py | 251 +++++++++ .../data/faulted_structure.py | 302 +++++++++++ src/aiida_mechanical/data/gliding_systems.py | 510 ++++++++++++++++++ .../data/structures/cif/Al.cif | 33 ++ .../data/structures/cif/AsTe.cif | 34 ++ .../data/structures/cif/CrH.cif | 34 ++ .../data/structures/cif/Cu.cif | 33 ++ .../data/structures/cif/HfAlPd2.cif | 36 ++ .../data/structures/cif/IrS2.cif | 44 ++ .../data/structures/cif/KCl.cif | 34 ++ .../data/structures/cif/MgB2.cif | 35 ++ .../data/structures/cif/MoN.cif | 34 ++ .../data/structures/cif/NaCl.cif | 34 ++ .../data/structures/cif/Nb3Sn.cif | 40 ++ .../data/structures/cif/NbCoSb.cif | 35 ++ .../data/structures/cif/NiTi.cif | 34 ++ .../data/structures/cif/TaRu3C.cif | 37 ++ .../data/structures/cif/TaSe2.cif | 38 ++ .../data/structures/cif/TiAlPt.cif | 38 ++ .../data/structures/cif/TiSbRu.cif | 35 ++ .../data/structures/cif/V.cif | 33 ++ .../data/structures/cif/VC.cif | 34 ++ .../data/structures/cif/YSnPd2.cif | 36 ++ .../data/structures/cif/ZrAlNi2.cif | 36 ++ .../data/structures/cif/ZrGaNi2.cif | 36 ++ .../data/structures/cif/ZrRuSb.cif | 35 ++ .../xyz/icsd-659260-IrS2-205-C2.xyz | 14 + .../xyz/icsd-77392-TaRu3C-221-E21.xyz | 7 + .../xyz/mpds-S1605628-AsTe-225-B1.xyz | 4 + .../xyz/mpds-S1612207-NbCoSb-216-C1b.xyz | 5 + .../xyz/mpds-S378780-Nb3Sn-223-A15.xyz | 10 + src/aiida_mechanical/data/system.py | 118 ++++ 33 files changed, 2050 insertions(+), 1 deletion(-) create mode 100644 src/aiida_mechanical/data/cleavaged_structure.py create mode 100644 src/aiida_mechanical/data/faulted_structure.py create mode 100644 src/aiida_mechanical/data/gliding_systems.py create mode 100644 src/aiida_mechanical/data/structures/cif/Al.cif create mode 100644 src/aiida_mechanical/data/structures/cif/AsTe.cif create mode 100644 src/aiida_mechanical/data/structures/cif/CrH.cif create mode 100644 src/aiida_mechanical/data/structures/cif/Cu.cif create mode 100644 src/aiida_mechanical/data/structures/cif/HfAlPd2.cif create mode 100644 src/aiida_mechanical/data/structures/cif/IrS2.cif create mode 100644 src/aiida_mechanical/data/structures/cif/KCl.cif create mode 100644 src/aiida_mechanical/data/structures/cif/MgB2.cif create mode 100644 src/aiida_mechanical/data/structures/cif/MoN.cif create mode 100644 src/aiida_mechanical/data/structures/cif/NaCl.cif create mode 100644 src/aiida_mechanical/data/structures/cif/Nb3Sn.cif create mode 100644 src/aiida_mechanical/data/structures/cif/NbCoSb.cif create mode 100644 src/aiida_mechanical/data/structures/cif/NiTi.cif create mode 100644 src/aiida_mechanical/data/structures/cif/TaRu3C.cif create mode 100644 src/aiida_mechanical/data/structures/cif/TaSe2.cif create mode 100644 src/aiida_mechanical/data/structures/cif/TiAlPt.cif create mode 100644 src/aiida_mechanical/data/structures/cif/TiSbRu.cif create mode 100644 src/aiida_mechanical/data/structures/cif/V.cif create mode 100644 src/aiida_mechanical/data/structures/cif/VC.cif create mode 100644 src/aiida_mechanical/data/structures/cif/YSnPd2.cif create mode 100644 src/aiida_mechanical/data/structures/cif/ZrAlNi2.cif create mode 100644 src/aiida_mechanical/data/structures/cif/ZrGaNi2.cif create mode 100644 src/aiida_mechanical/data/structures/cif/ZrRuSb.cif create mode 100644 src/aiida_mechanical/data/structures/xyz/icsd-659260-IrS2-205-C2.xyz create mode 100644 src/aiida_mechanical/data/structures/xyz/icsd-77392-TaRu3C-221-E21.xyz create mode 100644 src/aiida_mechanical/data/structures/xyz/mpds-S1605628-AsTe-225-B1.xyz create mode 100644 src/aiida_mechanical/data/structures/xyz/mpds-S1612207-NbCoSb-216-C1b.xyz create mode 100644 src/aiida_mechanical/data/structures/xyz/mpds-S378780-Nb3Sn-223-A15.xyz create mode 100644 src/aiida_mechanical/data/system.py diff --git a/src/aiida_mechanical/data/__init__.py b/src/aiida_mechanical/data/__init__.py index 0b190a0..8b67019 100644 --- a/src/aiida_mechanical/data/__init__.py +++ b/src/aiida_mechanical/data/__init__.py @@ -1,3 +1,13 @@ from .printer import Printer +from .cleavaged_structure import CleavagedStructure, CleavagedStructureData, PlanarStructure +from .faulted_structure import FaultedStructure, FaultedStructureData, GeneralFaultStructurePoint -__all__ = ("Printer",) +__all__ = ( + "Printer", + "PlanarStructure", + "CleavagedStructure", + "CleavagedStructureData", + "FaultedStructure", + "FaultedStructureData", + "GeneralFaultStructurePoint", +) diff --git a/src/aiida_mechanical/data/cleavaged_structure.py b/src/aiida_mechanical/data/cleavaged_structure.py new file mode 100644 index 0000000..181676c --- /dev/null +++ b/src/aiida_mechanical/data/cleavaged_structure.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import typing as ty + +import numpy +from aiida.common.exceptions import ModificationNotAllowed +from aiida.orm import Data +from ase import Atoms +from ase.build import make_supercell +from pymatgen.io.ase import AseAtomsAdaptor +from pymatgen.symmetry.analyzer import SpacegroupAnalyzer + +from aiida_mechanical.data.gliding_systems import ( + GlidingPlaneConfig, + GlidingSystem, + get_gliding_system, +) +from aiida_mechanical.tools.structure_builder import build_atoms_surface +from aiida_mechanical.tools.structure_utils import get_strukturbericht, group_by_layers + +def find_rotation(src_vectors, dst_vectors): + """ + Finds the rotation matrix R, axis, and angle between two sets of vectors. + src_vectors: (3, N) array + dst_vectors: (3, N) array + """ + + src_norm = src_vectors / numpy.linalg.norm(src_vectors, axis=0) + dst_norm = dst_vectors / numpy.linalg.norm(dst_vectors, axis=0) + + # 1. Compute Covariance Matrix H + H = dst_norm @ src_norm.T + + # 2. SVD + U, S, Vt = numpy.linalg.svd(H) + + # 3. Compute Rotation Matrix R + R = U @ Vt + + # Special reflection check + if numpy.linalg.det(R) < 0: + U[:, -1] *= -1 + R = U @ Vt + + # 4. Extract Angle + trace_r = numpy.trace(R) + angle = numpy.arccos(numpy.clip((trace_r - 1.0) / 2.0, -1.0, 1.0)) + + # 5. Extract Axis + # R - R.T = 2 * sin(theta) * skew_symmetric(axis) + if angle < 1e-6: + axis = numpy.array([0.0, 0.0, 1.0]) # Arbitrary axis for zero rotation + else: + axis_vals = numpy.array([ + R[2, 1] - R[1, 2], + R[0, 2] - R[2, 0], + R[1, 0] - R[0, 1] + ]) + axis = axis_vals / (2 * numpy.sin(angle)) + axis = axis / numpy.linalg.norm(axis) + + return angle* 180 / numpy.pi, axis + +class PlanarStructure: + """Shared helper for conventional cell operations on a selected gliding plane.""" + + def __init__( + self, + ase_atoms: Atoms, + n_unit_cells: int, + gliding_plane: ty.Optional[str] = None, + ) -> None: + self._ase_atoms = ase_atoms + self._n_unit_cells = n_unit_cells + if not gliding_plane: + self._gliding_plane = self.gliding_system.default_plane + else: + self._gliding_plane = gliding_plane + + @property + def unit_cell(self) -> Atoms: + """Get the original unit-cell structure.""" + return self._ase_atoms + + @property + def n_unit_cells(self) -> int: + """Get the number of repeated unit cells.""" + return self._n_unit_cells + + @property + def gliding_plane(self) -> str: + """Get the stored gliding plane.""" + return self._gliding_plane + + @property + def strukturbericht(self) -> str: + """Get the Strukturbericht designation.""" + strukturbericht = get_strukturbericht(self.unit_cell) + if not strukturbericht: + raise ValueError('No match found in the provided list of prototypes.') + return strukturbericht + + @property + def gliding_system(self) -> GlidingSystem: + """Get the gliding system instance.""" + gliding_system = get_gliding_system(self.strukturbericht) + if not gliding_system: + raise ValueError('No match found in the provided list of prototypes.') + return gliding_system + + @property + def is_primitive(self) -> bool: + """Check if the unit cell is primitive.""" + pmg_struct = AseAtomsAdaptor.get_structure(self.unit_cell) + prim_pmg = pmg_struct.get_primitive_structure() + return pmg_struct.composition == prim_pmg.composition + + @property + def wyckoff_elements(self) -> dict[str, str]: + """Get Wyckoff symbols for ASE atoms.""" + pmg_struct = AseAtomsAdaptor.get_structure(self.unit_cell) + sga = SpacegroupAnalyzer(pmg_struct, symprec=1e-5) + symmetrized = sga.get_symmetrized_structure() + return {w: e.symbol for w, e in zip(symmetrized.wyckoff_letters, symmetrized.elements)} + + @property + def surface_area(self) -> float: + """Calculate the surface area of the conventional cell.""" + cell = self.get_conventional_structure().cell + return float(numpy.linalg.norm(numpy.cross(cell[0], cell[1]))) + + def _get_effective_gliding_plane(self) -> str: + """Return the configured gliding plane or the default detected one.""" + return self.gliding_plane or self.gliding_system.default_plane + + def _prepare_plane_data(self) -> GlidingPlaneConfig: + """Get the plane configuration for the effective gliding plane.""" + return self.gliding_system.get_plane(self._get_effective_gliding_plane()) + + def get_conventional_structure( + self, + P: ty.Optional[ty.Union[list[ty.Any], 'numpy.ndarray']] = None, + print_info: bool = False, + ) -> Atoms: + """Generate the conventional structure.""" + if print_info: + print(f'Strukturbericht {self.strukturbericht} detected') + + plane_config = self._prepare_plane_data() + if P is None: + P = plane_config.transformation_matrix + else: + P = numpy.array(P) + + conventional_structure = make_supercell(self.unit_cell, P) + + target_unit_vectors = plane_config.target_unit_vectors + if target_unit_vectors is not None: + angle, axis = find_rotation(conventional_structure.cell.T, numpy.array(target_unit_vectors).T) + conventional_structure.rotate(angle, axis, rotate_cell =True) + return conventional_structure + +class CleavagedStructure(PlanarStructure): + """Helper for conventional and cleavaged structure generation.""" + + def get_cleavaged_structure( + self, + vacuum_spacing: float = 1.0, + print_info: bool = False, + ) -> Atoms: + """Generate the cleavaged surface structure from the conventional cell.""" + if print_info: + print(f'Strukturbericht {self.strukturbericht} detected') + + conventional_structure = self.get_conventional_structure() + plane_config = self._prepare_plane_data() + layers_dict = group_by_layers(conventional_structure) + + if len(layers_dict) != plane_config.n_layers: + raise ValueError( + f'Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers} for ' + f'{self.strukturbericht} with gliding plane {self._get_effective_gliding_plane()}.' + ) + + return build_atoms_surface( + conventional_structure, + self.n_unit_cells, + layers_dict, + print_info=print_info, + vacuum_spacing=vacuum_spacing, + ) + + +class CleavagedStructureData(Data): + """Pure configuration node for cleavaged slab generation.""" + + N_UNIT_CELLS_KEY = 'n_unit_cells' + GLIDING_PLANE_KEY = 'gliding_plane' + VACUUM_SPACINGS_KEY = 'vacuum_spacings' + + def __init__( + self, + n_unit_cells: ty.Optional[int] = None, + gliding_plane: ty.Optional[str] = None, + vacuum_spacings: ty.Optional[ty.Sequence[float]] = None, + **kwargs: ty.Any, + ) -> None: + super().__init__(**kwargs) + if n_unit_cells is None and gliding_plane is None and vacuum_spacings is None: + return + + resolved_n_unit_cells = 4 if n_unit_cells is None else int(n_unit_cells) + resolved_gliding_plane = '' if gliding_plane is None else str(gliding_plane) + resolved_vacuum_spacings = [float(value) for value in (vacuum_spacings or [1.0])] + + self._set_attribute(self.N_UNIT_CELLS_KEY, resolved_n_unit_cells) + self._set_attribute(self.GLIDING_PLANE_KEY, resolved_gliding_plane) + self._set_attribute(self.VACUUM_SPACINGS_KEY, resolved_vacuum_spacings) + + def _set_attribute(self, key: str, value: ty.Any) -> None: + """Set an attribute before storing the node.""" + if self.is_stored: + raise ModificationNotAllowed('`CleavagedStructureData` attributes cannot be modified after storing.') + self.base.attributes.set(key, value) + + @property + def n_unit_cells(self) -> int: + """Return the configured number of repeated unit cells.""" + return int(self.base.attributes.get(self.N_UNIT_CELLS_KEY)) + + @property + def gliding_plane(self) -> str: + """Return the configured gliding plane.""" + return str(self.base.attributes.get(self.GLIDING_PLANE_KEY, '')) + + @property + def vacuum_spacings(self) -> list[float]: + """Return the configured vacuum spacings.""" + return [float(value) for value in self.base.attributes.get(self.VACUUM_SPACINGS_KEY, [1.0])] + + def get_structure_builder(self, structure: 'orm.StructureData | Atoms') -> CleavagedStructure: + """Return a helper bound to a specific structure.""" + from aiida import orm + + ase_atoms = structure.get_ase() if isinstance(structure, orm.StructureData) else structure + effective_gliding_plane = self.gliding_plane or None + return CleavagedStructure( + ase_atoms=ase_atoms, + n_unit_cells=self.n_unit_cells, + gliding_plane=effective_gliding_plane, + ) diff --git a/src/aiida_mechanical/data/faulted_structure.py b/src/aiida_mechanical/data/faulted_structure.py new file mode 100644 index 0000000..0b588ff --- /dev/null +++ b/src/aiida_mechanical/data/faulted_structure.py @@ -0,0 +1,302 @@ +from __future__ import annotations + +import typing as ty +from copy import deepcopy + +import numpy +from aiida.common.exceptions import ModificationNotAllowed +from aiida.orm import Data +from ase import Atoms + +from aiida_mechanical.tools.structure_utils import group_by_layers +from aiida_mechanical.data.cleavaged_structure import PlanarStructure +from aiida_mechanical.data.gliding_systems import ( + FaultConfig, +) +from aiida_mechanical.tools.structure_builder import ( + build_atoms_from_stacking_removal, + build_atoms_from_stacking_mirror, + build_atoms_from_burger_vector_with_vacuum, + build_atoms_from_burger_vector_general, + build_atoms_from_burger_vector, + update_faults +) + + +class GeneralFaultStructurePoint(ty.TypedDict): + """Flattened generalized stacking fault point used for calcfunction normalization.""" + + label: str + structure: Atoms + burger_vector: list[float] + total_cell_shift: list[float] + interface_slips: dict[int, list[float]] + direction_name: str + step_index: int + + +class GeneralFaultStructureMetadata(ty.TypedDict): + """Metadata snapshot for a generalized stacking fault state.""" + + label: str + direction_name: str + step_index: int + burger_vector: list[float] + total_cell_shift: list[float] + interface_slips: dict[int, list[float]] + + +class GeneralFaultStructureEntry(ty.TypedDict): + """Generalized stacking fault structure and its metadata snapshot.""" + + structure: Atoms + metadata: GeneralFaultStructureMetadata + + +GeneralFaultStructureResult = dict[str, dict[int, GeneralFaultStructureEntry]] +FaultedStructureResult = ty.Union[Atoms, list[dict[str, ty.Any]], GeneralFaultStructureResult] + + +class FaultedStructure(PlanarStructure): + """ + A class to handle dislocation structures and their manipulations using ASE Atoms. + """ + + @staticmethod + def _serialize_vector(vector: ty.Union[numpy.ndarray, list[float]]) -> list[float]: + """Return a JSON-serializable vector of floats.""" + if isinstance(vector, numpy.ndarray): + return [float(value) for value in vector.tolist()] + return [float(value) for value in vector] + + def _build_general_fault_entry( + self, + direction_name: str, + step_index: int, + structure: Atoms, + total_cell_shift: numpy.ndarray, + interface_slips: dict[int, numpy.ndarray], + ) -> GeneralFaultStructureEntry: + """Build a generalized fault entry with a frozen metadata snapshot.""" + total_cell_shift_serialized = self._serialize_vector(total_cell_shift) + interface_slips_snapshot = { + int(interface): self._serialize_vector(deepcopy(interface_shift)) + for interface, interface_shift in interface_slips.items() + } + metadata: GeneralFaultStructureMetadata = { + 'label': f'sfe_{direction_name}_{step_index:03d}', + 'direction_name': direction_name, + 'step_index': step_index, + 'burger_vector': total_cell_shift_serialized, + 'total_cell_shift': total_cell_shift_serialized, + 'interface_slips': interface_slips_snapshot, + } + return { + 'structure': structure, + 'metadata': metadata, + } + + def get_faulted_structure(self, + fault_mode: str, + fault_type: str, + additional_spacing: float = 0.0, + vacuum_ratio: float = 0.0, + print_info: bool = False, + **kwargs) -> ty.Optional[FaultedStructureResult]: + """ + Generate faulted structure. + Returns faulted structures for the requested mode. + """ + if fault_mode not in ['removal', 'vacuum', 'general']: + raise ValueError(f"fault_mode must be one of 'removal', 'vacuum', 'general', got '{fault_mode}'") + + if fault_mode == 'removal' and fault_type not in ['intrinsic', 'unstable', 'extrinsic']: + raise ValueError(f"fault_type must be one of 'intrinsic', 'unstable', or 'extrinsic', got '{fault_type}'") + + if print_info: + print(f'Strukturbericht {self.strukturbericht} detected') + + conventional_structure = self.get_conventional_structure() + + plane_config = self._prepare_plane_data() + + layers_dict = group_by_layers(conventional_structure) + + if len(layers_dict) != plane_config.n_layers: + raise ValueError( + f'Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers}.' + ) + + fault_config = getattr(plane_config, fault_type) + if not fault_config.possible: + return None + + faulted_result = None + + # Removal Mode + if fault_mode == 'removal' and fault_config.removal_layers is not None: + structure = build_atoms_from_stacking_removal( + conventional_structure, + self.n_unit_cells, + fault_config.removal_layers, + layers_dict, + additional_spacing=(fault_config.interface, additional_spacing), + print_info=print_info + ) + faulted_result = structure + + # Vacuum Mode + if fault_mode == 'vacuum' and vacuum_ratio > 0.0 and fault_config.burger_vectors is not None: + structures_list = [] + for burger_vector in fault_config.burger_vectors: + structure = build_atoms_from_burger_vector_with_vacuum( + conventional_structure, + self.n_unit_cells, + burger_vector, + layers_dict, + vacuum_ratio=vacuum_ratio, + print_info=print_info + ) + structures_list.append({ + 'structure': structure, + 'burger_vector': burger_vector, + }) + faulted_result = structures_list + + # General Mode + if fault_mode == 'general' and fault_config.burger_vectors is not None: + structures_by_direction: GeneralFaultStructureResult = {} + nsteps = kwargs.get('nsteps', fault_config.nsteps) + stacking_order = ''.join(layers_dict.keys()) + + zs = [(value['z'] + layer) / self.n_unit_cells for layer in range(self.n_unit_cells) for value in layers_dict.values()] + stacking_order_supercell = stacking_order * self.n_unit_cells + + new_cell = conventional_structure.cell.array.copy() + new_cell[-1] *= self.n_unit_cells + + if isinstance(fault_config.burger_vectors, dict): + for direction_name, segment in fault_config.burger_vectors.items(): + structures_by_direction[direction_name] = {} + burgers_vector_for_cell = numpy.zeros(3) + faults = numpy.zeros((len(stacking_order_supercell), 3)) + interface_slips: dict[int, numpy.ndarray] = {} + step_index = 0 + + # Initial state (0 displacement) + structure = build_atoms_from_burger_vector_general( + new_cell, deepcopy(zs), layers_dict, stacking_order_supercell, + burgers_vector_for_cell, faults, print_info=print_info + ) + structures_by_direction[direction_name][step_index] = self._build_general_fault_entry( + direction_name=direction_name, + step_index=step_index, + structure=structure, + total_cell_shift=burgers_vector_for_cell, + interface_slips=interface_slips, + ) + + for interface, burgers_vector in segment: + burgers_vector_step = numpy.array(burgers_vector) / nsteps + for _ in range(1, 1+nsteps): + step_index += 1 + current_interface_shift = interface_slips.get(interface, numpy.zeros(3)) + interface_slips[interface] = current_interface_shift + burgers_vector_step + faults = update_faults(faults, interface, burgers_vector_step) + burgers_vector_for_cell += burgers_vector_step + structure = build_atoms_from_burger_vector_general( + new_cell, deepcopy(zs), layers_dict, stacking_order_supercell, + burgers_vector_for_cell, faults, print_info=print_info + ) + structures_by_direction[direction_name][step_index] = self._build_general_fault_entry( + direction_name=direction_name, + step_index=step_index, + structure=structure, + total_cell_shift=burgers_vector_for_cell, + interface_slips=interface_slips, + ) + + faulted_result = structures_by_direction + + return faulted_result + + def _build_faulted_structure_helper( + self, + config: FaultConfig, + ase_atoms_t: Atoms, + layers_dict: dict[str, dict[str, ty.Any]], + print_info: bool = False, + ) -> ty.Optional[FaultedStructureResult]: + """Internal helper for unstable/intrinsic fault building.""" + if not config.possible: + return None + + if config.removal_layers is not None: + structure = build_atoms_from_stacking_removal( + ase_atoms_t, self.n_unit_cells, config.removal_layers, layers_dict, + additional_spacing=(config.interface, 0.0), print_info=print_info + ) + return structure + + if config.burger_vectors is not None and isinstance(config.burger_vectors, list): + structures_list = [] + for bv in config.burger_vectors: + structure = build_atoms_from_burger_vector( + ase_atoms_t, self.n_unit_cells, bv, layers_dict, print_info=print_info + ) + structures_list.append({ + 'structure': structure, + 'burger_vector': bv, + }) + return structures_list + return None + +class FaultedStructureData(Data): + """Pure configuration node for faulted-structure generation.""" + + N_UNIT_CELLS_KEY = 'n_unit_cells' + GLIDING_PLANE_KEY = 'gliding_plane' + + def __init__( + self, + n_unit_cells: ty.Optional[int] = None, + gliding_plane: ty.Optional[str] = None, + **kwargs: ty.Any, + ) -> None: + super().__init__(**kwargs) + if n_unit_cells is None and gliding_plane is None: + return + + resolved_n_unit_cells = 4 if n_unit_cells is None else int(n_unit_cells) + resolved_gliding_plane = '' if gliding_plane is None else str(gliding_plane) + + self._set_attribute(self.N_UNIT_CELLS_KEY, resolved_n_unit_cells) + self._set_attribute(self.GLIDING_PLANE_KEY, resolved_gliding_plane) + + def _set_attribute(self, key: str, value: ty.Any) -> None: + """Set an attribute before storing the node.""" + if self.is_stored: + raise ModificationNotAllowed('`FaultedStructureData` attributes cannot be modified after storing.') + self.base.attributes.set(key, value) + + @property + def n_unit_cells(self) -> int: + """Return the configured number of repeated unit cells.""" + return int(self.base.attributes.get(self.N_UNIT_CELLS_KEY)) + + @property + def gliding_plane(self) -> str: + """Return the configured gliding plane.""" + return str(self.base.attributes.get(self.GLIDING_PLANE_KEY, '')) + + def get_structure_builder(self, structure: 'orm.StructureData | Atoms') -> FaultedStructure: + """Return a helper bound to a specific structure.""" + from aiida import orm + + ase_atoms = structure.get_ase() if isinstance(structure, orm.StructureData) else structure + effective_gliding_plane = self.gliding_plane or None + return FaultedStructure( + ase_atoms=ase_atoms, + n_unit_cells=self.n_unit_cells, + gliding_plane=effective_gliding_plane, + ) diff --git a/src/aiida_mechanical/data/gliding_systems.py b/src/aiida_mechanical/data/gliding_systems.py new file mode 100644 index 0000000..7bce948 --- /dev/null +++ b/src/aiida_mechanical/data/gliding_systems.py @@ -0,0 +1,510 @@ +import typing as ty +from dataclasses import dataclass, field +from abc import ABC, abstractmethod + +GeneralFaultStep = tuple[int, list[float]] +GeneralFaultPath = tuple[GeneralFaultStep, ...] +GeneralFaultDirections = dict[str, GeneralFaultPath] +BurgerVectorConfig = ty.Union[list[list[float]], GeneralFaultDirections] + + +@dataclass +class FaultConfig: + """Configuration for a fault type (intrinsic, unstable, or extrinsic).""" + removal_layers: ty.Union[list[int], int] = None + burger_vectors: ty.Optional[BurgerVectorConfig] = None + periodicity: bool = False + possible: bool = True + interface: int = 0 + nsteps: int = 1 + +@dataclass +class GlidingPlaneConfig: + """Configuration for a specific gliding plane.""" + transformation_matrix: list[list[int]] + transformation_matrix_c: ty.Optional[list[list[int]]] = None + target_unit_vectors: ty.Optional[list[list[float]]] = None + n_layers: int = 2 + intrinsic: FaultConfig = field(default_factory=FaultConfig) + unstable: FaultConfig = field(default_factory=FaultConfig) + extrinsic: FaultConfig = field(default_factory=FaultConfig) + general: FaultConfig = field(default_factory=FaultConfig) + +class GlidingSystem(ABC): + """Base class for gliding system configurations.""" + + default_plane: str = '111' # Default gliding plane, can be overridden by subclasses + + def __init__(self, strukturbericht: str): + self.strukturbericht = strukturbericht + self._planes: dict[str, GlidingPlaneConfig] = {} + self._register_planes() + + @abstractmethod + def _register_planes(self): + """Register all gliding planes for this system.""" + pass + + def get_plane(self, gliding_plane: str) -> GlidingPlaneConfig: + """Get configuration for a specific gliding plane.""" + if gliding_plane not in self._planes: + raise ValueError( + f'Gliding plane {gliding_plane} is not supported for {self.strukturbericht}. ' + f'Supported planes: {list(self._planes.keys())}' + ) + return self._planes[gliding_plane] + + def list_planes(self) -> list[str]: + """List all supported gliding planes.""" + return list(self._planes.keys()) + +# Concrete implementations +class A1GlidingSystem(GlidingSystem): + """A1 (FCC) gliding system.""" + + def _register_planes(self): + self._planes['100'] = GlidingPlaneConfig( + transformation_matrix=[[1, 0, 0], [0, -1, 1], [-1, 1, 1]], + n_layers=2, + intrinsic=FaultConfig(possible=False), + extrinsic=FaultConfig(possible=False), + general=FaultConfig( + possible=True, + burger_vectors={ + '100': ( + (2, [1, 0, 0]), + ) + }, + # interface=(2, ), + nsteps = 10 + ) + ) + self._planes['011'] = GlidingPlaneConfig( + transformation_matrix=[[0, 1, -1], [-1, 1, 1], [1, 0, 0]], + transformation_matrix_c=[[0, 1, -1], [-1, 1, 1], [1, 0, 0]], + n_layers=2, + intrinsic=FaultConfig(possible=False), + extrinsic=FaultConfig(possible=False), + general=FaultConfig( + possible=True, + burger_vectors={ + '010': ( + (2, [0, 1, 0]), + ) + }, + # interface=(2, ), + nsteps = 10 + ) + ) + self._planes['111'] = GlidingPlaneConfig( + transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], + transformation_matrix_c=[[1, -1, 0], [1, 1, -2], [1, 1, 1]], + target_unit_vectors = [[1, 1, 0], [-1, 1, 0], [0, 0, 1]], + n_layers=3, + intrinsic=FaultConfig( + possible=True, + removal_layers=[3], + burger_vectors=[[1/3, 1/3, 0]], + periodicity=False, + interface=3, + ), + extrinsic=FaultConfig( + possible=True, + removal_layers=[3, 5], + burger_vectors=[[2/3, 2/3, 0]], + periodicity=False + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '110': ( + (3, [1/3, 1/3, 0]), + (4, [1/3, 1/3, 0]), + ), + }, + interface=(3, 4), + nsteps = 8 + ) + ) + +class A2GlidingSystem(GlidingSystem): + """A2 (BCC) gliding system.""" + + def _register_planes(self): + self._planes['100'] = GlidingPlaneConfig( + transformation_matrix=[[1, 1, 0], [1, 0, 1], [0, 1, 1]], + n_layers=2, + intrinsic=FaultConfig( + removal_layers=[2] + ), + unstable=FaultConfig( + removal_layers=[2] + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '100': ( + (2, [1, 0, 0]), + ) + }, + # interface=[2, ], + nsteps = 10 + ) + ) + self._planes['011'] = GlidingPlaneConfig( + transformation_matrix=[[0, 1, 0], [0, 0, 1], [2, 1, 1]], + transformation_matrix_c=[[0, 1, -1], [0, 1, 1], [2, 1, 1]], + n_layers=2, + intrinsic=FaultConfig( + removal_layers=[2] + ), + unstable=FaultConfig( + removal_layers=[2] + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '100': ( + (2, [1, 0, 0]), + ) + }, + # interface=[2,], + nsteps = 10 + ) + ) + self._planes['111'] = GlidingPlaneConfig( + transformation_matrix=[[-1, 1, 0], [-1, 0, 1], [1, 1, 1]], + transformation_matrix_c=[[-2, 1, 1], [0, -1, 1], [2, 2, 2]], + n_layers=3, + intrinsic=FaultConfig( + removal_layers=[3], interface=3, + burger_vectors=[[1/3, 1/3, 0]], + periodicity=False, + ), + extrinsic=FaultConfig( + removal_layers=[3, 5], + burger_vectors=[[2/3, 2/3, 0]], + periodicity=False, + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '110': ( + (3, [1/3, 1/3, 0]), + (4, [1/3, 1/3, 0]), + ) + }, + # interface=(3, 4), + nsteps = 8 + ) + ) + +class B1GlidingSystem(GlidingSystem): + """B1 (NaCl) gliding system.""" + + def _register_planes(self): + self._planes['100'] = GlidingPlaneConfig( + transformation_matrix=[[1, 0, 0], [0, -1, 1], [-1, 1, 1]], + n_layers=2, + general=FaultConfig( + possible=True, + burger_vectors={ + '100': ( + (2, [1, 0, 0]), + ) + }, + # interface=[2, ], + nsteps = 10 + ) + ) + self._planes['011'] = GlidingPlaneConfig( + transformation_matrix=[[0, 1, -1], [-1, 1, 1], [1, 0, 0]], + n_layers=2, + general=FaultConfig( + possible=True, + burger_vectors={ + '100': ( + (2, [1, 0, 0]), + ), + '010': ( + (2, [0, 1, 0]), + ), + }, + interface=[2, ], + nsteps = 8 + ) + ) + self._planes['111'] = GlidingPlaneConfig( + transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], + transformation_matrix_c=[[1, -1, 0], [1, 1, -2], [1, 1, 1]], + n_layers=6, + intrinsic=FaultConfig( + removal_layers=[6, 7, 8, 9], interface=6, + burger_vectors=[[1/3, 1/3, 0]], + periodicity=False, + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '110': ( + (6, [1/3, 1/3, 0]), + (7, [1/3, 1/3, 0]), + ) + }, + # interface=(6, ), + nsteps = 10 + ) + ) + +class B2GlidingSystem(GlidingSystem): + """B2 (CsCl) gliding system.""" + + def _register_planes(self): + self._planes['100'] = GlidingPlaneConfig( + transformation_matrix=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], + n_layers=2, + general=FaultConfig( + possible=True, + burger_vectors={ + '100': ( + (2, [1, 0, 0]), + ) + }, + # interface=[2, ], + nsteps = 10 + ) + ) + self._planes['011'] = GlidingPlaneConfig( + transformation_matrix=[[0, 1, -1], [1, 0, 0], [0, 1, 1]], + n_layers=2, + general=FaultConfig( + possible=True, + burger_vectors={ + '100': ( + (2, [1, 0, 0]), + ), + '010': ( + (2, [0, 1, 0]), + ), + '110': ( + (2, [1, 1, 0]), + ), + }, + # interface=[2, ], + nsteps = 6 + ) + ) + self._planes['111'] = GlidingPlaneConfig( + transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], + transformation_matrix_c=[[1, -1, 0], [1, 1, -2], [1, 1, 1]], + n_layers=6, + intrinsic=FaultConfig( + removal_layers=[6, 7, 8, 9], interface=6, + burger_vectors=[[1/3, 1/3, 0]], + periodicity=False, + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '110': ( + (6, [1/3, 1/3, 0]), + ) + }, + # interface=(6, ), + nsteps = 10 + ) + ) + +class C1bGlidingSystem(GlidingSystem): + """C1b (Half-Heusler) gliding system.""" + + def _register_planes(self): + self._planes['100'] = GlidingPlaneConfig( + transformation_matrix=[[0, -1, 1], [1, 0, 0], [-1, 1, 1]], + target_unit_vectors=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], + n_layers=4, + intrinsic=FaultConfig( + removal_layers=[2], + burger_vectors=[[1/2, 0, 0], [0, 1/2, 0], [1/2, 1/2, 0]], + periodicity=False, + interface=2, + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '110': ( + (4, [1, 1, 0]), + ) + }, + # interface=(4, 4), + nsteps = 8 + ) + ) + self._planes['011'] = GlidingPlaneConfig( + transformation_matrix=[[0, 1, -1], [-1, 1, 1], [1, 0, 0]], + target_unit_vectors=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], + n_layers=2, + intrinsic=FaultConfig( + removal_layers=[2], + burger_vectors=[[1/2, 0, 0], [0, 1/2, 0], [1/2, 1/2, 0]], + periodicity=False, + interface=2, + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '100': ( + (2, [1, 0, 0]), + ), + '010': ( + (2, [0, 1, 0]), + ), + '210': ( + (2, [2, 1, 0]), + ) + }, + # interface=(2, 2), + nsteps = 12 + ) + ) + self._planes['111'] = GlidingPlaneConfig( + transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], + transformation_matrix_c=[[1, -1, 0], [1, 1, -2], [1, 1, 1]], + n_layers=9, + intrinsic=FaultConfig( + removal_layers=[9, 10, 11, 12, 13, 14], + burger_vectors=[[1/3, 1/3, 0]], + periodicity=False, + interface=9, + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '110': ( + (9, [1/3, 1/3, 0]), + ) + }, + # interface=9, + nsteps = 10 + ) + ) + +class L21GlidingSystem(GlidingSystem): + """L21 (Heusler) gliding system.""" + + def _register_planes(self): + self._planes['100'] = GlidingPlaneConfig( + transformation_matrix=[[0, -1, 1], [1, 0, 0], [-1, 1, 1]], + target_unit_vectors=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], + n_layers=4, + intrinsic=FaultConfig( + removal_layers=[2], + burger_vectors=[[1/2, 0, 0], [0, 1/2, 0], [1/2, 1/2, 0]], + periodicity=False, + interface=2, + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '110': ( + (4, [1, 1, 0]), + ) + }, + # interface=(4,), + nsteps = 8 + ) + ) + self._planes['011'] = GlidingPlaneConfig( + transformation_matrix=[[0, 1, -1], [-1, 1, 1], [1, 0, 0]], + target_unit_vectors=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], + n_layers=2, + intrinsic=FaultConfig( + removal_layers=[2], + burger_vectors=[[1/2, 0, 0], [0, 1/2, 0], [1/2, 1/2, 0]], + periodicity=False, + interface=2, + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '100': ( + (2, [1, 0, 0]), + ), + '010': ( + (2, [0, 1, 0]), + ), + '210': ( + (2, [2, 1, 0]), + ) + }, + # interface=(2, ), + nsteps = 12 + ) + ) + self._planes['111'] = GlidingPlaneConfig( + transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], + transformation_matrix_c=[[1, -1, 0], [1, 1, -2], [1, 1, 1]], + n_layers=12, + intrinsic=FaultConfig( + removal_layers=[9, 10, 11, 12, 13, 14], + burger_vectors=[[1/3, 1/3, 0]], + periodicity=False, + interface=9, + ), + general=FaultConfig( + possible=True, + burger_vectors={ + '110': ( + (9, [1/3, 1/3, 0]), + ) + }, + # interface=9, + nsteps = 10 + ) + ) + +class E21GlidingSystem(GlidingSystem): + """E21 (Perovskite) gliding system.""" + + def _register_planes(self): + self._planes['011'] = GlidingPlaneConfig( + transformation_matrix=[[0, 0, 1], [-1, 1, 0], [1, 1, 0]], + n_layers=4, + intrinsic=FaultConfig(removal_layers=[4, 5]), + extrinsic=FaultConfig(possible=False) + ) + self._planes['111'] = GlidingPlaneConfig( + transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], + n_layers=6, + intrinsic=FaultConfig(removal_layers=[6, 7, 8, 9]), + unstable=FaultConfig(removal_layers=[6, 7]) + ) + +# Registry for gliding systems +_GLIDING_SYSTEM_REGISTRY: dict[str, type[GlidingSystem]] = { + 'A1': A1GlidingSystem, + 'A2': A2GlidingSystem, + 'B1': B1GlidingSystem, + 'B2': B2GlidingSystem, + 'C1_b': C1bGlidingSystem, + 'L2_1': L21GlidingSystem, + 'E_21': E21GlidingSystem, +} + +# Cache for instantiated systems +_GLIDING_SYSTEM_CACHE: dict[str, GlidingSystem] = {} + + +def get_gliding_system(strukturbericht: str) -> GlidingSystem: + """Get or create a gliding system instance.""" + if strukturbericht not in _GLIDING_SYSTEM_REGISTRY: + raise ValueError( + f'Strukturbericht {strukturbericht} is not supported. ' + f'Supported types: {list(_GLIDING_SYSTEM_REGISTRY.keys())}' + ) + + if strukturbericht not in _GLIDING_SYSTEM_CACHE: + system_class = _GLIDING_SYSTEM_REGISTRY[strukturbericht] + _GLIDING_SYSTEM_CACHE[strukturbericht] = system_class(strukturbericht) + + return _GLIDING_SYSTEM_CACHE[strukturbericht] diff --git a/src/aiida_mechanical/data/structures/cif/Al.cif b/src/aiida_mechanical/data/structures/cif/Al.cif new file mode 100644 index 0000000..d12e06a --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/Al.cif @@ -0,0 +1,33 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Al1 0.0 0.0 0.0 Al +_cell_angle_alpha 59.99999999999999 +_cell_angle_beta 59.99999999999999 +_cell_angle_gamma 59.99999999999999 +_cell_length_a 2.8385976702960884 +_cell_length_b 2.8385976702960884 +_cell_length_c 2.8385976702960884 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/AsTe.cif b/src/aiida_mechanical/data/structures/cif/AsTe.cif new file mode 100644 index 0000000..e2be4ff --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/AsTe.cif @@ -0,0 +1,34 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Te1 0.5000000000000001 0.5 0.49999999999999994 Te + As1 0.0 0.0 0.0 As +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 4.131955011697864 +_cell_length_b 4.131955011697864 +_cell_length_c 4.131955011697864 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/CrH.cif b/src/aiida_mechanical/data/structures/cif/CrH.cif new file mode 100644 index 0000000..860a7d5 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/CrH.cif @@ -0,0 +1,34 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Cr1 0.0 0.0 0.0 Cr + H1 0.5 0.5 0.5 H +_cell_angle_alpha 59.99999999999999 +_cell_angle_beta 59.99999999999999 +_cell_angle_gamma 59.99999999999999 +_cell_length_a 2.642815287539562 +_cell_length_b 2.642815287539562 +_cell_length_c 2.642815287539562 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/Cu.cif b/src/aiida_mechanical/data/structures/cif/Cu.cif new file mode 100644 index 0000000..32160a3 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/Cu.cif @@ -0,0 +1,33 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Cu1 0.0 0.0 0.0 Cu +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 2.5189362786337055 +_cell_length_b 2.5189362786337055 +_cell_length_c 2.5189362786337055 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/HfAlPd2.cif b/src/aiida_mechanical/data/structures/cif/HfAlPd2.cif new file mode 100644 index 0000000..ec3bcfa --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/HfAlPd2.cif @@ -0,0 +1,36 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Hf1 0.5 0.49999999999999994 0.5 Hf + Al1 0.0 0.0 0.0 Al + Pd1 0.750000000000008 0.750000000000008 0.7500000000000078 Pd + Pd2 0.250000000000008 0.25000000000000805 0.25000000000000766 Pd +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 4.490114799344118 +_cell_length_b 4.490114799344118 +_cell_length_c 4.490114799344118 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/IrS2.cif b/src/aiida_mechanical/data/structures/cif/IrS2.cif new file mode 100644 index 0000000..5690177 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/IrS2.cif @@ -0,0 +1,44 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Ir1 0.5000000000000088 0.5000000000000088 0.0 Ir + Ir2 0.5000000000000088 0.0 0.5000000000000088 Ir + Ir3 0.0 0.5000000000000088 0.5000000000000088 Ir + Ir4 0.0 0.0 0.0 Ir + S1 0.13175090908773657 0.8682490909122793 0.3682490909122705 S + S2 0.8682490909122793 0.3682490909122705 0.13175090908773657 S + S3 0.3682490909122705 0.13175090908773657 0.8682490909122793 S + S4 0.6317509090877471 0.6317509090877471 0.6317509090877471 S + S5 0.8682490909122793 0.13175090908773657 0.6317509090877471 S + S6 0.13175090908773657 0.6317509090877471 0.8682490909122793 S + S7 0.6317509090877471 0.8682490909122793 0.13175090908773657 S + S8 0.3682490909122705 0.3682490909122705 0.3682490909122705 S +_cell_angle_alpha 90.0 +_cell_angle_beta 90.0 +_cell_angle_gamma 90.0 +_cell_length_a 5.6698286942175 +_cell_length_b 5.6698286942175 +_cell_length_c 5.6698286942175 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/KCl.cif b/src/aiida_mechanical/data/structures/cif/KCl.cif new file mode 100644 index 0000000..1e84d84 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/KCl.cif @@ -0,0 +1,34 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + K1 0.0 0.0 0.0 K + Cl1 0.5 0.5 0.5 Cl +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 4.412791394710306 +_cell_length_b 4.412791394710306 +_cell_length_c 4.412791394710306 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/MgB2.cif b/src/aiida_mechanical/data/structures/cif/MgB2.cif new file mode 100644 index 0000000..8b2ac80 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/MgB2.cif @@ -0,0 +1,35 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Mg1 0.0 0.0 0.0 Mg + B1 0.33333333332076615 0.6666666666415323 0.4999999999999857 B + B2 0.6666666666792262 0.33333333335845255 0.4999999999999857 B +_cell_angle_alpha 90.0 +_cell_angle_beta 90.0 +_cell_angle_gamma 119.99999999907921 +_cell_length_a 3.0642754440062 +_cell_length_b 3.0642754440914954 +_cell_length_c 3.4935180501301 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/MoN.cif b/src/aiida_mechanical/data/structures/cif/MoN.cif new file mode 100644 index 0000000..5d9d4e0 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/MoN.cif @@ -0,0 +1,34 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Mo1 0.0 0.0 0.0 Mo + N1 0.33333333331979764 0.6666666666396187 0.5 N +_cell_angle_alpha 90.0 +_cell_angle_beta 90.0 +_cell_angle_gamma 120.00000000030694 +_cell_length_a 2.8460571143601 +_cell_length_b 2.846057114333592 +_cell_length_c 2.819984637274 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/NaCl.cif b/src/aiida_mechanical/data/structures/cif/NaCl.cif new file mode 100644 index 0000000..2e52cf6 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/NaCl.cif @@ -0,0 +1,34 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Na1 0.0 2.409855782874135e-32 0.0 Na + Cl1 0.5 0.5 0.5 Cl +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 3.9693984125113606 +_cell_length_b 3.9693984125113606 +_cell_length_c 3.9693984125113606 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/Nb3Sn.cif b/src/aiida_mechanical/data/structures/cif/Nb3Sn.cif new file mode 100644 index 0000000..64aea1c --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/Nb3Sn.cif @@ -0,0 +1,40 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Nb1 0.25 0.0 0.5 Nb + Nb2 0.75 0.0 0.5 Nb + Nb3 0.5 0.25 0.0 Nb + Nb4 0.5 0.75 0.0 Nb + Nb5 0.0 0.5 0.75 Nb + Nb6 0.0 0.5 0.25 Nb + Sn1 0.5 0.5 0.5 Sn + Sn2 0.0 0.0 0.0 Sn +_cell_angle_alpha 90.0 +_cell_angle_beta 90.0 +_cell_angle_gamma 90.0 +_cell_length_a 5.2577378134188 +_cell_length_b 5.2577378134188 +_cell_length_c 5.2577378134188 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/NbCoSb.cif b/src/aiida_mechanical/data/structures/cif/NbCoSb.cif new file mode 100644 index 0000000..2363c8e --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/NbCoSb.cif @@ -0,0 +1,35 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Nb1 0.0 0.0 0.0 Nb + Co1 0.7500000000000084 0.7500000000000084 0.7500000000000085 Co + Sb1 0.5 0.5 0.5 Sb +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 4.170443155645985 +_cell_length_b 4.170443155645985 +_cell_length_c 4.170443155645985 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/NiTi.cif b/src/aiida_mechanical/data/structures/cif/NiTi.cif new file mode 100644 index 0000000..52e0d6f --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/NiTi.cif @@ -0,0 +1,34 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Ti1 0.0 0.0 0.0 Ti + Ni1 0.5000000000168391 0.5000000000168391 0.5000000000168391 Ni +_cell_angle_alpha 90.0 +_cell_angle_beta 90.0 +_cell_angle_gamma 90.0 +_cell_length_a 2.9692803227648 +_cell_length_b 2.9692803227648 +_cell_length_c 2.9692803227648 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/TaRu3C.cif b/src/aiida_mechanical/data/structures/cif/TaRu3C.cif new file mode 100644 index 0000000..e679a52 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/TaRu3C.cif @@ -0,0 +1,37 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Ta1 0.0 0.0 0.0 Ta + Ru1 0.5 0.5 0.0 Ru + Ru2 0.5 0.0 0.5 Ru + Ru3 0.0 0.5 0.5 Ru + C1 0.5 0.5 0.5 C +_cell_angle_alpha 90.0 +_cell_angle_beta 90.0 +_cell_angle_gamma 90.0 +_cell_length_a 4.0044841003254 +_cell_length_b 4.0044841003254 +_cell_length_c 4.0044841003254 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/TaSe2.cif b/src/aiida_mechanical/data/structures/cif/TaSe2.cif new file mode 100644 index 0000000..55ed914 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/TaSe2.cif @@ -0,0 +1,38 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Ta1 0.0 0.0 0.2499999999960209 Ta + Ta2 0.0 0.0 0.749999999996037 Ta + Se1 0.3333333333318227 0.6666666666441146 0.38335906200380854 Se + Se2 0.3333333333318227 0.6666666666441146 0.1166409379962075 Se + Se3 0.6666666666974752 0.3333333333558889 0.8833590620038644 Se + Se4 0.6666666666974752 0.3333333333558889 0.6166409379962235 Se +_cell_angle_alpha 90.0 +_cell_angle_beta 90.0 +_cell_angle_gamma 120.0000000003692 +_cell_length_a 3.4134212459802 +_cell_length_b 3.413421246042106 +_cell_length_c 12.527874266241 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/TiAlPt.cif b/src/aiida_mechanical/data/structures/cif/TiAlPt.cif new file mode 100644 index 0000000..6b610bf --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/TiAlPt.cif @@ -0,0 +1,38 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Ti1 0.0 0.0 0.5000000000093044 Ti + Ti2 1.2446601325452118e-26 8.297734217047494e-27 0.0 Ti + Al1 0.3333333333345104 0.666666666684186 0.2500000000046615 Al + Al2 0.6666666666427432 0.333333333315815 0.7499999999953386 Al + Pt1 0.3333333333345104 0.666666666684186 0.7499999999953386 Pt + Pt2 0.6666666666427432 0.333333333315815 0.2500000000046615 Pt +_cell_angle_alpha 90.0 +_cell_angle_beta 90.0 +_cell_angle_gamma 119.99999999966057 +_cell_length_a 4.3963017414878 +_cell_length_b 4.39630174143292 +_cell_length_c 5.3737917583094 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/TiSbRu.cif b/src/aiida_mechanical/data/structures/cif/TiSbRu.cif new file mode 100644 index 0000000..ad4ebb9 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/TiSbRu.cif @@ -0,0 +1,35 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Ti1 0.0 0.0 0.0 Ti + Sb1 0.5 0.5 0.5 Sb + Ru1 0.7499999999917712 0.7499999999917712 0.7499999999917715 Ru +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 4.300911623141082 +_cell_length_b 4.300911623141082 +_cell_length_c 4.300911623141082 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/V.cif b/src/aiida_mechanical/data/structures/cif/V.cif new file mode 100644 index 0000000..80b8e68 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/V.cif @@ -0,0 +1,33 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + V1 0.0 0.0 0.0 V +_cell_angle_alpha 109.47122063449069 +_cell_angle_beta 109.47122063449069 +_cell_angle_gamma 109.47122063449069 +_cell_length_a 2.5951340459285310 +_cell_length_b 2.5951340459285310 +_cell_length_c 2.5951340459285310 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' diff --git a/src/aiida_mechanical/data/structures/cif/VC.cif b/src/aiida_mechanical/data/structures/cif/VC.cif new file mode 100644 index 0000000..77606c6 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/VC.cif @@ -0,0 +1,34 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + V1 0.0 0.0 0.0 V + C1 0.5 0.5 0.5 C +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 2.910392637942859 +_cell_length_b 2.910392637942859 +_cell_length_c 2.910392637942859 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/YSnPd2.cif b/src/aiida_mechanical/data/structures/cif/YSnPd2.cif new file mode 100644 index 0000000..56d1c3c --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/YSnPd2.cif @@ -0,0 +1,36 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Y1 0.0 0.0 0.0 Y + Sn1 0.5 0.5 0.5 Sn + Pd1 0.7500000000074836 0.7500000000074836 0.7500000000074835 Pd + Pd2 0.25000000000748335 0.25000000000748346 0.25000000000748357 Pd +_cell_angle_alpha 59.99999999999999 +_cell_angle_beta 59.99999999999999 +_cell_angle_gamma 59.99999999999999 +_cell_length_a 4.729148556420489 +_cell_length_b 4.729148556420489 +_cell_length_c 4.729148556420489 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/ZrAlNi2.cif b/src/aiida_mechanical/data/structures/cif/ZrAlNi2.cif new file mode 100644 index 0000000..7c02fc0 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/ZrAlNi2.cif @@ -0,0 +1,36 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Zr1 0.5 0.5 0.5 Zr + Al1 5.906859747672147e-31 1.893019177889009e-31 0.0 Al + Ni1 0.7499999999999918 0.7499999999999918 0.7499999999999917 Ni + Ni2 0.24999999999999178 0.24999999999999176 0.24999999999999167 Ni +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 4.281306370452744 +_cell_length_b 4.281306370452744 +_cell_length_c 4.281306370452744 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/ZrGaNi2.cif b/src/aiida_mechanical/data/structures/cif/ZrGaNi2.cif new file mode 100644 index 0000000..98dc171 --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/ZrGaNi2.cif @@ -0,0 +1,36 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Zr1 0.0 0.0 0.0 Zr + Ga1 0.5 0.5 0.5 Ga + Ni1 0.7499999999999835 0.7499999999999835 0.7500000000000167 Ni + Ni2 0.24999999999996692 0.25000000000000006 0.24999999999999997 Ni +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 4.265297418243143 +_cell_length_b 4.265297418243143 +_cell_length_c 4.265297418243143 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/cif/ZrRuSb.cif b/src/aiida_mechanical/data/structures/cif/ZrRuSb.cif new file mode 100644 index 0000000..6f9e30e --- /dev/null +++ b/src/aiida_mechanical/data/structures/cif/ZrRuSb.cif @@ -0,0 +1,35 @@ + +########################################################################## +# Crystallographic Information Format file +# Produced by PyCifRW module +# +# This is a CIF file. CIF has been adopted by the International +# Union of Crystallography as the standard for data archiving and +# transmission. +# +# For information on this file format, follow the CIF links at +# http://www.iucr.org +########################################################################## + +data_0 + +loop_ + _atom_site_label + _atom_site_fract_x + _atom_site_fract_y + _atom_site_fract_z + _atom_site_type_symbol + Zr1 0.0 0.0 0.0 Zr + Sb1 0.5 0.5 0.5 Sb + Ru1 0.7499999999920202 0.7499999999920202 0.74999999999202 Ru +_cell_angle_alpha 60.00000000000001 +_cell_angle_beta 60.00000000000001 +_cell_angle_gamma 60.00000000000001 +_cell_length_a 4.430557140958729 +_cell_length_b 4.430557140958729 +_cell_length_c 4.430557140958729 +loop_ + _symmetry_equiv_pos_as_xyz + 'x, y, z' +_symmetry_int_tables_number 1 +_symmetry_space_group_name_H-M 'P 1' \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/xyz/icsd-659260-IrS2-205-C2.xyz b/src/aiida_mechanical/data/structures/xyz/icsd-659260-IrS2-205-C2.xyz new file mode 100644 index 0000000..305dd5f --- /dev/null +++ b/src/aiida_mechanical/data/structures/xyz/icsd-659260-IrS2-205-C2.xyz @@ -0,0 +1,14 @@ +12 +Lattice="5.6698286942175 0.0 0.0 0.0 5.6698286942175 0.0 0.0 0.0 5.6698286942175" pbc="True True True" +Ir 2.8349143471 2.8349143471 0.0000000000 +Ir 2.8349143471 0.0000000000 2.8349143471 +Ir 0.0000000000 2.8349143471 2.8349143471 +Ir 0.0000000000 0.0000000000 0.0000000000 +S 0.7470050848 4.9228236094 2.0879092623 +S 4.9228236094 2.0879092623 0.7470050848 +S 2.0879092623 0.7470050848 4.9228236094 +S 3.5819194319 3.5819194319 3.5819194319 +S 4.9228236094 0.7470050848 3.5819194319 +S 0.7470050848 3.5819194319 4.9228236094 +S 3.5819194319 4.9228236094 0.7470050848 +S 2.0879092623 2.0879092623 2.0879092623 \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/xyz/icsd-77392-TaRu3C-221-E21.xyz b/src/aiida_mechanical/data/structures/xyz/icsd-77392-TaRu3C-221-E21.xyz new file mode 100644 index 0000000..018e3c7 --- /dev/null +++ b/src/aiida_mechanical/data/structures/xyz/icsd-77392-TaRu3C-221-E21.xyz @@ -0,0 +1,7 @@ +5 +Lattice="4.0044841003254 0.0 0.0 0.0 4.0044841003254 0.0 0.0 0.0 4.0044841003254" pbc="True True True" +Ta 0.0000000000 0.0000000000 0.0000000000 +Ru 2.0022420502 2.0022420502 0.0000000000 +Ru 2.0022420502 0.0000000000 2.0022420502 +Ru 0.0000000000 2.0022420502 2.0022420502 +C 2.0022420502 2.0022420502 2.0022420502 \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/xyz/mpds-S1605628-AsTe-225-B1.xyz b/src/aiida_mechanical/data/structures/xyz/mpds-S1605628-AsTe-225-B1.xyz new file mode 100644 index 0000000..cfa81d5 --- /dev/null +++ b/src/aiida_mechanical/data/structures/xyz/mpds-S1605628-AsTe-225-B1.xyz @@ -0,0 +1,4 @@ +2 +Lattice="0.0 2.9217334083293 2.9217334083293 2.9217334083293 0.0 2.9217334083293 2.9217334083293 2.9217334083293 0.0" pbc="True True True" +Te 2.9217334083 0.0000000000 -0.0000000000 +As 0.0000000000 0.0000000000 0.0000000000 \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/xyz/mpds-S1612207-NbCoSb-216-C1b.xyz b/src/aiida_mechanical/data/structures/xyz/mpds-S1612207-NbCoSb-216-C1b.xyz new file mode 100644 index 0000000..3f83c1c --- /dev/null +++ b/src/aiida_mechanical/data/structures/xyz/mpds-S1612207-NbCoSb-216-C1b.xyz @@ -0,0 +1,5 @@ +3 +Lattice="0.0 2.9489486359103 2.9489486359103 2.9489486359103 0.0 2.9489486359103 2.9489486359103 2.9489486359103 0.0" pbc="True True True" +Nb 0.0000000000 0.0000000000 0.0000000000 +Co 1.4744743180 1.4744743180 4.4234229539 +Sb 2.9489486359 0.0000000000 -0.0000000000 \ No newline at end of file diff --git a/src/aiida_mechanical/data/structures/xyz/mpds-S378780-Nb3Sn-223-A15.xyz b/src/aiida_mechanical/data/structures/xyz/mpds-S378780-Nb3Sn-223-A15.xyz new file mode 100644 index 0000000..965f1ac --- /dev/null +++ b/src/aiida_mechanical/data/structures/xyz/mpds-S378780-Nb3Sn-223-A15.xyz @@ -0,0 +1,10 @@ +8 +Lattice="5.2577378134188 0.0 0.0 0.0 5.2577378134188 0.0 0.0 0.0 5.2577378134188" pbc="True True True" +Nb 1.3144344534 0.0000000000 2.6288689067 +Nb 3.9433033601 0.0000000000 2.6288689067 +Nb 2.6288689067 1.3144344534 0.0000000000 +Nb 2.6288689067 3.9433033601 0.0000000000 +Nb 0.0000000000 2.6288689067 3.9433033601 +Nb 0.0000000000 2.6288689067 1.3144344534 +Sn 2.6288689067 2.6288689067 2.6288689067 +Sn 0.0000000000 0.0000000000 0.0000000000 \ No newline at end of file diff --git a/src/aiida_mechanical/data/system.py b/src/aiida_mechanical/data/system.py new file mode 100644 index 0000000..84d6eec --- /dev/null +++ b/src/aiida_mechanical/data/system.py @@ -0,0 +1,118 @@ +_IMPLEMENTED_SLIPPING_SYSTEMS = { + 'A1': { + 'info': 'FCC element crystal . ' + 'Usually, the gliding plane is 111.', + 'possible_gliding_planes': { + '100': {'stacking': 'AB', + 'slipping_direction': '1/2[010]', + 'faulting_possible': True, + }, + '110': {'stacking': 'AB', + 'slipping_direction': '1/2[112]', + 'faulting_possible': True, + }, + '111': {'stacking': 'ABC', + 'slipping_direction': '1/2[110]', + 'faulting_possible': True, + }, + } + }, + 'A2': { + 'info': 'FCC element crystal . ' + 'I don\'t know the usual gliding plane. ', + 'possible_gliding_planes': { + '100': {'stacking': 'AB', + 'slipping_direction': '1/2[110]', + 'faulting_possible': True, + }, + '110': {'stacking': 'AB', + 'slipping_direction': '1/2[001]', + 'faulting_possible': True, + }, + '111': {'stacking': 'ABC', + 'slipping_direction': '1/2[011]', + 'faulting_possible': True, + }, + } + }, + 'A15': { + 'info': 'A3B crystal . ' + 'I don\'t know the usual gliding plane. ', + 'possible_gliding_planes': { + '100': {'stacking': 'AB', + 'slipping_direction': '1/2[110]', + 'faulting_possible': True, + }, + '110': {'stacking': 'AB', + 'slipping_direction': '1/2[001]', + 'faulting_possible': True, + }, + '111': {'stacking': 'ABC', + 'slipping_direction': '1/2[011]', + 'faulting_possible': True, + }, + } + }, + 'B1': { + 'info': 'FCC element crystal . ' + 'I don\'t know the usual gliding plane. ', + 'possible_gliding_planes': { + '100': {'stacking': 'AB', + 'slipping_direction': '1/2[010]', + 'faulting_possible': True, + }, + '110': {'stacking': 'AB', + 'slipping_direction': '1/2[112]', + 'faulting_possible': True, + }, + } + }, + 'B2': { + 'info': 'FCC element crystal . ' + 'I don\'t know the usual gliding plane. ', + 'possible_gliding_planes': { + '100': {'stacking': 'AB', + 'slipping_direction': '1/2[010]', + 'faulting_possible': True, + }, + } + }, + 'C1': { + 'info': 'We are doing pyrite-type structure. . ' + 'I don\'t know the usual gliding plane. ', + 'possible_gliding_planes': { + '100': {'stacking': 'ABCD', + 'slipping_direction': '1/2[100]', + 'faulting_possible': True, + }, + } + }, + 'C1b': { + 'info': 'We are doing half-heusler-type structure. . ' + 'I don\'t know the usual gliding plane. ', + 'possible_gliding_planes': { + '100': {'stacking': 'ABCD', + 'slipping_direction': '1/2[100]', + 'faulting_possible': True, + }, + '110': {'stacking': 'AB', + 'slipping_direction': '1/2[110]', + 'faulting_possible': True, + }, + '111': {'stacking': 'ABC', + 'slipping_direction': '1/2[111]', + 'faulting_possible': True, + }, + } + }, + 'E21': { + 'info': 'We are doing perovskite-type structure. . ' + 'I don\'t know the usual gliding plane. ', + 'possible_gliding_planes': { + '100': {'stacking': 'AB', + 'slipping_direction': '1/2[010]', + 'faulting_possible': True, + }, + } + }, +} From e2d0c85bc313a82f517a77f1f00d5fe9b86f691a Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 16:25:25 +0200 Subject: [PATCH 14/17] feat: port structural utility tools for gliding systems, builders, and cutting --- src/aiida_mechanical/tools/builder.py | 18 + src/aiida_mechanical/tools/calculator.py | 0 src/aiida_mechanical/tools/cut.py | 686 ++++++++++++++++++ src/aiida_mechanical/tools/gliding_systems.py | 22 + src/aiida_mechanical/tools/structure.py | 602 +++++++++++++++ .../tools/structure_builder.py | 360 +++++++++ src/aiida_mechanical/tools/structure_utils.py | 216 ++++++ 7 files changed, 1904 insertions(+) create mode 100644 src/aiida_mechanical/tools/builder.py create mode 100644 src/aiida_mechanical/tools/calculator.py create mode 100644 src/aiida_mechanical/tools/cut.py create mode 100644 src/aiida_mechanical/tools/gliding_systems.py create mode 100644 src/aiida_mechanical/tools/structure.py create mode 100644 src/aiida_mechanical/tools/structure_builder.py create mode 100644 src/aiida_mechanical/tools/structure_utils.py diff --git a/src/aiida_mechanical/tools/builder.py b/src/aiida_mechanical/tools/builder.py new file mode 100644 index 0000000..9e3907b --- /dev/null +++ b/src/aiida_mechanical/tools/builder.py @@ -0,0 +1,18 @@ +from ..workflows import USFEWorkChain, GSFEWorkChain + +def get_builder(workchain_type: str, **kwargs): + """Get a builder for the specified workchain type. + + :param workchain_type: Type of workchain ('usfe' or 'gsfe') + :param kwargs: Additional arguments to pass to get_builder() + :return: WorkChain builder + """ + if workchain_type == 'usfe': + return USFEWorkChain.get_builder(**kwargs) + elif workchain_type == 'gsfe': + return GSFEWorkChain.get_builder(**kwargs) + else: + raise ValueError( + f"Invalid workchain type: {workchain_type}. " + f"Supported types: 'usfe', 'gsfe'" + ) \ No newline at end of file diff --git a/src/aiida_mechanical/tools/calculator.py b/src/aiida_mechanical/tools/calculator.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aiida_mechanical/tools/cut.py b/src/aiida_mechanical/tools/cut.py new file mode 100644 index 0000000..3933214 --- /dev/null +++ b/src/aiida_mechanical/tools/cut.py @@ -0,0 +1,686 @@ +from aiida import orm +from ase import Atoms +from ase.build import make_supercell +import numpy +import sympy +import math +from fractions import Fraction + +import warnings +import functools +import numpy as np +import matplotlib.pyplot as plt +from pymatgen.core import Structure +from matplotlib import patheffects +from matplotlib.patches import Circle +import string +from pymatgen.transformations.standard_transformations import SupercellTransformation + +LIST_ALPHABET = list(string.ascii_uppercase) + +def list_to_tex(indices): + """ + 将一个整数列表(如 [1, -1, 0])转换为 Miller 指数的 LaTeX 格式, + 例如 [1, -1, 0] → '$1\\bar{1}0$'。 + """ + out = [] + for h in indices: + if h < 0: + # 负数用 \bar{digit} 表示 + out.append(r"\bar{%d}" % abs(h)) + else: + # 非负数直接写数字 + out.append(str(h)) + return "[$" + "".join(out) + "$]" + + +def draw_sphere(ax, x, y, radius, base_color='skyblue', base_alpha=1, + n_rings=150, inner=0.2, outer=0.4, power=1.): + """ + 在 (x,y) 画一个假 3D 球: + - fac = i/n_rings 为环带半径比例,从 1 到 0 递减 + - fac >= outer: 纯 base_color + - inner < fac < outer: t = ((fac-inner)/(outer-inner))**power 渐变 + - fac <= inner: 纯白 + - 最外层再加一圈黑边 + """ + base_rgb = np.array(plt.cm.colors.to_rgb(base_color)) + white = np.array([1.0, 1.0, 1.0]) + + for i in range(n_rings, 0, -1): + fac = i / n_rings + r = radius * fac + + if fac <= inner: + color = white + elif fac <= outer: + # 在 inner list[list[float]]: + """ + Find the plane that cuts the atoms in the PBC box. + """ + cell = ase_atoms.cell.array + + + # 2. + n = numpy.dot(cut_plane, cell) + + n2 = numpy.dot(cell, n) + # 3. + fracs = [Fraction(x).limit_denominator(max_denom) for x in n2] + D = math.lcm(*(f.denominator for f in fracs)) + A, B, C = [int(f * D) for f in fracs] + g_all = math.gcd(math.gcd(abs(A), abs(B)), abs(C)) + A, B, C = A//g_all, B//g_all, C//g_all + + print(A, B, C) + + g = math.gcd(A, B) + v1 = numpy.array([ B//g, -A//g, 0 ], dtype=int) + v2 = numpy.cross(numpy.array([A, B, C], int), v1) + + # + def dot(u,v): return int(u.dot(v)) + def norm2(u): return dot(u,u) + + # 5. + while True: + mu = round(Fraction(dot(v1, v2), norm2(v1))) + v2 = v2 - mu * v1 + if norm2(v2) < norm2(v1): + v1, v2 = v2, v1 + continue + break + + v3 = numpy.cross(v1, v2) # w = v1 × v2 + g = math.gcd(math.gcd(abs(v3[0]), abs(v3[1])), abs(v3[2])) + n = v3 // g + return (v1, v2, v3) + +@deprecated("use Pymatgen instead") +def basis_transform( + v1, v2, v3, + old_atoms, + tol=1e-6 + ): + """ + v1, v2, v3 : np.array(shape=(3,)), 原三维晶格基矢 + old_atoms : list of (x,y,z),原胞内所有原子的分数坐标 + tol : 判断 gamma 是否为整数的容差 + + 返回: + 一个列表,元素为 (alpha_mod1, beta_mod1, layer_index, real_xyz) + layer_index 是 gamma 四舍五入后的整数层号, + real_xyz 是实空间坐标。 + """ + # 2) 计算 M⁻¹(它恰好也是整数矩阵的逆,但用浮点足够精度) + M = numpy.column_stack((v1, v2, v3)) + Minv = numpy.linalg.inv(M) + + results = [] + for x,y,z in old_atoms: + f = numpy.array([x, y, z], dtype=float) + alpha, beta, gamma = Minv.dot(f) + + # 3) 筛出落在过原点平面上的那些 atom + if abs(gamma - round(gamma)) > tol: + continue + + layer = int(round(gamma)) + # 把 alpha,beta 映射回 [0,1) + a_mod = alpha - numpy.floor(alpha) + b_mod = beta - numpy.floor(beta) + # 4) 真实空间坐标 + R = x*a + y*b + z*c + + results.append((a_mod, b_mod, layer, R)) + + return results + +@deprecated("use Pymatgen instead") +def shortest_plane_basis( + ase_atoms: Atoms, + cut_plane: list[float], + tol: float = 1e-6 + ): + + v1, v2, v3 = find_plane_pbc(ase_atoms, cut_plane) + return basis_transform(v1, v2, v3, ase_atoms.positions) + +@deprecated("use Pymatgen instead") +def shortest_lattice_and_plane_vectors( + basis: numpy.ndarray, + frac_coords: list[float] +) -> tuple[ + numpy.ndarray, tuple[int, int, int], + list[numpy.ndarray], list[tuple[int, int, int]] +]: + """ + 计算给定晶格基矢和倒易空间坐标法向下: + 1. 法向上的最短晶格向量 d_normal + 2. 平面内的两条最短晶格基矢 d_plane[0], d_plane[1] + + Parameters: + - basis: shape (3,3) 的 NumPy 数组,每列为晶格基矢 a, b, c。 + - frac_coords: 法向在倒易基底中的坐标 [h, k, l],可为浮点数或 Fraction。 + + Returns: + - d_normal: 最短晶格向量(笛卡尔坐标)。 + - primitive_hkl_normal: 归一化后的 Miller 整数指数 (h0, k0, l0)。 + - d_plane: List of two np.ndarrays, 平面内最短晶格向量。 + - primitive_hkls_plane: List of two tuples, 平面基矢对应的整数向量 (h, k, l). + """ + # 将输入坐标转换为 Fraction + frac_list = [] + for f in frac_coords: + if isinstance(f, Fraction): + frac_list.append(f) + else: + frac_list.append(Fraction(f).limit_denominator()) + + # --- 法向部分 --- + denoms = [f.denominator for f in frac_list] + lcm = math.lcm(*denoms) + hkl_int = [int(f.numerator * (lcm // f.denominator)) for f in frac_list] + g = math.gcd(math.gcd(abs(hkl_int[0]), abs(hkl_int[1])), abs(hkl_int[2])) + if g == 0: + raise ValueError("分数坐标至少需一个非零分量") + primitive_hkl_normal = tuple(h // g for h in hkl_int) + d_normal = (primitive_hkl_normal[0] * basis[:, 0] + + primitive_hkl_normal[1] * basis[:, 1] + + primitive_hkl_normal[2] * basis[:, 2]) + + # --- 平面部分 --- + M = sympy.Matrix([primitive_hkl_normal]) + nulls = M.nullspace() + ints = [] + for v in nulls: + v_rat = sympy.nsimplify(v, []) + den = [ri.q for ri in v_rat] + l_val = sympy.ilcm(*den) + m_int = (v_rat * l_val).applyfunc(lambda x: int(x)) + ints.append(numpy.array(m_int, dtype=int).flatten()) + # 构造候选 + v1, v2 = ints + candidates = { + tuple(v1), tuple(v2), + tuple(v1 + v2), tuple(v1 - v2), tuple(v2 - v1) + } + vecs = [] + for hkl in candidates: + d = hkl[0] * basis[:, 0] + hkl[1] * basis[:, 1] + hkl[2] * basis[:, 2] + length = numpy.linalg.norm(d) + vecs.append((length, hkl, d)) + vecs_sorted = sorted(vecs, key=lambda x: x[0]) + _, hkl1, d1 = vecs_sorted[0] + d_plane = [d1] + primitive_hkls_plane = [hkl1] + for length, hkl, d in vecs_sorted[1:]: + if numpy.linalg.norm(numpy.cross(d1, d)) > 1e-8: + d_plane.append(d) + primitive_hkls_plane.append(hkl) + break + + return d_normal, primitive_hkl_normal, d_plane, primitive_hkls_plane + +@deprecated("use Pymatgen instead") +def map_atoms_to_new_cell(old_basis, new_basis, frac_old, padding=1): + """ + 将旧原胞中原子的分数坐标映射到新基矢定义的原胞内, + 并考虑周期性,确保新胞内所有原子都被捕获。 + + Parameters: + - old_basis: (3,3) NumPy 数组,每列是旧基矢 + - new_basis: (3,3) NumPy 数组,每列是新基矢 + - frac_old: (N,3) NumPy 数组,旧原胞内原子的分数坐标 + - padding: 周期包裹范围, 默认1 (即考虑 -1,0,+1 的平移) + + Returns: + - frac_new_in_cell: (M,3) 新原胞内原子的分数坐标,归一化到 [0,1) + """ + # 旧分数坐标 -> 笛卡尔 + cart_old = frac_old @ old_basis.T + + # 产生周期平移向量组合 + shifts = numpy.array([[i, j, k] + for i in range(-padding, padding+1) + for j in range(-padding, padding+1) + for k in range(-padding, padding+1)]) + + frac_new_list = [] + inv_new = numpy.linalg.inv(new_basis) + + # 对每个原子和每个平移组合进行映射 + for v_frac, R in zip(frac_old, cart_old): + for shift in shifts: + R_shift = R + shift @ old_basis.T + frac_new = R_shift @ inv_new.T + # 归一化到[0,1) + frac_new_mod = frac_new - np.floor(frac_new) + frac_new_list.append(frac_new_mod) + + frac_new_array = numpy.array(frac_new_list) + + # 去重:依靠坐标四舍五入 + unique_frac = numpy.unique(numpy.round(frac_new_array, 6), axis=0) + + return unique_frac diff --git a/src/aiida_mechanical/tools/gliding_systems.py b/src/aiida_mechanical/tools/gliding_systems.py new file mode 100644 index 0000000..b2a9403 --- /dev/null +++ b/src/aiida_mechanical/tools/gliding_systems.py @@ -0,0 +1,22 @@ +from aiida_mechanical.data.gliding_systems import ( + FaultConfig, + GlidingPlaneConfig, + GlidingSystem, + get_gliding_system, + A1GlidingSystem, + A2GlidingSystem, + B1GlidingSystem, + B2GlidingSystem, + C1bGlidingSystem, + L21GlidingSystem, + E21GlidingSystem, + _GLIDING_SYSTEM_REGISTRY, + _GLIDING_SYSTEM_CACHE +) + +__all__ = [ + 'FaultConfig', 'GlidingPlaneConfig', 'GlidingSystem', 'get_gliding_system', + 'A1GlidingSystem', 'A2GlidingSystem', 'B1GlidingSystem', 'B2GlidingSystem', + 'C1bGlidingSystem', 'L21GlidingSystem', 'E21GlidingSystem', + '_GLIDING_SYSTEM_REGISTRY', '_GLIDING_SYSTEM_CACHE' +] diff --git a/src/aiida_mechanical/tools/structure.py b/src/aiida_mechanical/tools/structure.py new file mode 100644 index 0000000..b51745b --- /dev/null +++ b/src/aiida_mechanical/tools/structure.py @@ -0,0 +1,602 @@ +from aiida import orm +from math import sqrt, acos, pi, ceil +import numpy +import numpy.linalg as la +import logging +from ase import Atoms +from ase.spacegroup import get_spacegroup +from ase.build import make_supercell +from pymatgen.symmetry.analyzer import SpacegroupAnalyzer +import pathlib +import typing as ty +from copy import deepcopy +import itertools +from deprecated import deprecated +from dataclasses import dataclass, field +from abc import ABC, abstractmethod + +# Import from new modules +from .structure_utils import ( + AttributeDict, + group_by_layers, + read_structure_from_file, + get_strukturbericht, + is_primitive_cell, + get_elements_for_wyckoff_symbols, + check_bravais_lattice, + calculate_surface_area +) +from .gliding_systems import ( + FaultConfig, + GlidingPlaneConfig, + GlidingSystem, + get_gliding_system, + A1GlidingSystem, + A2GlidingSystem, + B1GlidingSystem, + B2GlidingSystem, + C1bGlidingSystem, + L21GlidingSystem, + E21GlidingSystem, + _GLIDING_SYSTEM_REGISTRY, + _GLIDING_SYSTEM_CACHE +) +from .structure_builder import ( + build_atoms_surface, + build_atoms_from_stacking_removal, + build_atoms_from_stacking_mirror, + build_atoms_from_burger_vector, + build_atoms_from_burger_vector_general, + build_atoms_from_burger_vector_with_vacuum, + update_faults +) + +# Re-export necessary functions and classes +__all__ = [ + 'FaultConfig', 'GlidingPlaneConfig', 'GlidingSystem', + 'get_gliding_system', 'AttributeDict', 'read_structure_from_file', + 'group_by_layers', 'get_strukturbericht', 'get_unstable_faulted_structure', + 'get_conventional_structure', 'get_cleavaged_structure', + 'get_faulted_structure', 'get_unstable_faulted_structure_and_kpoints', + 'is_primitive_cell', 'get_elements_for_wyckoff_symbols', + 'get_kpoints_mesh_for_supercell', 'calculate_surface_area' +] + +def _build_base_structure( + structure_type: str, + ase_atoms_t, + n_unit_cells: int, + layers_dict: dict, + plane_config: GlidingPlaneConfig, + print_info: bool = False, +): + """ + Build base structures (unfaulted/conventional, cleavaged, twinning). + + Args: + structure_type: Type of structure to build ('unfaulted', 'cleavaged', 'twinning') + ase_atoms_t: Transformed atoms structure + n_unit_cells: Number of unit cells + layers_dict: Dictionary of layers + plane_config: GlidingPlaneConfig object + print_info: Whether to print debug information + + Returns: + Structure (ASE Atoms object) or None + """ + if structure_type == 'unfaulted': + return ase_atoms_t + elif structure_type == 'cleavaged': + return build_atoms_surface( + ase_atoms_t, n_unit_cells, layers_dict, print_info=print_info, + ) + elif structure_type == 'twinning': + if plane_config.n_layers > 2: + return build_atoms_from_stacking_mirror( + ase_atoms_t, n_unit_cells, layers_dict, print_info=print_info, + ) + else: + return None + else: + raise ValueError(f'Unknown base structure type: {structure_type}') + + +def _build_faulted_structure( + config: FaultConfig, + ase_atoms_t, + n_unit_cells: int, + layers_dict: dict, + print_info: bool = False +): + """ + Internal helper to build faulted structure from config. + """ + if not config.possible: + return None + + if config.removal_layers is not None: + structure = build_atoms_from_stacking_removal( + ase_atoms_t, + n_unit_cells, + config.removal_layers, + layers_dict, + additional_spacing=(config.interface, 0.0), + print_info=print_info + ) + return { + 'mode': 'removal', + 'structures': [{ + 'structure': structure, + 'layers': config.removal_layers, + }], + } + + if config.burger_vectors is not None: + structures_list = [] + # Handle list format (intrinsic/unstable/extrinsic usually) + if isinstance(config.burger_vectors, list): + for bv in config.burger_vectors: + structure = build_atoms_from_burger_vector( + ase_atoms_t, + n_unit_cells, + bv, + layers_dict, + print_info=print_info + ) + structures_list.append({ + 'structure': structure, + 'burger_vector': bv, + }) + + if structures_list: + return { + 'mode': 'gliding', + 'structures': structures_list, + } + + return None + + +def _prepare_structure_data( + ase_atoms_conventional, + gliding_plane: ty.Optional[str] = None, + print_info: bool = False, +) -> tuple[str, GlidingSystem, GlidingPlaneConfig, dict]: + """ + Prepare common structure data for building faulted and cleavaged structures. + This function works on conventional cell, not unit cell. + + Args: + ase_atoms_conventional: ASE Atoms object representing the conventional cell + gliding_plane: Gliding plane direction (e.g., '111', '011'). + If None, uses the default plane from gliding system + print_info: Whether to print debug information + + Returns: + Tuple of (strukturbericht, gliding_system, plane_config, layers_dict) + """ + strukturbericht = get_strukturbericht(ase_atoms_conventional) + if not strukturbericht: + raise ValueError('No match found in the provided list of prototypes.') + + if print_info: + print(f'Strukturbericht {strukturbericht} detected') + + # Get gliding system using new architecture + gliding_system = get_gliding_system(strukturbericht) + + # Use default plane if not provided + if not gliding_plane: + gliding_plane = gliding_system.default_plane + + plane_config = gliding_system.get_plane(gliding_plane) + + # Group layers from conventional structure + layers_dict = group_by_layers(ase_atoms_conventional) + + if len(layers_dict) != plane_config.n_layers: + raise ValueError( + f'Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers} for ' + f'{strukturbericht} with gliding plane {gliding_plane}. ' + 'This may indicate wrong initial structure, incorrect structure type, or incorrect transformation matrix.' + ) + + return (strukturbericht, gliding_system, plane_config, layers_dict) + +def get_unstable_faulted_structure( + ase_atoms_uc, + gliding_plane: ty.Optional[str] = None, + P: ty.Optional[ty.Union[list, numpy.ndarray]] = None, + n_unit_cells: int = 3, + print_info: bool = False, + ) -> tuple[str, AttributeDict]: + """ + Generate faulted structures for a given unit cell structure. + + Args: + ase_atoms_uc: ASE Atoms object representing the unit cell + gliding_plane: Gliding plane direction (e.g., '111', '011'). Defaults to '111' + P: Transformation matrix. If None, uses the default from gliding system + n_unit_cells: Number of unit cells to repeat + print_info: Whether to print debug information + + Returns: + Tuple of (strukturbericht, structures_dict) where structures_dict contains: + - 'conventional': Conventional structure + - 'twinning': Twinning structure (if applicable) + - 'cleavaged': Cleavaged surface structure + - 'intrinsic': Intrinsic fault structure (if configured) + - 'unstable': Unstable fault structure (if configured) + - 'extrinsic': Extrinsic fault structure (if configured) + """ + + strukturbericht = get_strukturbericht(ase_atoms_uc) + if not strukturbericht: + raise ValueError('No match found in the provided list of prototypes.') + + if print_info: + print(f'Strukturbericht {strukturbericht} detected') + + # Get gliding system using new architecture + gliding_system = get_gliding_system(strukturbericht) + + # Use default plane if not provided + if not gliding_plane: + gliding_plane = gliding_system.default_plane + + plane_config = gliding_system.get_plane(gliding_plane) + + # Use provided transformation matrix or default from config + if not P: + P = plane_config.transformation_matrix + else: + P = numpy.array(P) + + ase_atoms_t = make_supercell(ase_atoms_uc, P) + layers_dict = group_by_layers(ase_atoms_t) + + if len(layers_dict) != plane_config.n_layers: + raise ValueError( + f'Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers} for ' + f'{strukturbericht} with gliding plane {gliding_plane}. ' + 'This may indicate wrong initial structure, incorrect structure type, or incorrect transformation matrix.' + ) + + # Build base structures using unified function + structures = AttributeDict({ + 'conventional': _build_base_structure( + 'unfaulted', ase_atoms_t, n_unit_cells, layers_dict, plane_config, print_info + ), + 'twinning': _build_base_structure( + 'twinning', ase_atoms_t, n_unit_cells, layers_dict, plane_config, print_info + ), + 'cleavaged': _build_base_structure( + 'cleavaged', ase_atoms_t, n_unit_cells, layers_dict, plane_config, print_info + ), + }) + + # Build faulted structures using new architecture + intrinsic_fault = _build_faulted_structure( + plane_config.intrinsic, ase_atoms_t, n_unit_cells, layers_dict, print_info + ) + if intrinsic_fault is not None: + structures['intrinsic'] = intrinsic_fault + + unstable_fault = _build_faulted_structure( + plane_config.unstable, ase_atoms_t, n_unit_cells, layers_dict, print_info + ) + if unstable_fault is not None: + structures['unstable'] = unstable_fault + + extrinsic_fault = _build_faulted_structure( + plane_config.extrinsic, ase_atoms_t, n_unit_cells, layers_dict, print_info + ) + if extrinsic_fault is not None: + structures['extrinsic'] = extrinsic_fault + + return (strukturbericht, structures) + +def get_conventional_structure( + ase_atoms_uc, + gliding_plane: ty.Optional[str] = None, + P: ty.Optional[ty.Union[list, numpy.ndarray]] = None, + print_info: bool = False, +) -> tuple[str, Atoms]: + """ + Generate conventional (unfaulted) structure from unit cell structure. + This is the only function that converts unit cell to conventional cell. + + Args: + ase_atoms_uc: ASE Atoms object representing the unit cell + gliding_plane: Gliding plane direction (e.g., '111', '011'). + If None, uses the default plane from gliding system + P: Transformation matrix. If None, uses the default from gliding system + print_info: Whether to print debug information + + Returns: + Tuple of (strukturbericht, conventional_structure) + """ + strukturbericht = get_strukturbericht(ase_atoms_uc) + if not strukturbericht: + raise ValueError('No match found in the provided list of prototypes.') + + if print_info: + print(f'Strukturbericht {strukturbericht} detected') + + # Get gliding system using new architecture + gliding_system = get_gliding_system(strukturbericht) + + # Use default plane if not provided + if not gliding_plane: + gliding_plane = gliding_system.default_plane + + plane_config = gliding_system.get_plane(gliding_plane) + + # Use provided transformation matrix or default from config + if not P: + P = plane_config.transformation_matrix + else: + P = numpy.array(P) + + ase_atoms_conventional = make_supercell(ase_atoms_uc, P) + + return (strukturbericht, ase_atoms_conventional) + + +def get_cleavaged_structure( + ase_atoms_conventional, + gliding_plane: ty.Optional[str] = None, + n_unit_cells: int = 3, + print_info: bool = False, + ) -> tuple[str, Atoms]: + """ + Generate cleavaged surface structure from conventional cell structure. + + Args: + ase_atoms_conventional: ASE Atoms object representing the conventional cell + gliding_plane: Gliding plane direction (e.g., '111', '011'). + If None, uses the default plane from gliding system + n_unit_cells: Number of unit cells to repeat + print_info: Whether to print debug information + + Returns: + Tuple of (strukturbericht, cleavaged_structure) + """ + strukturbericht, _, _, layers_dict = _prepare_structure_data( + ase_atoms_conventional, gliding_plane, print_info + ) + + cleavaged_structure = build_atoms_surface( + ase_atoms_conventional, n_unit_cells, layers_dict, print_info=print_info, + ) + + return (strukturbericht, cleavaged_structure) + + +class FaultedStructureEntry(ty.TypedDict, total=False): + """Container for a single faulted structure variant.""" + structure: Atoms + layers: list[int] # only for removal faults + burger_vector: list[float] # only for gliding faults + + +class FaultedStructureResult(ty.TypedDict): + """Normalized return type for faulted structures.""" + mode: ty.Literal['removal', 'gliding'] + structures: list[FaultedStructureEntry] + +def get_faulted_structure( + ase_atoms_conventional, + fault_mode: str, + fault_type: str, + additional_spacing: float = 0.0, + gliding_plane: ty.Optional[str] = None, + n_unit_cells: int = 3, + vacuum_ratio: float = 0.0, + print_info: bool = False, + **kwargs, + ) -> tuple[str, ty.Optional[FaultedStructureResult]]: + """Generate faulted structure of a specific type from conventional cell structure.""" + + from copy import deepcopy as dp + + if fault_mode not in ['removal', 'vacuum', 'general']: + raise ValueError( + f"fault_mode must be one of 'removal', 'vacuum', 'general', " + f"got '{fault_mode}'" + ) + + if fault_mode == 'removal' and fault_type not in ['intrinsic', 'unstable', 'extrinsic']: + raise ValueError( + f"fault_type must be one of 'intrinsic', 'unstable', or 'extrinsic', " + f"got '{fault_type}'" + ) + + strukturbericht, _, plane_config, layers_dict = _prepare_structure_data( + ase_atoms_conventional, gliding_plane, print_info + ) + + fault_config = getattr(plane_config, fault_type) + + if not fault_config.possible: + return None + + # Prefer removal mode if available and requested + if fault_mode == 'removal' and fault_config.removal_layers is not None: + if fault_config.removal_layers is not None: + raise ValueError( + f"Fault type {fault_type} is not available for removal mode." + ) + structure = build_atoms_from_stacking_removal( + ase_atoms_conventional, + n_unit_cells, + fault_config.removal_layers, + layers_dict, + additional_spacing=(fault_config.interface, additional_spacing), + print_info=print_info + ) + faulted_structure = { + 'mode': 'removal', + 'structures': [{ + 'structure': structure, + 'layers': fault_config.removal_layers, + }], + } + + # Use burger vector (gliding/vacuum) mode if available + if fault_mode == 'vacuum' and vacuum_ratio > 0.0 and fault_config.burger_vectors is not None: + structures_list: list[FaultedStructureEntry] = [] + for burger_vector in fault_config.burger_vectors: + structure = build_atoms_from_burger_vector_with_vacuum( + ase_atoms_conventional, + n_unit_cells, + burger_vector, + layers_dict, + vacuum_ratio=vacuum_ratio, + print_info=print_info + ) + + structures_list.append({ + 'structure': structure, + 'burger_vector': burger_vector, + }) + faulted_structure = { + 'mode': 'vacuum', + 'structures': structures_list, + } + + if fault_mode == 'general' and fault_config.burger_vectors is not None: + structures_list: list[FaultedStructureEntry] = [] + nsteps = kwargs.get('nsteps', fault_config.nsteps) + stacking_order = ''.join(layers_dict.keys()) + if not isinstance(n_unit_cells, int) or n_unit_cells < 2: + raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be an integer >= 2.") + + zs = [(value['z'] + layer)/n_unit_cells for layer in range(n_unit_cells) for value in layers_dict.values()] + stacking_order_supercell = stacking_order * n_unit_cells + + new_cell = ase_atoms_conventional.cell.array.copy() + new_cell[-1] *= (n_unit_cells) + + if isinstance(fault_config.burger_vectors, dict): + for _direction_name, segment in fault_config.burger_vectors.items(): + burgers_vector_for_cell = numpy.zeros(3) + faults = numpy.zeros((len(stacking_order_supercell), 3)) + structure = build_atoms_from_burger_vector_general( + new_cell, + deepcopy(zs), + layers_dict, + stacking_order_supercell, + burgers_vector_for_cell, + faults, + print_info=print_info + ) + structures_list.append({ + 'structure': structure, + 'burger_vector': burgers_vector_for_cell.tolist(), + }) + for interface, burgers_vector in segment: + burgers_vector_step = numpy.array(burgers_vector) / nsteps + for _ in range(1, 1+nsteps): + faults = update_faults(faults, interface, burgers_vector_step) + burgers_vector_for_cell += burgers_vector_step + structure = build_atoms_from_burger_vector_general( + new_cell, + deepcopy(zs), + layers_dict, + stacking_order_supercell, + burgers_vector_for_cell, + faults, + print_info=print_info + ) + + structures_list.append({ + 'structure': structure, + 'burger_vector': burgers_vector_for_cell.tolist(), + }) + + else: + # Legacy or unhandled format - skipping as per new requirement + pass + + faulted_structure = { + 'mode': 'gliding', + 'structures': structures_list, + } + return (strukturbericht, faulted_structure) + + +def get_unstable_faulted_structure_and_kpoints( + structure_uc: orm.StructureData, + kpoints_uc: orm.KpointsData, + n_layers: int, + slipping_system: orm.List, +) -> tuple[orm.StructureData, orm.KpointsData]: + """Get unstable faulted structure and corresponding kpoints for GSFE workflow. + + This is a convenience wrapper that extracts structure and calculates kpoints + from get_unstable_faulted_structure. + + :param structure_uc: Unit cell structure + :param kpoints_uc: Unit cell kpoints + :param n_layers: Number of layers + :param slipping_system: List containing [structure_type, gliding_plane, slipping_direction] + :return: Tuple of (faulted_structure, kpoints) + """ + structure_type, gliding_plane, _ = slipping_system.get_list() + + # Get unstable faulted structure + _, structures_dict = get_unstable_faulted_structure( + structure_uc.get_ase(), + gliding_plane=gliding_plane if gliding_plane else None, + n_unit_cells=n_layers, + ) + + # Extract unstable structure + if 'unstable' not in structures_dict or structures_dict['unstable'] is None: + raise ValueError('Unstable fault structure is not available for this gliding system.') + + unstable_data = structures_dict['unstable'] + if not unstable_data.get('structures'): + raise ValueError('Unstable fault structure list is empty.') + + unstable_structure_ase = unstable_data['structures'][0].get('structure') + if unstable_structure_ase is None: + raise ValueError('Unstable fault structure is missing structure data.') + + # Convert to StructureData + structure_sc = orm.StructureData(ase=unstable_structure_ase) + + # Calculate kpoints for supercell + # Get z-ratio between supercell and unit cell + z_ratio = unstable_structure_ase.cell.cellpar()[2] / structure_uc.cell.cellpar()[2] + kpoints_mesh_uc = kpoints_uc.get_kpoints_mesh()[0] + + # Adjust kpoints mesh for supercell + kpoints_mesh_sc = list(kpoints_mesh_uc) + kpoints_mesh_sc[2] = ceil(kpoints_mesh_sc[2] / z_ratio) + + kpoints_sc = orm.KpointsData() + kpoints_sc.set_kpoints_mesh(kpoints_mesh_sc) + + return (structure_sc, kpoints_sc) + +def get_kpoints_mesh_for_supercell( + kpoints_uc: orm.KpointsData, + n_layers: int, + n_stacking: int, + ) -> orm.KpointsData: + """ + Get the kpoints mesh for the supercell. + Assume scaling by n_layers * n_stacking along Z? + Or just roughly heuristic. + """ + kpoints_mesh = list(kpoints_uc.get_kpoints_mesh()[0]) + # Heuristic: reduce z-sampling by factor of supercell expansion + # Total expansion ~ n_layers (if n_layers means n_unit_cells) + if n_layers > 0: + kpoints_mesh[2] = max(1, int(ceil(kpoints_mesh[2] / n_layers))) + + kpoints = orm.KpointsData() + kpoints.set_kpoints_mesh(kpoints_mesh) + return kpoints diff --git a/src/aiida_mechanical/tools/structure_builder.py b/src/aiida_mechanical/tools/structure_builder.py new file mode 100644 index 0000000..6fd994a --- /dev/null +++ b/src/aiida_mechanical/tools/structure_builder.py @@ -0,0 +1,360 @@ +from ase import Atoms +from copy import deepcopy +import typing as ty +import numpy +from deprecated import deprecated +import itertools + +def build_atoms_surface( + ase_atoms_uc, + n_unit_cells, + layers_dict, + print_info = False, + vacuum_spacing = 1.0, + ): + atoms = Atoms() + + if not isinstance(n_unit_cells, int) or n_unit_cells < 1: + raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be a positive integer.") + + stacking_order = n_unit_cells * ''.join(layers_dict.keys()) + + zs = [(value['z'] + cell)/n_unit_cells/(1+vacuum_spacing) for cell in range(n_unit_cells) for value in layers_dict.values()] + + new_cell = ase_atoms_uc.cell.array.copy() + new_cell[-1] *= n_unit_cells * (1+vacuum_spacing) + atoms.set_cell(new_cell) + for layer_label, z in zip(stacking_order, zs): + for atom in layers_dict[layer_label]['atoms']: + scaled_position = atom.scaled_position + scaled_position[-1] = z + atom.position = scaled_position @ new_cell + atoms.append(atom) + + return atoms + +def build_atoms_from_stacking_removal( + ase_atoms_uc, + n_unit_cells, + removed_layers, + layers_dict, + additional_spacing = (0, 0.0), + print_info = False, + ): + + atoms = Atoms() + + stacking_order = n_unit_cells * ''.join(layers_dict.keys()) + if not isinstance(n_unit_cells, int) or n_unit_cells < 1: + raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be a positive integer.") + if any(layer >= len(stacking_order) for layer in removed_layers): + raise ValueError( + f"Invalid removed layers {removed_layers}: layer indices must be < {len(stacking_order)} " + f"(number of layers in stacking order)" + ) + + zs = numpy.array([value['z']/n_unit_cells + layer/n_unit_cells for layer in range(n_unit_cells) for value in layers_dict.values()]) + + removed_layers_sorted = sorted(set(removed_layers)) + removed_spacing = 0.0 + faulted_stacking = "".join([char for i, char in enumerate(stacking_order) if i not in removed_layers_sorted]) + + # Remove layers from the end to avoid index shifts while updating zs + for removed_layer in reversed(removed_layers_sorted): + spacing = zs[removed_layer] - zs[removed_layer - 1] + if spacing + additional_spacing[1] < 0.0: + raise ValueError(f"Spacing between removed layers is less than additional spacing: {spacing} < {additional_spacing}") + removed_spacing += spacing + zs[removed_layer:] -= spacing + zs = numpy.delete(zs, removed_layer) + + # Apply additional spacing if requested + if additional_spacing[0] >= len(zs): + raise ValueError(f"additional_spacing layer index {additional_spacing[0]} is out of bounds for remaining layers {len(zs)}") + if additional_spacing[1] != 0.0: + zs[additional_spacing[0]:] += additional_spacing[1] + removed_spacing -= additional_spacing[1] + + zs /= (1-removed_spacing) + if print_info: + print(zs) + print(faulted_stacking) + new_cell = ase_atoms_uc.cell.array.copy() + new_cell[-1] *= (1-removed_spacing) * n_unit_cells + atoms.set_cell(new_cell) + for layer_label, z in zip(faulted_stacking, zs): + for atom in layers_dict[layer_label]['atoms']: + new_atom = deepcopy(atom) + scaled_position = new_atom.scaled_position + scaled_position[-1] = z + new_atom.position = scaled_position @ new_cell + atoms.append(new_atom) + return atoms + +def build_atoms_from_stacking_mirror( + ase_atoms_uc, + n_unit_cells, + layers_dict, + print_info = False, + ): + + atoms = Atoms() + cell = ase_atoms_uc.cell.array.copy() + z_norm = numpy.linalg.norm(cell[2]) + + n_layers_uc = len(layers_dict) + stacking_order_uc = ''.join(layers_dict.keys()) + stacking_order = n_unit_cells * stacking_order_uc + stacking_order_uc_r = stacking_order_uc[::-1] + if not isinstance(n_unit_cells, int) or n_unit_cells < 1: + raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be a positive integer.") + + # Taking 3 unit cells of 3-layer unit cell as an example + # Firstly, we place an 'ABC' stacking as a substrate. + + spacings = [ + (layers_dict[label]['z'] - layers_dict[prev_label]['z'])*z_norm + for label, prev_label in zip(stacking_order_uc[1:], stacking_order_uc[:-1]) + ] + connection_to_next_cell = (1 + layers_dict[stacking_order[0]]['z'] - layers_dict[stacking_order[-1]]['z']) * z_norm + if print_info: + print(spacings) + # Then we calculate the z coordinate of 3 stacked unit cells. + # (ABC)ABCABCABC + zs = [ + (value['z'] + layer) * z_norm + for layer in range(n_unit_cells) + for value in layers_dict.values() + ] + # And we calculate the spacing of (ABC)CBACBACBA and reverse it. + # We calculate the spacing between the layers. + # Note that the first spacing just link the substrate to the reversed layers. + # It's convenient then we remove one C layer. + # We pop the last spacing between B and A because + # it will be calculated later when we do normal stacking. + spacings += [ + z - prev_z + for z, prev_z in zip(zs[1:], zs[:-1]) + ][::-1] + + # spacings.pop() + if print_info: + print('zs for reversed layers', zs) + print('spacings for reversed layers', spacings) + # Here we do the stacking of the rest (n_unit_cells-1) unit cells. + # Because we already have one substrate unit cell. + # (ABC)(BACBACBA)(BCABC) + zs = [ + (value['z'] + layer + n_unit_cells+1) * z_norm + for layer in range(n_unit_cells-1) + for value in layers_dict.values() + ] + + spacings += [ + z - prev_z + for z, prev_z in zip(zs[1:], zs[:-1]) + ] + + if print_info: + print(spacings) + # spacings += [(layers_dict[stacking_order_uc[0]]['z']+1.0 - layers_dict[stacking_order_uc[-1]]['z']) / n_layers/2] + + zs = [0.0] + list(itertools.accumulate(spacings)) + if print_info: + print(zs) + + new_thickness = zs[-1] + connection_to_next_cell + + faulted_stacking = stacking_order_uc[:-1] + stacking_order_uc_r * n_unit_cells + (stacking_order_uc * (n_unit_cells-1))[1:] + if print_info: + print(faulted_stacking) + z_dialation = new_thickness / z_norm + new_cell = ase_atoms_uc.cell.array.copy() + new_cell[-1] *= z_dialation + atoms.set_cell(new_cell) + for layer_label, z in zip(faulted_stacking, zs): + for atom in layers_dict[layer_label]['atoms']: + new_atom = deepcopy(atom) + scaled_position = new_atom.scaled_position + scaled_position[-1] = z / new_thickness + new_atom.position = scaled_position @ new_cell + atoms.append(new_atom) + + return atoms + +def build_atoms_from_burger_vector( + ase_atoms_uc, + n_unit_cells, + burger_vector, + layers_dict, + print_info = False, + ): + + atoms = Atoms() + + stacking_order = ''.join(layers_dict.keys()) + if not isinstance(n_unit_cells, int) or n_unit_cells < 2: + raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be an integer >= 2.") + + zs = [(value['z'] + layer)/n_unit_cells/2 for layer in range(2*n_unit_cells) for value in layers_dict.values()][::-1] + + if print_info: + print(zs) + + new_cell = ase_atoms_uc.cell.array.copy() + new_cell[-1] *= (n_unit_cells*2) + atoms.set_cell(new_cell) + + for layer_label in stacking_order: + z = zs.pop() + for atom in layers_dict[layer_label]['atoms']: + new_atom = deepcopy(atom) + scaled_position = new_atom.scaled_position + scaled_position[-1] = z + new_atom.position = scaled_position @ new_cell + atoms.append(new_atom) + + for layer in range(n_unit_cells): + for layer_label in stacking_order: + z = zs.pop() + for atom in layers_dict[layer_label]['atoms']: + new_atom = deepcopy(atom) + scaled_position = new_atom.scaled_position + scaled_position += numpy.array(burger_vector) + scaled_position[-1] = z + new_atom.position = scaled_position @ new_cell + atoms.append(new_atom) + + for layer in range(n_unit_cells-1): + for layer_label in stacking_order: + z = zs.pop() + for atom in layers_dict[layer_label]['atoms']: + new_atom = deepcopy(atom) + scaled_position = new_atom.scaled_position + scaled_position[-1] = z + new_atom.position = scaled_position @ new_cell + atoms.append(new_atom) + + if zs: + raise ValueError(f"zs is not empty: {zs}") + + return atoms + +def update_faults(faults, interface, burger_vector): + """ + Update faults list by adding burger_vector to layers at and after interface. + + Args: + faults: numpy array of shape (n_layers, 3) containing burger_vectors for each layer + interface: Layer index where fault starts (layers at and after this index will be updated) + burger_vector: Burger vector to add to layers at/after interface + + Returns: + Updated faults array + """ + faults = faults.copy() + faults[interface:] += burger_vector + return faults + +def build_atoms_from_burger_vector_general( + new_cell, + zs, + layers_dict, + stacking_order_supercell, + burger_vector_for_cell, + faults, + print_info = False, + ): + """ + Build atoms structure with burger vector faults. + + Args: + new_cell: Cell matrix + zs: List of z coordinates for layers + layers_dict: Dictionary of layers + stacking_order_supercell: Stacking order for supercell + faults: numpy array of shape (n_layers, 3) containing burger_vectors for each layer + print_info: Whether to print debug info + + Returns: + Atoms object with faults applied + """ + atoms = Atoms() + + # Calculate cell tilt from total burger_vector in xy plane + # Sum all faults to get total burger_vector for cell tilt + burger_vector_cart = burger_vector_for_cell[:2] @ new_cell[:2] + new_cell_tilted = deepcopy(new_cell) + new_cell_tilted[-1] += burger_vector_cart + + atoms.set_cell(new_cell_tilted) + + for layer_label, fault in zip(stacking_order_supercell, faults): + z = zs.pop(0) + for atom in layers_dict[layer_label]['atoms']: + new_atom = deepcopy(atom) + scaled_position = new_atom.scaled_position + fault + scaled_position[-1] = z + # Calculate absolute position using original cell (without tilt) + # This preserves the absolute spatial position of atoms + absolute_position = scaled_position @ new_cell + # Set the absolute position directly; ASE will handle fractional coordinate conversion + # when we access scaled_position later with the tilted cell + new_atom.position = absolute_position + atoms.append(new_atom) + + if zs: + raise ValueError(f"zs is not empty: {zs}") + + return atoms + +@deprecated(reason="This function is not used in any workflow. Use build_atoms_from_burger_vector instead.") +def build_atoms_from_burger_vector_with_vacuum( + ase_atoms_uc, + n_unit_cells, + burger_vector, + layers_dict, + vacuum_ratio = 0.0, + print_info = False, + ): + + atoms = Atoms() + + stacking_order = ''.join(layers_dict.keys()) + if not isinstance(n_unit_cells, int) or n_unit_cells < 2: + raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be an integer >= 2.") + + new_cell = ase_atoms_uc.cell.array.copy() + new_cell[-1] *= n_unit_cells + new_cell[-1] *= (1 + vacuum_ratio) + atoms.set_cell(new_cell) + + zs = [(value['z'] + layer)/n_unit_cells/2/(1 + vacuum_ratio) for layer in range(2*n_unit_cells) for value in layers_dict.values()][::-1] + + # if print_info: + # print(zs) + + for layer in range(n_unit_cells): + for layer_label in stacking_order: + # z = (layers_dict[layer_label]['z'] + layer)/n_unit_cells/2/(1 + vacuum_ratio) + z = zs.pop() + for atom in layers_dict[layer_label]['atoms']: + new_atom = deepcopy(atom) + scaled_position = new_atom.scaled_position + scaled_position[-1] = z + new_atom.position = scaled_position @ new_cell + atoms.append(new_atom) + + for layer in range(n_unit_cells): + for layer_label in stacking_order: + # z = (layers_dict[layer_label]['z'] + layer)/n_unit_cells/2/(1 + vacuum_ratio) + z = zs.pop() + for atom in layers_dict[layer_label]['atoms']: + new_atom = deepcopy(atom) + scaled_position = new_atom.scaled_position + scaled_position += numpy.array(burger_vector) + scaled_position[-1] = z + new_atom.position = scaled_position @ new_cell + atoms.append(new_atom) + + return atoms diff --git a/src/aiida_mechanical/tools/structure_utils.py b/src/aiida_mechanical/tools/structure_utils.py new file mode 100644 index 0000000..d4287c5 --- /dev/null +++ b/src/aiida_mechanical/tools/structure_utils.py @@ -0,0 +1,216 @@ +import numpy +import typing as ty +import pathlib +from aiida import orm +from copy import deepcopy +from deprecated import deprecated +import importlib.resources +from pymatgen.symmetry.analyzer import SpacegroupAnalyzer + +class AttributeDict(dict): + """ + A dictionary that can be accessed like an attribute. + """ + def __getattr__(self, name): + try: + return self[name] + except KeyError: + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + def __setattr__(self, name, value): + self[name] = value + + def __delattr__(self, name): + try: + del self[name] + except KeyError: + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + +@deprecated(reason="This function is not used in any workflow. Use ASE's built-in methods instead.") +def check_bravais_lattice(ase_atoms): + bl = ase_atoms.cell.get_bravais_lattice(eps=1e-6) + return bl.name +def __getattr__(name): + if name == 'available_structures': + import importlib.resources + return [ + f.stem + for f in importlib.resources.files('aiida_mechanical.data').glob('structures/cif/*.cif') + ] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + +def read_structure_from_file( + formula: str, + store: bool = False + ) -> orm.StructureData: + """Read a cif file by its chemical formula (or path) and return aiida ``StructureData``.""" + from ase.io import read as aseread + import importlib.resources + + formula_str = str(formula) + + if formula_str.endswith('.cif'): + raise ValueError("Please provide the chemical formula without the .cif extension (e.g. 'Al' instead of 'Al.cif')") + + if formula_str in __getattr__('available_structures'): + data_path = importlib.resources.files('aiida_mechanical.data') + filename = data_path / f'structures/cif/{formula_str}.cif' + else: + filename = formula_str + + struct = orm.StructureData(ase=aseread(filename)) + + if store: + struct.store() + print(f"Read and stored structure {struct.get_formula()}<{struct.pk}>") + + return struct + +def group_by_layers( + ase_atoms, + decimals=6, + ): + """ + Splits an ASE Atoms object into multiple layers based on z-coordinates. + + Args: + atoms (ase.Atoms): The input Atoms object to be split. + decimals (int): The number of decimal places to round the z-coordinates + to for grouping atoms into layers. This acts as a tolerance. + + Returns: + dict: A dictionary where keys are the unique z-coordinates of the layers + and values are new Atoms objects, each containing one layer. + """ + import string + from copy import deepcopy + + if not ase_atoms: + return {} + + scaled_positions = ase_atoms.get_scaled_positions() + + z_coords = scaled_positions[:, 2] + rounded_z = numpy.round(z_coords, decimals=decimals) % 1.0 + + sorted_unique_z = sorted(numpy.unique(rounded_z)) + + labels = string.ascii_uppercase + if len(sorted_unique_z) > len(labels): + print(f"Warning: Number of layers ({len(sorted_unique_z)}) exceeds number of labels ({len(labels)}).") + labels = [f"Layer_{i+1}" for i in range(len(sorted_unique_z))] + + labeled_layers_dict = {} + + for i, z_val in enumerate(sorted_unique_z): + layer_label = labels[i] + indices = numpy.where(rounded_z == z_val)[0] + layer_content = [deepcopy(ase_atoms[idx]) for idx in indices] + labeled_layers_dict[layer_label] = { + 'atoms': layer_content, + 'z': z_val + } + + return labeled_layers_dict + +def get_strukturbericht( + atoms_to_check, + print_info = False, + ): + import pymatgen.core as mg + from pymatgen.analysis.structure_matcher import StructureMatcher + from pymatgen.symmetry.analyzer import SpacegroupAnalyzer + from pymatgen.io.ase import AseAtomsAdaptor + # This dictionary holds the names of common prototypes and their + # corresponding Material IDs (mp-id) in the Materials Project database. + # sga = SpacegroupAnalyzer(read_structure_from_file('AsTe').get_pymatgen()) + + # 1. Load your local crystal structure from a file (e.g., a CIF or POSCAR) + # For this example, let's create a simple NaCl structure in memory. + # In your real code, you would use: struct_to_check = mg.Structure.from_file("your_file.cif") + + PROTOTYPES = { + "A1": read_structure_from_file('Al').get_pymatgen(), # Copper (Cu) + 'A2': read_structure_from_file('V').get_pymatgen(), # Vandadium (V) + "B1": read_structure_from_file('AsTe').get_pymatgen(), # Arsenic Telluride (AsTe) + "B2": read_structure_from_file('NiTi').get_pymatgen(), # Arsenic Telluride (AsTe) + "B_h": read_structure_from_file('MoN').get_pymatgen(), # Arsenic Telluride (AsTe) + "A15": read_structure_from_file('Nb3Sn').get_pymatgen(), # Nb3Sn (Nb3Sn) + "C1_b": read_structure_from_file('NbCoSb').get_pymatgen(), # Gold-Copper (AuCu3) + "L2_1": read_structure_from_file('HfAlPd2').get_pymatgen(), # Gold-Copper (AuCu3) + "C_7": read_structure_from_file('TaSe2').get_pymatgen(), # Gold-Copper (AuCu3) + "C_32": read_structure_from_file('MgB2').get_pymatgen(), # Gold-Copper (AuCu3) + "E_21": read_structure_from_file('TaRu3C').get_pymatgen(), # Gold-Copper (AuCu3) + } + struct_to_check = AseAtomsAdaptor.get_structure(atoms_to_check) + + try: + # 2. Initialize the StructureMatcher. + # primitive_cell=True is crucial because it compares the fundamental building block + # of the crystal, ignoring differences in conventional vs. primitive cell choices. + matcher = StructureMatcher(primitive_cell=True, scale=True) + + # 3. Fetch prototypes from Materials Project and compare + found_match = False + if print_info: + print("Comparing your structure against the database...") + for name, prototype_struct in PROTOTYPES.items(): + # Fetch the standard prototype structure + # prototype_struct = mpr.get_structure_by_material_id(mp_id) + + # Use the .fit() method to see if they match + if matcher.fit_anonymous(struct_to_check, prototype_struct): + if print_info: + print(f"✅ Your structure<{atoms_to_check.get_chemical_formula()}> is of the {name} type.") + found_match = True + return name + + if not found_match: + if print_info: + print(f"\n❌ No match found for structure<{atoms_to_check.get_chemical_formula()}> in the provided list of prototypes.") + return None + except Exception as e: + print(f"An error occurred: {e}") + print("Please ensure you have a valid structure file or an API key for the Materials Project.") + return None + +def is_primitive_cell(structure: orm.StructureData) -> bool: + """ + Check if the structure is a primitive cell + """ + structure_pmg = structure.get_pymatgen() + + primivite_structure_pmg = structure_pmg.get_primitive_structure() + + return structure_pmg.composition == primivite_structure_pmg.composition + +def get_elements_for_wyckoff_symbols( + structure: orm.StructureData, + ) -> dict: + """ + Get the symbol of the atom at the given fractional coordinates + """ + sga = SpacegroupAnalyzer(structure.get_pymatgen_structure(), symprec=1e-5) + symmetrized_structure = sga.get_symmetrized_structure() + + + return {wyckoff_letter: element.symbol + for wyckoff_letter, element in zip( + symmetrized_structure.wyckoff_letters, + symmetrized_structure.elements + ) + } + +def calculate_surface_area(ase_atoms) -> float: + """ + Calculate the surface area of the structure (XY plane area). + + Args: + ase_atoms: ASE Atoms object + + Returns: + float: Surface area in Angstrom^2 + """ + cell = ase_atoms.cell + # Assuming surface is defined by vector 0 and 1 (standard for this package) + return numpy.linalg.norm(numpy.cross(cell[0], cell[1])) From 8d71205e337af8ac8010922e7b5cdd8465d8db09 Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 16:25:42 +0200 Subject: [PATCH 15/17] feat: port SFE calculations / structure-generating calcfunctions --- src/aiida_mechanical/calculations/__init__.py | 9 +- .../calculations/structure.py | 174 ++++++++++++++++++ 2 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 src/aiida_mechanical/calculations/structure.py diff --git a/src/aiida_mechanical/calculations/__init__.py b/src/aiida_mechanical/calculations/__init__.py index 30e2e1a..76fb8f4 100644 --- a/src/aiida_mechanical/calculations/__init__.py +++ b/src/aiida_mechanical/calculations/__init__.py @@ -1,6 +1,11 @@ # -*- coding: utf-8 -*- -"""The official AiiDA plugin for Thermo_pw.""" +"""The official AiiDA plugin for mechanical properties.""" from .thermo_pw import Thermo_pwCalculation +from .structure import generate_cleavaged_structures, generate_faulted_structures -__all__ = ["Thermo_pwCalculation"] +__all__ = ( + "Thermo_pwCalculation", + "generate_cleavaged_structures", + "generate_faulted_structures", +) diff --git a/src/aiida_mechanical/calculations/structure.py b/src/aiida_mechanical/calculations/structure.py new file mode 100644 index 0000000..4333574 --- /dev/null +++ b/src/aiida_mechanical/calculations/structure.py @@ -0,0 +1,174 @@ +"""Calcfunctions for provenance-aware structure generation.""" + +from __future__ import annotations + +import typing as ty + +from ase import Atoms +from aiida import orm +from aiida.engine import calcfunction + +from aiida_mechanical.data.cleavaged_structure import CleavagedStructureData +from aiida_mechanical.data.faulted_structure import ( + FaultedStructureData, + GeneralFaultStructurePoint, + GeneralFaultStructureResult, +) + + +def _normalize_faulted_structure_points( + generated: ty.Any, + fault_type: str, +) -> list[GeneralFaultStructurePoint]: + """Normalize outputs from ``FaultedStructure.get_faulted_structure`` to a point list.""" + if generated is None: + return [] + + if isinstance(generated, Atoms): + return [{ + 'label': f'sfe_{fault_type}_000', + 'structure': generated, + 'burger_vector': [], + 'total_cell_shift': [], + 'interface_slips': {}, + 'direction_name': fault_type, + 'step_index': 0, + }] + + if isinstance(generated, list): + normalized: list[GeneralFaultStructurePoint] = [] + for index, item in enumerate(generated): + if isinstance(item, Atoms): + normalized.append({ + 'label': f'sfe_{fault_type}_{index:03d}', + 'structure': item, + 'burger_vector': [], + 'total_cell_shift': [], + 'interface_slips': {}, + 'direction_name': fault_type, + 'step_index': index, + }) + continue + + if not isinstance(item, dict) or 'structure' not in item: + raise TypeError('Unsupported faulted structure payload returned by `FaultedStructureData`.') + + normalized.append({ + 'label': str(item.get('label', f'sfe_{item.get("direction_name", fault_type)}_{index:03d}')), + 'structure': item['structure'], + 'burger_vector': [float(value) for value in item.get('burger_vector', [])], + 'total_cell_shift': [float(value) for value in item.get('total_cell_shift', item.get('burger_vector', []))], + 'interface_slips': { + int(interface): [float(value) for value in interface_shift] + for interface, interface_shift in item.get('interface_slips', {}).items() + }, + 'direction_name': item.get('direction_name', fault_type), + 'step_index': int(item.get('step_index', index)), + }) + + return normalized + + if isinstance(generated, dict): + normalized = [] + general_result = ty.cast(GeneralFaultStructureResult, generated) + + for direction_name, steps in general_result.items(): + for step_index, entry in sorted(steps.items()): + metadata = entry['metadata'] + normalized.append({ + 'label': metadata['label'], + 'structure': entry['structure'], + 'burger_vector': [float(value) for value in metadata['burger_vector']], + 'total_cell_shift': [float(value) for value in metadata['total_cell_shift']], + 'interface_slips': { + int(interface): [float(value) for value in interface_shift] + for interface, interface_shift in metadata['interface_slips'].items() + }, + 'direction_name': direction_name, + 'step_index': int(step_index), + }) + + return normalized + + raise TypeError('Unsupported faulted structure payload returned by `FaultedStructureData`.') + + +def _format_spacing_key(vacuum_spacing: float) -> str: + """Return a Dict-safe key for a vacuum spacing.""" + return f'{vacuum_spacing:.6f}'.replace('.', '_') + + +@calcfunction +def generate_faulted_structures( + structure: orm.StructureData, + faulted_data: FaultedStructureData, + fault_mode: orm.Str, + fault_type: orm.Str, +) -> dict[str, orm.Data]: + """Generate provenance-tracked faulted structures from structure and faulted configuration.""" + builder = faulted_data.get_structure_builder(structure) + generated = builder.get_faulted_structure( + fault_mode=fault_mode.value, + fault_type=fault_type.value, + ) + + normalized_points = _normalize_faulted_structure_points(generated, fault_type.value) + if not normalized_points: + raise ValueError('No faulted structures could be generated for the requested configuration.') + + outputs: dict[str, orm.Data] = { + 'conventional_structure': orm.StructureData(ase=builder.get_conventional_structure()), + 'surface_area': orm.Float(float(builder.surface_area)), + } + + for point in normalized_points: + key = point['label'] + structure_node = orm.StructureData(ase=point['structure']) + structure_node.label = key + structure_node.base.extras.set_many({ + 'label': key, + 'direction_name': point['direction_name'], + 'step_index': int(point['step_index']), + 'burger_vector': [float(value) for value in point['burger_vector']], + 'total_cell_shift': [float(value) for value in point['total_cell_shift']], + 'interface_slips': { + str(interface): [float(value) for value in interface_shift] + for interface, interface_shift in point['interface_slips'].items() + }, + }) + outputs[key] = structure_node + + return outputs + + +@calcfunction +def generate_cleavaged_structures( + structure: orm.StructureData, + cleavaged_data: CleavagedStructureData, +) -> dict[str, orm.Data]: + """Generate provenance-tracked slab structures from primitive structure and cleavaged configuration.""" + builder = cleavaged_data.get_structure_builder(structure) + vacuum_spacings = cleavaged_data.vacuum_spacings + + if not vacuum_spacings: + raise ValueError('No vacuum spacings configured for cleavaged structure generation.') + + outputs: dict[str, orm.Data] = { + 'conventional_structure': orm.StructureData(ase=builder.get_conventional_structure()), + 'surface_area': orm.Float(float(builder.surface_area)), + } + + for vacuum_spacing in vacuum_spacings: + spacing_key = _format_spacing_key(float(vacuum_spacing)) + slab_key = f'slab_{spacing_key}' + spacing_output_key = f'vacuum_spacing_{spacing_key}' + + if slab_key in outputs or spacing_output_key in outputs: + raise ValueError(f'Duplicate vacuum spacing key generated for {vacuum_spacing}.') + + outputs[spacing_output_key] = orm.Float(float(vacuum_spacing)) + outputs[slab_key] = orm.StructureData( + ase=builder.get_cleavaged_structure(vacuum_spacing=vacuum_spacing) + ) + + return outputs From d7aff540c6788dca01f59397a5d0d5fd20958e4d Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 16:26:25 +0200 Subject: [PATCH 16/17] feat: port ESFE, GSFE, ISFE, USFE, twinning, and relaxation workflows and protocol YAMLs --- .../workflows/dislocation/__init__.py | 35 ++ .../workflows/dislocation/esfe.py | 27 + .../workflows/dislocation/gsfe.py | 549 +++++++++++++++++ .../workflows/dislocation/gsfe_relax.py | 569 ++++++++++++++++++ .../workflows/dislocation/isfe.py | 26 + .../workflows/dislocation/layer_relax.py | 284 +++++++++ .../workflows/dislocation/mixins.py | 250 ++++++++ .../dislocation/protocols/__init__.py | 0 .../workflows/dislocation/protocols/esfe.yaml | 44 ++ .../workflows/dislocation/protocols/gsfe.yaml | 38 ++ .../dislocation/protocols/gsfe_relax.yaml | 38 ++ .../workflows/dislocation/protocols/isfe.yaml | 44 ++ .../dislocation/protocols/layer_relax.yaml | 30 + .../dislocation/protocols/sfebase.yaml | 44 ++ .../dislocation/protocols/surface.yaml | 40 ++ .../dislocation/protocols/twinning.yaml | 39 ++ .../workflows/dislocation/protocols/usfe.yaml | 52 ++ .../workflows/dislocation/sfebase.py | 560 +++++++++++++++++ .../workflows/dislocation/surface.py | 504 ++++++++++++++++ .../workflows/dislocation/twinning.py | 169 ++++++ .../workflows/dislocation/usfe.py | 41 ++ 21 files changed, 3383 insertions(+) create mode 100644 src/aiida_mechanical/workflows/dislocation/__init__.py create mode 100644 src/aiida_mechanical/workflows/dislocation/esfe.py create mode 100644 src/aiida_mechanical/workflows/dislocation/gsfe.py create mode 100644 src/aiida_mechanical/workflows/dislocation/gsfe_relax.py create mode 100644 src/aiida_mechanical/workflows/dislocation/isfe.py create mode 100644 src/aiida_mechanical/workflows/dislocation/layer_relax.py create mode 100644 src/aiida_mechanical/workflows/dislocation/mixins.py create mode 100644 src/aiida_mechanical/workflows/dislocation/protocols/__init__.py create mode 100644 src/aiida_mechanical/workflows/dislocation/protocols/esfe.yaml create mode 100644 src/aiida_mechanical/workflows/dislocation/protocols/gsfe.yaml create mode 100644 src/aiida_mechanical/workflows/dislocation/protocols/gsfe_relax.yaml create mode 100644 src/aiida_mechanical/workflows/dislocation/protocols/isfe.yaml create mode 100644 src/aiida_mechanical/workflows/dislocation/protocols/layer_relax.yaml create mode 100644 src/aiida_mechanical/workflows/dislocation/protocols/sfebase.yaml create mode 100644 src/aiida_mechanical/workflows/dislocation/protocols/surface.yaml create mode 100644 src/aiida_mechanical/workflows/dislocation/protocols/twinning.yaml create mode 100644 src/aiida_mechanical/workflows/dislocation/protocols/usfe.yaml create mode 100644 src/aiida_mechanical/workflows/dislocation/sfebase.py create mode 100644 src/aiida_mechanical/workflows/dislocation/surface.py create mode 100644 src/aiida_mechanical/workflows/dislocation/twinning.py create mode 100644 src/aiida_mechanical/workflows/dislocation/usfe.py diff --git a/src/aiida_mechanical/workflows/dislocation/__init__.py b/src/aiida_mechanical/workflows/dislocation/__init__.py new file mode 100644 index 0000000..b33aacb --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/__init__.py @@ -0,0 +1,35 @@ +from .isfe import ( + ISFEWorkChain, +) +from .esfe import ( + ESFEWorkChain, +) +from .usfe import ( + USFEWorkChain, +) +from .twinning import ( + TwinningWorkChain, +) +from .gsfe import ( + GSFEWorkChain, +) +from .gsfe_relax import ( + GSFERelaxWorkChain, +) +from .layer_relax import ( + RigidLayerRelaxWorkChain, +) +from .surface import ( + SurfaceEnergyWorkChain, +) + +__all__ = ( + 'ISFEWorkChain', + 'ESFEWorkChain', + 'USFEWorkChain', + 'TwinningWorkChain', + 'GSFEWorkChain', + 'GSFERelaxWorkChain', + 'RigidLayerRelaxWorkChain', + 'SurfaceEnergyWorkChain', +) \ No newline at end of file diff --git a/src/aiida_mechanical/workflows/dislocation/esfe.py b/src/aiida_mechanical/workflows/dislocation/esfe.py new file mode 100644 index 0000000..c49c378 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/esfe.py @@ -0,0 +1,27 @@ +from .sfebase import SFEBaseWorkChain +from .layer_relax import RigidLayerRelaxWorkChain +from aiida import orm + +class ESFEWorkChain(SFEBaseWorkChain): + """ESFE WorkChain""" + + _SFE_NAMESPACE = "esfe" + + @classmethod + def define(cls, spec): + super().define(spec) + + spec.exit_code( + 403, + "ERROR_SUB_PROCESS_FAILED_ESF", + message='The `PwBaseWorkChain` for the ESF run failed.', + ) + + + def _get_fault_type(self): + """Return the fault type for ESFE workchain.""" + return 'extrinsic' + + def results(self): + """Expose collected ESFE data to the caller.""" + pass diff --git a/src/aiida_mechanical/workflows/dislocation/gsfe.py b/src/aiida_mechanical/workflows/dislocation/gsfe.py new file mode 100644 index 0000000..115d4d2 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/gsfe.py @@ -0,0 +1,549 @@ +from __future__ import annotations + +import typing as ty + +from aiida import orm +from aiida.common import AttributeDict +from aiida.engine import ExitCode, WorkChain, append_, if_, while_ +from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import ( + create_kpoints_from_distance, +) + +from aiida_quantumespresso.workflows.protocols.utils import ProtocolMixin + +from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain +from aiida_quantumespresso.workflows.pw.relax import PwRelaxWorkChain + +from aiida_mechanical.calculations import generate_faulted_structures +from aiida_mechanical.data.faulted_structure import FaultedStructureData + +from .mixins import ( + StructureGenerationMixin, + EnergyCalculationMixin, + KpointsSetupMixin, + WorkflowInspectionMixin, + clean_workchain_calcs, +) + +class GSFEWorkChain( + ProtocolMixin, + StructureGenerationMixin, + EnergyCalculationMixin, + KpointsSetupMixin, + WorkflowInspectionMixin, + WorkChain): + """GSFE WorkChain""" + + _NAMESPACE = 'gsfe' + + _RELAX_NAMESPACE = "relax" + _SCF_NAMESPACE = "scf" + _SFE_NAMESPACE = "sfe" + _SURFACE_ENERGY_NAMESPACE = "surface_energy" + + _RY2eV = 13.605693122990 + _eVA22Jm2 = 1.602176634E-19 * 1E+20 + + @classmethod + def define(cls, spec) -> None: + super().define(spec) + + spec.input('structure', valid_type=orm.StructureData, required=True,) + spec.input( + 'faulted_structure_data', + valid_type=FaultedStructureData, + required=False, + default=lambda: FaultedStructureData(n_unit_cells=4), + help='Configuration for GSFE faulted-structure generation.', + ) + spec.input('kpoints_distance', valid_type=orm.Float, required=False, default=lambda: orm.Float(0.3), + help='The distance between kpoints for the kpoints generation') + spec.input('clean_workdir', valid_type=orm.Bool, default=lambda: orm.Bool(False), + help='If `True`, work directories of all called calculation will be cleaned at the end of execution.') + + spec.expose_inputs( + PwRelaxWorkChain, + namespace=cls._RELAX_NAMESPACE, + exclude=( + 'structure', + 'clean_workdir', + 'kpoints', + 'kpoints_distance', + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwRelaxWorkChain`.' + } + ) + + spec.expose_inputs( + PwBaseWorkChain, + namespace=cls._SCF_NAMESPACE, + exclude=( + 'pw.structure', + 'clean_workdir', + 'kpoints', + 'kpoints_distance', + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwBaseWorkChain` for SCF calculation.' + } + ) + spec.expose_inputs( + PwBaseWorkChain, + namespace=cls._SFE_NAMESPACE, + exclude=( + 'pw.structure', + 'clean_workdir', + 'kpoints', + 'kpoints_distance', + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwBaseWorkChain` for USF calculation.' + } + ) + + spec.outline( + if_(cls.should_run_relax)( + cls.run_relax, + cls.inspect_relax, + ), + cls.generate_structures, + cls.setup, + if_(cls.should_run_scf)( + cls.run_scf, + cls.inspect_scf, + ), + while_(cls.should_run_sfe)( + cls.run_sfe, + cls.inspect_sfe, + ), + cls.results, + ) + spec.expose_outputs( + PwRelaxWorkChain, + namespace=cls._RELAX_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + spec.expose_outputs( + PwBaseWorkChain, + namespace=cls._SCF_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + spec.output( + 'results', + valid_type=orm.Dict, + required=False, + help='Aggregated GSFE results for all evaluated faulted structures.', + ) + + spec.exit_code( + 401, + "ERROR_SUB_PROCESS_FAILED_RELAX", + message='The `PwBaseWorkChain` for the GSF run failed.', + ) + + spec.exit_code( + 402, + "ERROR_SUB_PROCESS_FAILED_SCF", + message='The `PwBaseWorkChain` for the USF run failed.', + ) + spec.exit_code( + 403, + "ERROR_SUB_PROCESS_FAILED_USF", + message='The `PwBaseWorkChain` for the USF run failed.', + ) + spec.exit_code( + 405, + "ERROR_NO_STRUCTURE_TYPE_DETECTED", + message='The structure type is not detected.', + ) + + @classmethod + def get_protocol_filepath(cls): + """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" + from importlib_resources import files + from . import protocols + return files(protocols) / f'{cls._NAMESPACE}.yaml' + + @classmethod + def get_protocol_overrides(cls) -> dict[str, ty.Any]: + """Get the ``overrides`` of the default protocol.""" + from importlib_resources import files + import yaml + from . import protocols + + path = files(protocols) / f"{cls._NAMESPACE}.yaml" + with path.open() as file: + return yaml.safe_load(file) + + @classmethod + def get_builder_from_protocol( + cls, + code, + structure, + protocol='moderate', + overrides=None, + n_repeats: ty.Optional[int | orm.Int] = None, + gliding_plane: ty.Optional[str | orm.Str] = None, + **kwargs + ): + """Return a builder prepopulated with inputs selected according to the chosen protocol. + """ + inputs = cls.get_protocol_inputs(protocol, overrides) + args = (code, structure, protocol) + + builder = cls.get_builder() + + # Set up the sub-workchains + for namespace, workchain_type in [ + (cls._RELAX_NAMESPACE, PwRelaxWorkChain), + (cls._SCF_NAMESPACE, PwBaseWorkChain), + (cls._SFE_NAMESPACE, PwBaseWorkChain), + ]: + overrides = inputs.get(namespace, {}) + + if workchain_type == PwRelaxWorkChain: + overrides.setdefault('base_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + overrides.setdefault('base_init_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + else: + overrides['pseudo_family'] = inputs.get('pseudo_family', None) + + sub_builder = workchain_type.get_builder_from_protocol( + *args, + overrides=overrides, + ) + sub_builder.pop('structure', None) + sub_builder.pop('clean_workdir', None) + + if namespace != cls._RELAX_NAMESPACE: + sub_builder.pop('kpoints', None) + sub_builder.pop('kpoints_distance', None) + + builder[namespace]._data = sub_builder._data + + if cls._RELAX_NAMESPACE in builder: + builder[cls._RELAX_NAMESPACE].pop('base_init_relax', None) + if 'base_relax' in builder[cls._RELAX_NAMESPACE]: + builder[cls._RELAX_NAMESPACE]['base_relax'].pop('kpoints', None) + builder[cls._RELAX_NAMESPACE]['base_relax'].pop('kpoints_distance', None) + + builder.structure = structure + resolved_n_repeats = n_repeats.value if isinstance(n_repeats, orm.Int) else n_repeats + resolved_gliding_plane = gliding_plane.value if isinstance(gliding_plane, orm.Str) else gliding_plane + builder.faulted_structure_data = FaultedStructureData( + n_unit_cells=inputs.get('n_repeats', 4) if resolved_n_repeats is None else resolved_n_repeats, + gliding_plane=inputs.get('gliding_plane', '') if resolved_gliding_plane is None else resolved_gliding_plane, + ) + builder.kpoints_distance = orm.Float(inputs['kpoints_distance']) + builder.clean_workdir = orm.Bool(inputs['clean_workdir']) + + return builder + + + def should_run_relax(self) -> bool: + return self._RELAX_NAMESPACE in self.inputs + + def run_relax(self) -> dict[str, orm.ProcessNode]: + inputs = AttributeDict( + self.exposed_inputs( + PwRelaxWorkChain, + namespace=self._RELAX_NAMESPACE + ) + ) + inputs.metadata.call_link_label = self._RELAX_NAMESPACE + inputs.structure = self.inputs.structure + inputs.base_relax.kpoints_distance = self.inputs.kpoints_distance + running = self.submit(PwRelaxWorkChain, **inputs) + self.report(f'launching PwRelaxWorkChain<{running.pk}> for primitive structure') + return {f"workchain_relax": running} + + def inspect_relax(self) -> ty.Optional[ExitCode]: + workchain = self.ctx.workchain_relax + if not workchain.is_finished_ok: + self.report(f'PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_RELAX + + self.report(f'PwRelaxWorkChain<{self.ctx.workchain_relax.pk}> finished') + + self.ctx.current_structure = workchain.outputs.output_structure + + # Expose outputs + self.out_many( + self.exposed_outputs( + workchain, + PwRelaxWorkChain, + namespace=self._RELAX_NAMESPACE + ) + ) + + def generate_structures(self) -> ty.Optional[ExitCode]: + """Generate provenance-tracked structures for GSFE calculations.""" + + if 'current_structure' not in self.ctx: + self.ctx.current_structure = self.inputs.structure + + try: + generated_structures = generate_faulted_structures( + structure=self.ctx.current_structure, + faulted_data=self.inputs.faulted_structure_data, + fault_mode=orm.Str('general'), + fault_type=orm.Str('general'), + ) + except ValueError as exception: + self.report(f'Failed to generate GSFE structures: {exception}') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + self.ctx.generated_structures = [] + + for output_label, output_node in generated_structures.items(): + if output_label in ('conventional_structure', 'surface_area') or not isinstance(output_node, orm.StructureData): + continue + + direction_name = output_node.base.extras.get('direction_name', None) + step_index = output_node.base.extras.get('step_index', None) + burger_vector = output_node.base.extras.get('burger_vector', None) + total_cell_shift = output_node.base.extras.get('total_cell_shift', None) + interface_slips = output_node.base.extras.get('interface_slips', None) + + if None in (direction_name, step_index, burger_vector, total_cell_shift, interface_slips): + self.report(f'Incomplete faulted-structure entry generated for `{output_label}`.') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + self.ctx.generated_structures.append({ + 'structure_key': output_label, + 'structure': output_node, + 'direction_name': str(direction_name), + 'step_index': int(step_index), + 'burger_vector': [float(value) for value in burger_vector], + 'total_cell_shift': [float(value) for value in total_cell_shift], + 'interface_slips': { + str(interface): [float(value) for value in interface_shift] + for interface, interface_shift in interface_slips.items() + }, + }) + + self.ctx.generated_structures.sort( + key=lambda entry: (entry['direction_name'], entry['step_index']) + ) + + if not self.ctx.generated_structures: + self.report('No generalized fault path is available for the selected structure and gliding plane.') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + self.ctx.number_of_structures = len(self.ctx.generated_structures) + self.ctx.conventional_structure = generated_structures['conventional_structure'] + self.ctx.surface_area = generated_structures['surface_area'].value + + self.report(f'Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2') + + self.ctx.unit_cell_multiplier = self._calculate_structure_multiplier(self.ctx.current_structure) + self.ctx.conventional_multiplier = self._calculate_structure_multiplier(self.ctx.conventional_structure) + + def _get_kpoints_scf(self) -> orm.KpointsData: + """Get or create kpoints_scf. Returns kpoints_scf KpointsData object.""" + if 'kpoints_scf' in self.ctx: + kpoints_scf = self.ctx.kpoints_scf + else: + inputs = { + 'structure': self.ctx.conventional_structure, + 'distance': self.inputs.kpoints_distance, + 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), + 'metadata': { + 'call_link_label': 'create_kpoints_from_distance' + } + } + kpoints_scf = create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg + + return kpoints_scf + + # def _get_kpoints_sfe(self) -> orm.KpointsData: + # """Get or create the shared k-point mesh for all generated GSFE structures.""" + # if 'kpoints_sfe' in self.ctx: + # return self.ctx.kpoints_sfe + + # first_faulted_structure = self.ctx.generated_structures[0]['structure'] + # inputs = { + # 'structure': first_faulted_structure, + # 'distance': self.inputs.kpoints_distance, + # 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), + # 'metadata': { + # 'call_link_label': 'create_kpoints_from_distance_sfe' + # } + # } + # return create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg + + def setup(self) -> None: + self.ctx.iteration = 0 + self.ctx.sfe_results = [] + self.ctx.kpoints_scf = self._get_kpoints_scf() + # self.ctx.kpoints_sfe = self._get_kpoints_sfe() + self.ctx.kpoints_sfe = self._calculate_kpoints_for_structure( + self.ctx.generated_structures[0]['structure'], + self.ctx.kpoints_scf, + ) + + def should_run_scf(self) -> bool: + return self._SCF_NAMESPACE in self.inputs + + def run_scf(self) -> dict[str, orm.ProcessNode]: + inputs = AttributeDict( + self.exposed_inputs( + PwBaseWorkChain, + namespace=self._SCF_NAMESPACE + ) + ) + + inputs.metadata.call_link_label = self._SCF_NAMESPACE + + inputs.pw.structure = self.ctx.conventional_structure + inputs.kpoints = self.ctx.kpoints_scf + + running = self.submit(PwBaseWorkChain, **inputs) + self.report(f'launching PwBaseWorkChain<{running.pk}> for conventional structure') + + return {f"workchain_scf": running} + + def inspect_scf(self) -> ty.Optional[ExitCode]: + workchain = self.ctx.workchain_scf + if not workchain.is_finished_ok: + self.report(f'PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SCF + + self.report(f'PwBaseWorkChain<{self.ctx.workchain_scf.pk}> finished') + + # Expose outputs + self.out_many( + self.exposed_outputs( + workchain, + PwBaseWorkChain, + namespace=self._SCF_NAMESPACE + ) + ) + self.ctx.total_energy_conventional_geometry = self._get_workchain_energy(workchain) + + def should_run_sfe(self) -> bool: + + if self._SFE_NAMESPACE not in self.inputs: + return False + + if self.ctx.iteration >= self.ctx.number_of_structures: + return False + + current_entry = self.ctx.generated_structures[self.ctx.iteration] + self.ctx.current_structure_key = current_entry['structure_key'] + self.ctx.current_structure = current_entry['structure'] + self.ctx.current_direction_name = current_entry['direction_name'] + self.ctx.current_step_index = current_entry['step_index'] + self.ctx.current_burger_vector = current_entry['burger_vector'] + self.ctx.current_total_cell_shift = current_entry['total_cell_shift'] + self.ctx.current_interface_slips = current_entry['interface_slips'] + self.ctx.current_multiplier = self._calculate_structure_multiplier(self.ctx.current_structure) + + return True + + def run_sfe(self) -> dict[str, ty.Any]: + inputs = AttributeDict( + self.exposed_inputs( + PwBaseWorkChain, + namespace=self._SFE_NAMESPACE + ) + ) + inputs.metadata.call_link_label = self.ctx.current_structure_key + + inputs.pw.structure = self.ctx.current_structure + inputs.kpoints = self.ctx.kpoints_sfe + + running = self.submit(PwBaseWorkChain, **inputs) + self.report( + f'launching PwBaseWorkChain<{running.pk}> for faulted structure ' + f'{self.ctx.iteration + 1}/{self.ctx.number_of_structures} ({self.ctx.current_structure_key}).' + ) + + return {f"workchain_sfe": append_(running)} + + def inspect_sfe(self) -> ty.Optional[ExitCode]: + workchain = self.ctx.workchain_sfe[-1] + if not workchain.is_finished_ok: + self.report(f'PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_USF + + self.report(f'PwBaseWorkChain<{workchain.pk}> finished') + + total_energy_faulted_geometry = self._get_workchain_energy(workchain) + gsfe_j_m2 = None + + if 'total_energy_conventional_geometry' in self.ctx: + gsfe_j_m2 = self._calculate_stacking_fault_energy( + total_energy_faulted_geometry, + self.ctx.current_multiplier, + 'generalized stacking fault' + ) + + self.ctx.sfe_results.append({ + 'label': self.ctx.current_structure_key, + 'structure_uuid': self.ctx.current_structure.uuid, + 'direction_name': self.ctx.current_direction_name, + 'step_index': self.ctx.current_step_index, + 'burger_vector': [float(value) for value in self.ctx.current_burger_vector], + 'total_cell_shift': [float(value) for value in self.ctx.current_total_cell_shift], + 'interface_slips': { + str(interface): [float(value) for value in interface_shift] + for interface, interface_shift in self.ctx.current_interface_slips.items() + }, + 'energy': float(total_energy_faulted_geometry), + 'sfe': float(gsfe_j_m2) if gsfe_j_m2 is not None else None, + 'workchain_uuid': workchain.uuid, + }) + self.ctx.iteration += 1 + + def results(self) -> None: + """Output collected results.""" + nested_results: dict[str, dict[str, dict[str, ty.Any]]] = {} + + for point_result in self.ctx.sfe_results: + direction_results = nested_results.setdefault(point_result['direction_name'], {}) + direction_results[str(point_result['step_index'])] = { + 'label': point_result['label'], + 'structure_uuid': point_result['structure_uuid'], + 'step_index': point_result['step_index'], + 'burger_vector': point_result['burger_vector'], + 'total_cell_shift': point_result['total_cell_shift'], + 'interface_slips': point_result['interface_slips'], + 'energy': point_result['energy'], + 'sfe': point_result['sfe'], + 'workchain_uuid': point_result['workchain_uuid'], + } + + results = { + 'results': nested_results, + 'surface_area_angstrom2': float(self.ctx.surface_area), + 'number_of_structures': self.ctx.number_of_structures, + } + + if 'total_energy_conventional_geometry' in self.ctx: + results['conventional_energy_ev'] = float(self.ctx.total_energy_conventional_geometry) + + self.out('results', orm.Dict(dict=results).store()) + + def on_terminated(self) -> None: + """Clean child calculation working directories if ``clean_workdir`` is enabled.""" + super().on_terminated() + + if self.inputs.clean_workdir.value is False: + self.report('remote folders will not be cleaned') + return + + cleaned_calcs = clean_workchain_calcs(self.node) + + if cleaned_calcs: + self.report(f'cleaned remote folders of calculations: {" ".join(map(str, cleaned_calcs))}') diff --git a/src/aiida_mechanical/workflows/dislocation/gsfe_relax.py b/src/aiida_mechanical/workflows/dislocation/gsfe_relax.py new file mode 100644 index 0000000..ee1c332 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/gsfe_relax.py @@ -0,0 +1,569 @@ +from __future__ import annotations + +import typing as ty +import numpy + +from aiida import orm +from aiida.common import AttributeDict +from aiida.engine import ExitCode, WorkChain, append_, if_, while_ +from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import ( + create_kpoints_from_distance, +) + +from aiida_quantumespresso.workflows.protocols.utils import ProtocolMixin + +from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain +from aiida_quantumespresso.workflows.pw.relax import PwRelaxWorkChain + +from aiida_mechanical.calculations import generate_faulted_structures +from aiida_mechanical.data.faulted_structure import FaultedStructureData + +from .mixins import ( + StructureGenerationMixin, + EnergyCalculationMixin, + KpointsSetupMixin, + WorkflowInspectionMixin, + clean_workchain_calcs, +) + +class GSFERelaxWorkChain( + ProtocolMixin, + StructureGenerationMixin, + EnergyCalculationMixin, + KpointsSetupMixin, + WorkflowInspectionMixin, + WorkChain): + """GSFE WorkChain""" + + _NAMESPACE = 'gsfe_relax' + + _RELAX_NAMESPACE = "relax" + _SCF_NAMESPACE = "scf" + _SFE_NAMESPACE = "sfe" + _SURFACE_ENERGY_NAMESPACE = "surface_energy" + + _RY2eV = 13.605693122990 + _eVA22Jm2 = 1.602176634E-19 * 1E+20 + + @classmethod + def define(cls, spec) -> None: + super().define(spec) + + spec.input('structure', valid_type=orm.StructureData, required=True,) + spec.input( + 'faulted_structure_data', + valid_type=FaultedStructureData, + required=False, + default=lambda: FaultedStructureData(n_unit_cells=4), + help='Configuration for GSFE faulted-structure generation.', + ) + spec.input('kpoints_distance', valid_type=orm.Float, required=False, default=lambda: orm.Float(0.3), + help='The distance between kpoints for the kpoints generation') + spec.input('clean_workdir', valid_type=orm.Bool, default=lambda: orm.Bool(False), + help='If `True`, work directories of all called calculation will be cleaned at the end of execution.') + + spec.expose_inputs( + PwRelaxWorkChain, + namespace=cls._RELAX_NAMESPACE, + exclude=( + 'structure', + 'clean_workdir', + 'base_relax.kpoints', + 'base_relax.kpoints_distance', + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwRelaxWorkChain`.' + } + ) + + spec.expose_inputs( + PwBaseWorkChain, + namespace=cls._SCF_NAMESPACE, + exclude=( + 'pw.structure', + 'clean_workdir', + 'kpoints', + 'kpoints_distance', + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwBaseWorkChain` for SCF calculation.' + } + ) + spec.expose_inputs( + PwRelaxWorkChain, + namespace=cls._SFE_NAMESPACE, + exclude=( + 'structure', + 'clean_workdir', + 'base_relax.kpoints', + 'base_relax.kpoints_distance', + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwRelaxWorkChain` for SFE calculation.' + } + ) + + spec.outline( + if_(cls.should_run_relax)( + cls.run_relax, + cls.inspect_relax, + ), + cls.generate_structures, + cls.setup, + if_(cls.should_run_scf)( + cls.run_scf, + cls.inspect_scf, + ), + while_(cls.should_run_sfe)( + cls.run_sfe, + cls.inspect_sfe, + ), + cls.results, + ) + spec.expose_outputs( + PwRelaxWorkChain, + namespace=cls._RELAX_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + spec.expose_outputs( + PwBaseWorkChain, + namespace=cls._SCF_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + spec.output( + 'results', + valid_type=orm.Dict, + required=False, + help='Aggregated GSFE results for all evaluated faulted structures.', + ) + + spec.exit_code( + 401, + "ERROR_SUB_PROCESS_FAILED_RELAX", + message='The `PwRelaxWorkChain` for the relax run failed.', + ) + + spec.exit_code( + 402, + "ERROR_SUB_PROCESS_FAILED_SCF", + message='The `PwBaseWorkChain` for the SCF run failed.', + ) + spec.exit_code( + 403, + "ERROR_SUB_PROCESS_FAILED_SFE", + message='The `PwRelaxWorkChain` for the SFE run failed.', + ) + spec.exit_code( + 405, + "ERROR_NO_STRUCTURE_TYPE_DETECTED", + message='The structure type is not detected.', + ) + + @classmethod + def get_protocol_filepath(cls): + """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" + from importlib_resources import files + from . import protocols + return files(protocols) / f'{cls._NAMESPACE}.yaml' + + @classmethod + def get_protocol_overrides(cls) -> dict[str, ty.Any]: + """Get the ``overrides`` of the default protocol.""" + from importlib_resources import files + import yaml + from . import protocols + + path = files(protocols) / f"{cls._NAMESPACE}.yaml" + with path.open() as file: + return yaml.safe_load(file) + + @classmethod + def get_builder_from_protocol( + cls, + code, + structure, + protocol='moderate', + overrides=None, + n_repeats: ty.Optional[int | orm.Int] = None, + gliding_plane: ty.Optional[str | orm.Str] = None, + **kwargs + ): + """Return a builder prepopulated with inputs selected according to the chosen protocol. + """ + inputs = cls.get_protocol_inputs(protocol, overrides) + args = (code, structure, protocol) + + builder = cls.get_builder() + + # Set up the sub-workchains + for namespace, workchain_type in [ + (cls._RELAX_NAMESPACE, PwRelaxWorkChain), + (cls._SCF_NAMESPACE, PwBaseWorkChain), + (cls._SFE_NAMESPACE, PwRelaxWorkChain), + ]: + overrides = inputs.get(namespace, {}) + + if workchain_type == PwRelaxWorkChain: + overrides.setdefault('base_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + overrides.setdefault('base_init_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + else: + overrides['pseudo_family'] = inputs.get('pseudo_family', None) + + sub_builder = workchain_type.get_builder_from_protocol( + *args, + overrides=overrides, + ) + sub_builder.pop('structure', None) + sub_builder.pop('clean_workdir', None) + + if workchain_type == PwBaseWorkChain: + sub_builder.pop('kpoints', None) + sub_builder.pop('kpoints_distance', None) + + if workchain_type == PwRelaxWorkChain: + sub_builder.pop('base_init_relax', None) + if 'base_relax' in sub_builder: + sub_builder['base_relax'].pop('kpoints', None) + sub_builder['base_relax'].pop('kpoints_distance', None) + + + builder[namespace]._data = sub_builder._data + + builder.structure = structure + resolved_n_repeats = n_repeats.value if isinstance(n_repeats, orm.Int) else n_repeats + resolved_gliding_plane = gliding_plane.value if isinstance(gliding_plane, orm.Str) else gliding_plane + builder.faulted_structure_data = FaultedStructureData( + n_unit_cells=inputs.get('n_repeats', 4) if resolved_n_repeats is None else resolved_n_repeats, + gliding_plane=inputs.get('gliding_plane', '') if resolved_gliding_plane is None else resolved_gliding_plane, + ) + builder.kpoints_distance = orm.Float(inputs['kpoints_distance']) + builder.clean_workdir = orm.Bool(inputs['clean_workdir']) + + return builder + + + def should_run_relax(self) -> bool: + return self._RELAX_NAMESPACE in self.inputs + + def run_relax(self) -> dict[str, orm.ProcessNode]: + inputs = AttributeDict( + self.exposed_inputs( + PwRelaxWorkChain, + namespace=self._RELAX_NAMESPACE + ) + ) + inputs.metadata.call_link_label = self._RELAX_NAMESPACE + inputs.structure = self.inputs.structure + inputs.base_relax.kpoints_distance = self.inputs.kpoints_distance + running = self.submit(PwRelaxWorkChain, **inputs) + self.report(f'launching PwRelaxWorkChain<{running.pk}> for primitive structure') + return {f"workchain_relax": running} + + def inspect_relax(self) -> ty.Optional[ExitCode]: + workchain = self.ctx.workchain_relax + if not workchain.is_finished_ok: + self.report(f'PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_RELAX + + self.report(f'PwRelaxWorkChain<{self.ctx.workchain_relax.pk}> finished') + + self.ctx.current_structure = workchain.outputs.output_structure + + # Expose outputs + self.out_many( + self.exposed_outputs( + workchain, + PwRelaxWorkChain, + namespace=self._RELAX_NAMESPACE + ) + ) + + def generate_structures(self) -> ty.Optional[ExitCode]: + """Generate provenance-tracked structures for GSFE calculations.""" + + if 'current_structure' not in self.ctx: + self.ctx.current_structure = self.inputs.structure + + try: + generated_structures = generate_faulted_structures( + structure=self.ctx.current_structure, + faulted_data=self.inputs.faulted_structure_data, + fault_mode=orm.Str('general'), + fault_type=orm.Str('general'), + ) + except ValueError as exception: + self.report(f'Failed to generate GSFE structures: {exception}') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + self.ctx.generated_structures = [] + + for output_label, output_node in generated_structures.items(): + if output_label in ('conventional_structure', 'surface_area') or not isinstance(output_node, orm.StructureData): + continue + + direction_name = output_node.base.extras.get('direction_name', None) + step_index = output_node.base.extras.get('step_index', None) + burger_vector = output_node.base.extras.get('burger_vector', None) + total_cell_shift = output_node.base.extras.get('total_cell_shift', None) + interface_slips = output_node.base.extras.get('interface_slips', None) + + if None in (direction_name, step_index, burger_vector, total_cell_shift, interface_slips): + self.report(f'Incomplete faulted-structure entry generated for `{output_label}`.') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + self.ctx.generated_structures.append({ + 'structure_key': output_label, + 'structure': output_node, + 'direction_name': str(direction_name), + 'step_index': int(step_index), + 'burger_vector': [float(value) for value in burger_vector], + 'total_cell_shift': [float(value) for value in total_cell_shift], + 'interface_slips': { + str(interface): [float(value) for value in interface_shift] + for interface, interface_shift in interface_slips.items() + }, + }) + + self.ctx.generated_structures.sort( + key=lambda entry: (entry['direction_name'], entry['step_index']) + ) + + if not self.ctx.generated_structures: + self.report('No generalized fault path is available for the selected structure and gliding plane.') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + self.ctx.number_of_structures = len(self.ctx.generated_structures) + self.ctx.conventional_structure = generated_structures['conventional_structure'] + self.ctx.surface_area = generated_structures['surface_area'].value + + self.report(f'Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2') + + self.ctx.unit_cell_multiplier = self._calculate_structure_multiplier(self.ctx.current_structure) + self.ctx.conventional_multiplier = self._calculate_structure_multiplier(self.ctx.conventional_structure) + + def _get_kpoints_scf(self) -> orm.KpointsData: + """Get or create kpoints_scf. Returns kpoints_scf KpointsData object.""" + if 'kpoints_scf' in self.ctx: + kpoints_scf = self.ctx.kpoints_scf + else: + inputs = { + 'structure': self.ctx.conventional_structure, + 'distance': self.inputs.kpoints_distance, + 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), + 'metadata': { + 'call_link_label': 'create_kpoints_from_distance' + } + } + kpoints_scf = create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg + + return kpoints_scf + + # def _get_kpoints_sfe(self) -> orm.KpointsData: + # """Get or create the shared k-point mesh for all generated GSFE structures.""" + # if 'kpoints_sfe' in self.ctx: + # return self.ctx.kpoints_sfe + + # first_faulted_structure = self.ctx.generated_structures[0]['structure'] + # inputs = { + # 'structure': first_faulted_structure, + # 'distance': self.inputs.kpoints_distance, + # 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), + # 'metadata': { + # 'call_link_label': 'create_kpoints_from_distance_sfe' + # } + # } + # return create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg + + def setup(self) -> None: + self.ctx.iteration = 0 + self.ctx.sfe_results = [] + self.ctx.kpoints_scf = self._get_kpoints_scf() + # self.ctx.kpoints_sfe = self._get_kpoints_sfe() + self.ctx.kpoints_sfe = self._calculate_kpoints_for_structure( + self.ctx.generated_structures[0]['structure'], + self.ctx.kpoints_scf, + ) + + def should_run_scf(self) -> bool: + return self._SCF_NAMESPACE in self.inputs + + def run_scf(self) -> dict[str, orm.ProcessNode]: + inputs = AttributeDict( + self.exposed_inputs( + PwBaseWorkChain, + namespace=self._SCF_NAMESPACE + ) + ) + + inputs.metadata.call_link_label = self._SCF_NAMESPACE + + inputs.pw.structure = self.ctx.conventional_structure + inputs.kpoints = self.ctx.kpoints_scf + + running = self.submit(PwBaseWorkChain, **inputs) + self.report(f'launching PwBaseWorkChain<{running.pk}> for conventional structure') + + return {f"workchain_scf": running} + + def inspect_scf(self) -> ty.Optional[ExitCode]: + workchain = self.ctx.workchain_scf + if not workchain.is_finished_ok: + self.report(f'PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SCF + + self.report(f'PwBaseWorkChain<{self.ctx.workchain_scf.pk}> finished') + + # Expose outputs + self.out_many( + self.exposed_outputs( + workchain, + PwBaseWorkChain, + namespace=self._SCF_NAMESPACE + ) + ) + self.ctx.total_energy_conventional_geometry = self._get_workchain_energy(workchain) + + def should_run_sfe(self) -> bool: + + if self._SFE_NAMESPACE not in self.inputs: + return False + + if self.ctx.iteration >= self.ctx.number_of_structures: + return False + + current_entry = self.ctx.generated_structures[self.ctx.iteration] + self.ctx.current_structure_key = current_entry['structure_key'] + self.ctx.current_structure = current_entry['structure'] + self.ctx.current_direction_name = current_entry['direction_name'] + self.ctx.current_step_index = current_entry['step_index'] + self.ctx.current_burger_vector = current_entry['burger_vector'] + self.ctx.current_total_cell_shift = current_entry['total_cell_shift'] + self.ctx.current_interface_slips = current_entry['interface_slips'] + self.ctx.current_multiplier = self._calculate_structure_multiplier(self.ctx.current_structure) + + return True + + def run_sfe(self) -> dict[str, ty.Any]: + inputs = AttributeDict( + self.exposed_inputs( + PwRelaxWorkChain, + namespace=self._SFE_NAMESPACE + ) + ) + inputs.metadata.call_link_label = self.ctx.current_structure_key + + inputs.structure = self.ctx.current_structure + inputs.base_relax.kpoints = self.ctx.kpoints_sfe + + parameters = inputs.base_relax.pw.parameters.get_dict() + parameters['CELL']['cell_dofree'] = 'z' + inputs.base_relax.pw.parameters = orm.Dict(parameters) + + # Apply fixed coordinates for relaxation + settings = inputs.base_relax.pw.settings.get_dict() + settings['USE_FRACTIONAL'] = False + + FIXED_COORDS = numpy.full_like( + self.ctx.current_structure.get_ase().get_positions(), + fill_value=True, + dtype=bool + ) + FIXED_COORDS[:, -1] = False + + settings['FIXED_COORDS'] = FIXED_COORDS.tolist() + inputs.base_relax.pw.settings = orm.Dict(settings) + + running = self.submit(PwRelaxWorkChain, **inputs) + self.report( + f'launching PwRelaxWorkChain<{running.pk}> for faulted structure ' + f'{self.ctx.iteration + 1}/{self.ctx.number_of_structures} ({self.ctx.current_structure_key}).' + ) + + return {f"workchain_sfe": append_(running)} + + def inspect_sfe(self) -> ty.Optional[ExitCode]: + workchain = self.ctx.workchain_sfe[-1] + if not workchain.is_finished_ok: + self.report(f'PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SFE + + self.report(f'PwRelaxWorkChain<{workchain.pk}> finished') + + total_energy_faulted_geometry = self._get_workchain_energy(workchain) + gsfe_j_m2 = None + + if 'total_energy_conventional_geometry' in self.ctx: + gsfe_j_m2 = self._calculate_stacking_fault_energy( + total_energy_faulted_geometry, + self.ctx.current_multiplier, + 'generalized stacking fault' + ) + + self.ctx.sfe_results.append({ + 'label': self.ctx.current_structure_key, + 'structure_uuid': self.ctx.current_structure.uuid, + 'direction_name': self.ctx.current_direction_name, + 'step_index': self.ctx.current_step_index, + 'burger_vector': [float(value) for value in self.ctx.current_burger_vector], + 'total_cell_shift': [float(value) for value in self.ctx.current_total_cell_shift], + 'interface_slips': { + str(interface): [float(value) for value in interface_shift] + for interface, interface_shift in self.ctx.current_interface_slips.items() + }, + 'energy': float(total_energy_faulted_geometry), + 'sfe': float(gsfe_j_m2) if gsfe_j_m2 is not None else None, + 'workchain_uuid': workchain.uuid, + }) + self.ctx.iteration += 1 + + def results(self) -> None: + """Output collected results.""" + nested_results: dict[str, dict[str, dict[str, ty.Any]]] = {} + + for point_result in self.ctx.sfe_results: + direction_results = nested_results.setdefault(point_result['direction_name'], {}) + direction_results[str(point_result['step_index'])] = { + 'label': point_result['label'], + 'structure_uuid': point_result['structure_uuid'], + 'step_index': point_result['step_index'], + 'burger_vector': point_result['burger_vector'], + 'total_cell_shift': point_result['total_cell_shift'], + 'interface_slips': point_result['interface_slips'], + 'energy': point_result['energy'], + 'sfe': point_result['sfe'], + 'workchain_uuid': point_result['workchain_uuid'], + } + + results = { + 'results': nested_results, + 'surface_area_angstrom2': float(self.ctx.surface_area), + 'number_of_structures': self.ctx.number_of_structures, + } + + if 'total_energy_conventional_geometry' in self.ctx: + results['conventional_energy_ev'] = float(self.ctx.total_energy_conventional_geometry) + + self.out('results', orm.Dict(dict=results).store()) + + def on_terminated(self) -> None: + """Clean child calculation working directories if ``clean_workdir`` is enabled.""" + super().on_terminated() + + if self.inputs.clean_workdir.value is False: + self.report('remote folders will not be cleaned') + return + + cleaned_calcs = clean_workchain_calcs(self.node) + + if cleaned_calcs: + self.report(f'cleaned remote folders of calculations: {" ".join(map(str, cleaned_calcs))}') diff --git a/src/aiida_mechanical/workflows/dislocation/isfe.py b/src/aiida_mechanical/workflows/dislocation/isfe.py new file mode 100644 index 0000000..f0f5320 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/isfe.py @@ -0,0 +1,26 @@ +from .sfebase import SFEBaseWorkChain +from .layer_relax import RigidLayerRelaxWorkChain +from aiida import orm + +class ISFEWorkChain(SFEBaseWorkChain): + """ISFE WorkChain""" + + _SFE_NAMESPACE = "isfe" + + @classmethod + def define(cls, spec): + super().define(spec) + + spec.exit_code( + 404, + "ERROR_SUB_PROCESS_FAILED_ISF", + message='The `PwBaseWorkChain` for the ISF run failed.', + ) + + def _get_fault_type(self): + """Return the fault type for ISFE workchain.""" + return 'intrinsic' + + def results(self): + """Expose collected ISFE data to the caller.""" + pass diff --git a/src/aiida_mechanical/workflows/dislocation/layer_relax.py b/src/aiida_mechanical/workflows/dislocation/layer_relax.py new file mode 100644 index 0000000..43b92dc --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/layer_relax.py @@ -0,0 +1,284 @@ +"""SFE Spacing WorkChain - handles looping over additional_spacings for SFE calculations.""" + +from aiida import orm +from aiida.common import AttributeDict +from aiida.engine import WorkChain, while_, append_ +from aiida_quantumespresso.workflows.pw.relax import PwRelaxWorkChain +from aiida_mechanical.tools import get_faulted_structure +from math import ceil +import numpy +from aiida_quantumespresso.workflows.protocols.utils import ProtocolMixin + +class RigidLayerRelaxWorkChain(ProtocolMixin, WorkChain): + """WorkChain for looping over additional_spacings and performing SFE calculations. + + This workchain handles: + - Looping over additional_spacings list + - For each spacing: generating faulted structure, setting up kpoints, running calculation + - Collecting results for all spacings + + It is designed to be called as a sub-workchain from RigidLayerWorkChain + or other workflows that need to perform SFE calculations for multiple spacings. + """ + + _NAMESPACE = 'layer_relax' + _RELAX_NAMESPACE = 'relax' + + @classmethod + def define(cls, spec): + super().define(spec) + + spec.input('structure', valid_type=orm.StructureData, required=True, + help='The conventional structure for generating faulted structures.') + spec.input('layer_spacings', valid_type=orm.List, required=True, + help='List of layer spacings to evaluate.') + spec.input('fault_type', valid_type=orm.Str, required=True, + help="Fault type: 'intrinsic', 'unstable', or 'extrinsic'.") + spec.input('fault_method', valid_type=orm.Str, required=False, + default=lambda: orm.Str('removal'), + help="Fault method: 'removal' or 'vacuum'.") + spec.input('vacuum_ratio', valid_type=orm.Float, required=False, + default=lambda: orm.Float(0.1), + help='Vacuum ratio when using vacuum method.') + spec.input('gliding_plane', valid_type=orm.Str, required=False, + help='Gliding plane direction.') + spec.input('n_repeats', valid_type=orm.Int, required=True, + help='Number of unit cells to repeat.') + spec.input('kpoints', valid_type=orm.KpointsData, required=True, + help='The kpoints mesh for the relaxation calculation.') + spec.input('clean_workdir', valid_type=orm.Bool, default=lambda: orm.Bool(False), + help='If `True`, work directories of all called calculation will be cleaned at the end of execution.') + + spec.expose_inputs( + PwRelaxWorkChain, + namespace=cls._RELAX_NAMESPACE, + exclude=('structure', 'clean_workdir', 'kpoints', 'kpoints_distance'), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwRelaxWorkChain`.' + } + ) + + + spec.outline( + cls.setup, + while_(cls.should_run_relax)( + cls.setup_supercell_kpoints, + cls.run_relax, + cls.inspect_relax, + ), + cls.results, + ) + + spec.expose_outputs( + PwRelaxWorkChain, + namespace=cls._RELAX_NAMESPACE, + namespace_options={'required': False} + ) + + spec.exit_code( + 400, + 'ERROR_SUB_PROCESS_FAILED', + message='The sub-process failed.', + ) + + + @classmethod + def get_protocol_filepath(cls): + """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" + from importlib_resources import files + from . import protocols + return files(protocols) / f'{cls._NAMESPACE}.yaml' + + @classmethod + def get_protocol_overrides(cls) -> dict: + """Get the ``overrides`` of the default protocol.""" + from importlib_resources import files + import yaml + from . import protocols + + path = files(protocols) / f"{cls._NAMESPACE}.yaml" + with path.open() as file: + return yaml.safe_load(file) + + @classmethod + def get_builder_from_protocol( + cls, + code, + structure, + protocol='moderate', + overrides=None, + **kwargs + ): + """Return a builder prepopulated with inputs selected according to the chosen protocol. + """ + inputs = cls.get_protocol_inputs(protocol, overrides) + args = (code, structure, protocol) + + builder = cls.get_builder() + + # Set up the sub-workchains + + sub_builder = PwRelaxWorkChain.get_builder_from_protocol( + *args, + overrides=inputs.get(cls._RELAX_NAMESPACE, {}), + ) + # sub_builder.pop('structure', None) + sub_builder.pop('clean_workdir', None) + sub_builder.pop('kpoints', None) + sub_builder.pop('kpoints_distance', None) + + sub_builder['base_relax'].pop('kpoints', None) + sub_builder['base_relax'].pop('kpoints_distance', None) + sub_builder.pop('base_init_relax', None) + + builder[cls._RELAX_NAMESPACE]._data = sub_builder._data + + builder.structure = structure + builder.clean_workdir = orm.Bool(inputs['clean_workdir']) + return builder + + def setup(self): + """Initialize context for spacing loop.""" + self.ctx.iteration = 1 + self.ctx.layer_spacings = self.inputs.layer_spacings.get_list().copy() + + def should_run_relax(self): + """Check if there are more spacings to process.""" + if self.ctx.layer_spacings == []: + return False + + # Get current spacing + current_spacing = self.ctx.layer_spacings.pop(0) + self.ctx.current_spacing = current_spacing + + # Generate faulted structure for this spacing + fault_type = self.inputs.fault_type.value + fault_method = self.inputs.fault_method.value.lower() if self.inputs.fault_method.value else 'removal' + gliding_plane = self.inputs.gliding_plane.value if self.inputs.gliding_plane.value else None + + if fault_method == 'removal': + _, faulted_structure_data = get_faulted_structure( + self.inputs.structure.get_ase(), + fault_type=fault_type, + additional_spacing=current_spacing, + gliding_plane=gliding_plane, + n_unit_cells=self.inputs.n_repeats.value, + fault_mode='removal', + ) + elif fault_method == 'vacuum': + vacuum_ratio = float(self.inputs.vacuum_ratio.value) + _, faulted_structure_data = get_faulted_structure( + self.inputs.structure.get_ase(), + fault_type=fault_type, + additional_spacing=current_spacing, + gliding_plane=gliding_plane, + n_unit_cells=self.inputs.n_repeats.value, + fault_mode='vacuum', + vacuum_ratio=vacuum_ratio, + ) + else: + raise ValueError(f"Unsupported fault method: {fault_method}") + + # Validate structure + if faulted_structure_data is None or not faulted_structure_data.get('structures'): + self.report(f'Faulted structure not available for spacing {current_spacing}. Skipping.') + return False + + # Extract structure + actual_structure = faulted_structure_data['structures'][0].get('structure') + if actual_structure is None: + self.report(f'Faulted structure is missing for spacing {current_spacing}. Skipping.') + return False + + # Store structure and calculate multiplier + self.ctx.current_structure_ase = actual_structure + self.ctx.current_structure = orm.StructureData(ase=actual_structure) + + # Calculate multiplier + from ase.formula import Formula + formula = Formula(actual_structure.get_chemical_formula()) + _, multiplier = formula.reduce() + self.ctx.current_multiplier = multiplier + + return True + + def setup_supercell_kpoints(self): + """Setup kpoints for the current rigid layer structure.""" + # Calculate kpoints based on z-ratio between faulted and conventional structures + + z_ratio = self.ctx.current_structure.get_ase().cell.cellpar()[2] / self.inputs.structure.get_ase().cell.cellpar()[2] + kpoints_mesh = self.inputs.kpoints.get_kpoints_mesh()[0] + + kpoints_relax = orm.KpointsData() + kpoints_relax.set_kpoints_mesh(kpoints_mesh[:2] + [ceil(kpoints_mesh[2] / z_ratio)]) + + self.ctx.kpoints_relax = kpoints_relax + self.report(f'Kpoints mesh for rigid layer relaxation (spacing {self.ctx.current_spacing}): {kpoints_relax.get_kpoints_mesh()[0]}') + + def run_relax(self): + """Run the rigid layer relaxation calculation for current spacing.""" + inputs = AttributeDict( + self.exposed_inputs( + PwRelaxWorkChain, + namespace=self._RELAX_NAMESPACE + ) + ) + + inputs.structure = self.ctx.current_structure + inputs.base_relax.kpoints = self.ctx.kpoints_relax + inputs.metadata.call_link_label = f'relax_{self.ctx.iteration}' + + # Apply fault_method specific settings + fault_method = self.inputs.fault_method.value.lower() if self.inputs.fault_method.value else 'removal' + parameters = inputs.base_relax.pw.parameters.get_dict() + + if fault_method == 'vacuum': + parameters['CELL']['cell_dofree'] = 'fixc' + + if hasattr(self.ctx, 'nbnd') and self.ctx.nbnd: + parameters['SYSTEM']['nbnd'] = int(self.ctx.nbnd) + + inputs.base_relax.pw.parameters = orm.Dict(parameters) + + # Apply fixed coordinates for relaxation + settings = inputs.base_relax.pw.settings.get_dict() + settings['USE_FRACTIONAL'] = True + + FIXED_COORDS = numpy.full_like( + self.ctx.current_structure.get_ase().get_positions(), + fill_value=True, + dtype=bool + ) + settings['FIXED_COORDS'] = FIXED_COORDS.tolist() + inputs.base_relax.pw.settings = orm.Dict(settings) + + running = self.submit(PwRelaxWorkChain, **inputs) + self.report(f'launching PwRelaxWorkChain<{running.pk}> for spacing: {self.ctx.current_spacing}.') + + return {f"workchain_relax": append_(running)} + + def inspect_relax(self): + """Inspect the rigid layer relaxation calculation results for current spacing.""" + workchain = self.ctx.workchain_relax[-1] + self.ctx.iteration += 1 + + if not workchain.is_finished_ok: + self.report( + f"PwRelaxWorkChain<{workchain.pk}> for spacing {self.ctx.current_spacing} " + f"failed with exit status {workchain.exit_status}" + ) + return self.exit_codes.ERROR_SUB_PROCESS_FAILED + + self.report(f'PwRelaxWorkChain<{workchain.pk}> for spacing {self.ctx.current_spacing} finished successfully.') + + # Extract number of bands for next iteration + if 'output_parameters' in workchain.outputs: + self.ctx.nbnd = workchain.outputs.output_parameters.get('number_of_bands') + + + + def results(self): + """Output collected results.""" + pass diff --git a/src/aiida_mechanical/workflows/dislocation/mixins.py b/src/aiida_mechanical/workflows/dislocation/mixins.py new file mode 100644 index 0000000..56bdadf --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/mixins.py @@ -0,0 +1,250 @@ +"""Mixins and helper classes for workflow organization.""" + +from __future__ import annotations + +import typing as ty + +from aiida import orm +from aiida.engine import ExitCode +from ase import Atoms +from ase.formula import Formula +from math import ceil + + +class StructureGenerationMixin: + """Mixin for structure generation related methods.""" + + @staticmethod + def _ensure_ase_structure(structure: orm.StructureData | Atoms) -> Atoms: + """Return an ASE structure from either ASE Atoms or `StructureData`.""" + return structure.get_ase() if isinstance(structure, orm.StructureData) else structure + + def _calculate_structure_multiplier(self, structure: orm.StructureData | Atoms) -> int: + """Calculate the multiplier for a given structure. + + :param structure: ASE Atoms object + :return: multiplier value + """ + ase_structure = self._ensure_ase_structure(structure) + formula = Formula(ase_structure.get_chemical_formula()) + _, multiplier = formula.reduce() + return multiplier + + def _store_structure_multiplier(self, structure: orm.StructureData | Atoms, multiplier_name: str) -> int: + """Store structure and its multiplier in context. + + :param structure: ASE Atoms object + :param multiplier_name: Name for the multiplier in context (e.g., 'intrinsic_multiplier') + """ + multiplier = self._calculate_structure_multiplier(structure) + setattr(self.ctx, multiplier_name, multiplier) + return multiplier + + def _validate_faulted_structure( + self, + faulted_structure_data: ty.Optional[dict[str, ty.Any]], + fault_type: str, + ) -> tuple[bool, ty.Optional[ExitCode]]: + """Validate that a faulted structure was generated. + + :param faulted_structure_data: Result from get_faulted_structure + :param fault_type: Type of fault (for error messages) + :return: tuple (is_valid, error_code_or_none) + """ + if faulted_structure_data is None: + self.report(f'{fault_type.capitalize()} fault structure is not available for this gliding system.') + return False, self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + structures = faulted_structure_data.get('structures', []) + if not structures: + self.report(f'{fault_type.capitalize()} fault structure list is empty.') + return False, self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + first_entry = structures[0] + actual_structure = first_entry.get('structure') + + if actual_structure is None: + self.report(f'{fault_type.capitalize()} fault structure is missing structure data.') + return False, self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + return True, None + + +class EnergyCalculationMixin: + """Mixin for energy calculation related methods.""" + + def _get_physical_constant(self, name: str) -> float: + """Return a required physical constant from the workchain.""" + if not hasattr(self, name): + raise AttributeError(f'`{type(self).__name__}` must define `{name}` to use energy mixin helpers.') + return getattr(self, name) + + def _calculate_stacking_fault_energy( + self, + total_energy_faulted: float, + fault_multiplier: int, + fault_type_name: str + ) -> ty.Optional[float]: + """Calculate stacking fault energy from faulted and conventional geometries. + + :param total_energy_faulted: Total energy of faulted geometry + :param fault_multiplier: Multiplier for faulted structure + :param fault_type_name: Name of fault type (for reporting) + :return: Stacking fault energy in J/m^2 or None if conventional energy not available + """ + if 'total_energy_conventional_geometry' not in self.ctx: + return None + + energy_difference = ( + total_energy_faulted + - self.ctx.total_energy_conventional_geometry + / self.ctx.conventional_multiplier + * fault_multiplier + ) + stacking_fault_energy = energy_difference / self.ctx.surface_area * self._get_physical_constant('_eVA22Jm2') + + self.report( + f'{fault_type_name} stacking fault energy evaluated from conventional geometry: ' + f'{stacking_fault_energy} J/m^2' + ) + + return stacking_fault_energy + + def _calculate_surface_energy(self, total_energy_slab: float, surface_multiplier: int) -> ty.Optional[float]: + """Calculate a two-surface slab energy in J/m^2.""" + if 'total_energy_conventional_geometry' not in self.ctx: + return None + + energy_difference = ( + total_energy_slab + - self.ctx.total_energy_conventional_geometry + / self.ctx.conventional_multiplier + * surface_multiplier + ) + return energy_difference / (2 * self.ctx.surface_area) * self._get_physical_constant('_eVA22Jm2') + + def _report_energy( + self, + energy: float, + multiplier: int, + structure_type: str, + unit_cells_description: str, + ) -> None: + """Report energy in a consistent format. + + :param energy: Energy value + :param multiplier: Multiplier value + :param structure_type: Type of structure (for reporting) + :param unit_cells_description: Description of unit cells + """ + self.report( + f'Total energy of {structure_type} [{multiplier} {unit_cells_description}]: ' + f'{energy / self._get_physical_constant("_RY2eV")} Ry' + ) + + +class KpointsSetupMixin: + """Mixin for kpoints setup related methods.""" + + def _calculate_kpoints_for_structure( + self, + structure: orm.StructureData | Atoms, + kpoints_scf: orm.KpointsData, + ) -> orm.KpointsData: + """Calculate kpoints mesh for a given structure based on z-ratio. + + :param structure: ASE Atoms object + :param kpoints_scf_mesh: Base kpoints mesh from SCF calculation + :return: KpointsData object + """ + kpoints_scf_mesh = kpoints_scf.get_kpoints_mesh()[0] + structure_ase = StructureGenerationMixin._ensure_ase_structure(structure) + conventional_ase = StructureGenerationMixin._ensure_ase_structure(self.ctx.conventional_structure) + z_ratio = structure_ase.cell.cellpar()[2] / conventional_ase.cell.cellpar()[2] + kpoints = orm.KpointsData() + kpoints.set_kpoints_mesh(kpoints_scf_mesh[:2] + [ceil(kpoints_scf_mesh[2] / z_ratio)]) + return kpoints + + def _setup_surface_energy_kpoints(self, kpoints_scf: orm.KpointsData) -> orm.KpointsData: + """Setup kpoints for surface energy calculation. + + :param kpoints_scf_mesh: Base kpoints mesh from SCF calculation + :return: KpointsData object for surface energy + """ + return self._calculate_kpoints_for_structure( + self.ctx.cleavaged_structure, + kpoints_scf + ) + + +class WorkflowInspectionMixin: + """Mixin for workflow inspection and error handling.""" + + def _inspect_workchain( + self, + workchain: orm.ProcessNode, + workchain_type_name: str, + structure_type: str, + exit_code_on_failure: ExitCode, + namespace: ty.Optional[str] = None, + workchain_class: ty.Optional[type] = None + ) -> ty.Optional[ExitCode]: + """Generic method to inspect a workchain and handle outputs. + + :param workchain: The workchain node to inspect + :param workchain_type_name: Name of workchain type (for reporting) + :param structure_type: Type of structure (for reporting) + :param exit_code_on_failure: Exit code to return on failure + :param namespace: Optional namespace for exposing outputs + :param workchain_class: Optional workchain class for exposing outputs + :return: Exit code if failed, None if successful + """ + if not workchain.is_finished_ok: + self.report( + f"{workchain_type_name}<{workchain.pk}> for {structure_type} " + f"failed with exit status {workchain.exit_status}" + ) + return exit_code_on_failure + + self.report( + f'{workchain_type_name}<{workchain.pk}> for {structure_type} finished OK' + ) + + if namespace and workchain_class: + self.out_many( + self.exposed_outputs(workchain, workchain_class, namespace=namespace) + ) + + return None + + def _get_workchain_energy(self, workchain: orm.ProcessNode) -> float: + """Extract energy from workchain outputs. + + :param workchain: Workchain node + :return: Energy value + """ + return float(workchain.outputs.output_parameters.get('energy')) + + +def clean_calcjob_remote(node: orm.CalcJobNode) -> bool: + """Clean the remote directory of a ``CalcJobNode``.""" + cleaned = False + + try: + node.outputs.remote_folder._clean() # noqa: SLF001 + cleaned = True + except (OSError, KeyError, RuntimeError): + pass + + return cleaned + + +def clean_workchain_calcs(workchain: orm.WorkChainNode) -> list[int]: + """Clean all remote directories of a workchain's descendant calculations.""" + cleaned_calcs: list[int] = [] + + for called_descendant in workchain.called_descendants: + if isinstance(called_descendant, orm.CalcJobNode) and clean_calcjob_remote(called_descendant): + cleaned_calcs.append(called_descendant.pk) + + return cleaned_calcs diff --git a/src/aiida_mechanical/workflows/dislocation/protocols/__init__.py b/src/aiida_mechanical/workflows/dislocation/protocols/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aiida_mechanical/workflows/dislocation/protocols/esfe.yaml b/src/aiida_mechanical/workflows/dislocation/protocols/esfe.yaml new file mode 100644 index 0000000..8a9cc68 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/protocols/esfe.yaml @@ -0,0 +1,44 @@ +default_inputs: + clean_workdir: True + kpoints_distance: 0.5 + relax: + base_relax: + pw: + metadata: + options: + withmpi: True + + scf: + pw: + metadata: + options: + withmpi: True + + layer_relax: + relax: + base_relax: + pw: + settings: + USE_FRACTIONAL: True + metadata: + options: + withmpi: True + + + surface_energy: + pw: + metadata: + options: + withmpi: True + +default_protocol: moderate +protocols: + moderate: + kpoints_distance: 0.3 + description: 'Protocol to perform a USF calculation at normal precision at moderate computational cost.' + precise: + description: 'Protocol to perform a USF calculation at high precision at higher computational cost.' + kpoints_distance: 0.15 + fast: + description: 'Protocol to perform a USF calculation at low precision at minimal computational cost for testing purposes.' + kpoints_distance: 0.5 diff --git a/src/aiida_mechanical/workflows/dislocation/protocols/gsfe.yaml b/src/aiida_mechanical/workflows/dislocation/protocols/gsfe.yaml new file mode 100644 index 0000000..b64401b --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/protocols/gsfe.yaml @@ -0,0 +1,38 @@ +default_inputs: + clean_workdir: True + kpoints_distance: 0.3 + fault_method: general + + relax: + base_relax: + pw: + metadata: + options: + withmpi: True + + scf: + pw: + metadata: + options: + withmpi: True + + sfe: + pw: + metadata: + options: + withmpi: True + parameters: + electrons: + conv_thr: 1.0e-6 + +default_protocol: moderate +protocols: + moderate: + kpoints_distance: 0.3 + description: 'Protocol to perform a USF calculation at normal precision at moderate computational cost.' + precise: + description: 'Protocol to perform a USF calculation at high precision at higher computational cost.' + kpoints_distance: 0.15 + fast: + description: 'Protocol to perform a USF calculation at low precision at minimal computational cost for testing purposes.' + kpoints_distance: 0.5 diff --git a/src/aiida_mechanical/workflows/dislocation/protocols/gsfe_relax.yaml b/src/aiida_mechanical/workflows/dislocation/protocols/gsfe_relax.yaml new file mode 100644 index 0000000..1ca3131 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/protocols/gsfe_relax.yaml @@ -0,0 +1,38 @@ +default_inputs: + clean_workdir: True + kpoints_distance: 0.3 + fault_method: general + + relax: + base_relax: + pw: + metadata: + options: + withmpi: True + + scf: + pw: + metadata: + options: + withmpi: True + + sfe: + base_relax: + pw: + settings: + USE_FRACTIONAL: False + metadata: + options: + withmpi: True + +default_protocol: moderate +protocols: + moderate: + kpoints_distance: 0.3 + description: 'Protocol to perform a USF calculation at normal precision at moderate computational cost.' + precise: + description: 'Protocol to perform a USF calculation at high precision at higher computational cost.' + kpoints_distance: 0.15 + fast: + description: 'Protocol to perform a USF calculation at low precision at minimal computational cost for testing purposes.' + kpoints_distance: 0.5 diff --git a/src/aiida_mechanical/workflows/dislocation/protocols/isfe.yaml b/src/aiida_mechanical/workflows/dislocation/protocols/isfe.yaml new file mode 100644 index 0000000..8a9cc68 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/protocols/isfe.yaml @@ -0,0 +1,44 @@ +default_inputs: + clean_workdir: True + kpoints_distance: 0.5 + relax: + base_relax: + pw: + metadata: + options: + withmpi: True + + scf: + pw: + metadata: + options: + withmpi: True + + layer_relax: + relax: + base_relax: + pw: + settings: + USE_FRACTIONAL: True + metadata: + options: + withmpi: True + + + surface_energy: + pw: + metadata: + options: + withmpi: True + +default_protocol: moderate +protocols: + moderate: + kpoints_distance: 0.3 + description: 'Protocol to perform a USF calculation at normal precision at moderate computational cost.' + precise: + description: 'Protocol to perform a USF calculation at high precision at higher computational cost.' + kpoints_distance: 0.15 + fast: + description: 'Protocol to perform a USF calculation at low precision at minimal computational cost for testing purposes.' + kpoints_distance: 0.5 diff --git a/src/aiida_mechanical/workflows/dislocation/protocols/layer_relax.yaml b/src/aiida_mechanical/workflows/dislocation/protocols/layer_relax.yaml new file mode 100644 index 0000000..aae89ae --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/protocols/layer_relax.yaml @@ -0,0 +1,30 @@ +default_inputs: + clean_workdir: True + kpoints_distance: 0.5 + additional_spacings: + - -0.004 + - -0.002 + - 0.000 + - 0.002 + - 0.004 + + relax: + base_relax: + pw: + settings: + USE_FRACTIONAL: True + metadata: + options: + withmpi: True + +default_protocol: moderate +protocols: + moderate: + kpoints_distance: 0.3 + description: 'Protocol to perform a USF calculation at normal precision at moderate computational cost.' + precise: + description: 'Protocol to perform a USF calculation at high precision at higher computational cost.' + kpoints_distance: 0.15 + fast: + description: 'Protocol to perform a USF calculation at low precision at minimal computational cost for testing purposes.' + kpoints_distance: 0.5 diff --git a/src/aiida_mechanical/workflows/dislocation/protocols/sfebase.yaml b/src/aiida_mechanical/workflows/dislocation/protocols/sfebase.yaml new file mode 100644 index 0000000..fe01320 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/protocols/sfebase.yaml @@ -0,0 +1,44 @@ +default_inputs: + clean_workdir: True + kpoints_distance: 0.5 + relax: + base_relax: + pw: + metadata: + options: + withmpi: True + + scf: + pw: + metadata: + options: + withmpi: True + + layer_relax: + relax: + base_relax: + pw: + settings: + USE_FRACTIONAL: True + metadata: + options: + withmpi: True + + + surface_energy: + pw: + metadata: + options: + withmpi: True + +default_protocol: moderate +protocols: + moderate: + kpoints_distance: 0.3 + description: 'Protocol to perform a USF calculation at normal precision at moderate computational cost.' + precise: + description: 'Protocol to perform a USF calculation at high precision at higher computational cost.' + kpoints_distance: 0.15 + fast: + description: 'Protocol to perform a USF calculation at low precision at minimal computational cost for testing purposes.' + kpoints_distance: 0.5 diff --git a/src/aiida_mechanical/workflows/dislocation/protocols/surface.yaml b/src/aiida_mechanical/workflows/dislocation/protocols/surface.yaml new file mode 100644 index 0000000..1e5bb55 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/protocols/surface.yaml @@ -0,0 +1,40 @@ +default_inputs: + clean_workdir: True + kpoints_distance: 0.3 + vacuum_spacings: + - 0.5 + - 0.8 + - 1.1 + - 1.4 + - 1.7 + - 2.0 + relax: + base_relax: + pw: + metadata: + options: + withmpi: True + + scf: + pw: + metadata: + options: + withmpi: True + + surface_energy: + pw: + metadata: + options: + withmpi: True + +default_protocol: moderate +protocols: + moderate: + kpoints_distance: 0.3 + description: 'Protocol to perform a USF calculation at normal precision at moderate computational cost.' + precise: + description: 'Protocol to perform a USF calculation at high precision at higher computational cost.' + kpoints_distance: 0.15 + fast: + description: 'Protocol to perform a USF calculation at low precision at minimal computational cost for testing purposes.' + kpoints_distance: 0.5 diff --git a/src/aiida_mechanical/workflows/dislocation/protocols/twinning.yaml b/src/aiida_mechanical/workflows/dislocation/protocols/twinning.yaml new file mode 100644 index 0000000..6b8c6c7 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/protocols/twinning.yaml @@ -0,0 +1,39 @@ +default_inputs: + clean_workdir: True + kpoints_distance: 0.3 + relax: + base_relax: + pw: + metadata: + options: + withmpi: True + + scf: + pw: + metadata: + options: + withmpi: True + + twinning: + pw: + metadata: + options: + withmpi: True + + surface_energy: + pw: + metadata: + options: + withmpi: True + +default_protocol: moderate +protocols: + moderate: + kpoints_distance: 0.3 + description: 'Protocol to perform a USF calculation at normal precision at moderate computational cost.' + precise: + description: 'Protocol to perform a USF calculation at high precision at higher computational cost.' + kpoints_distance: 0.15 + fast: + description: 'Protocol to perform a USF calculation at low precision at minimal computational cost for testing purposes.' + kpoints_distance: 0.5 diff --git a/src/aiida_mechanical/workflows/dislocation/protocols/usfe.yaml b/src/aiida_mechanical/workflows/dislocation/protocols/usfe.yaml new file mode 100644 index 0000000..cddbc50 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/protocols/usfe.yaml @@ -0,0 +1,52 @@ +default_inputs: + clean_workdir: True + kpoints_distance: 0.3 + fault_method: removal + vacuum_ratio: 1.0 + layer_spacings: + - 0.000 + - 0.002 + - 0.004 + - 0.006 + - 0.008 + + relax: + base_relax: + pw: + metadata: + options: + withmpi: True + + scf: + pw: + metadata: + options: + withmpi: True + + layer_relax: + relax: + base_relax: + pw: + settings: + USE_FRACTIONAL: True + metadata: + options: + withmpi: True + + surface_energy: + pw: + metadata: + options: + withmpi: True + +default_protocol: moderate +protocols: + moderate: + kpoints_distance: 0.3 + description: 'Protocol to perform a USF calculation at normal precision at moderate computational cost.' + precise: + description: 'Protocol to perform a USF calculation at high precision at higher computational cost.' + kpoints_distance: 0.15 + fast: + description: 'Protocol to perform a USF calculation at low precision at minimal computational cost for testing purposes.' + kpoints_distance: 0.5 diff --git a/src/aiida_mechanical/workflows/dislocation/sfebase.py b/src/aiida_mechanical/workflows/dislocation/sfebase.py new file mode 100644 index 0000000..51075b5 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/sfebase.py @@ -0,0 +1,560 @@ +from aiida import orm +from aiida.common import AttributeDict +from aiida.engine import WorkChain, ToContext, if_ + +from aiida_quantumespresso.workflows.protocols.utils import ProtocolMixin + +from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain +from aiida_quantumespresso.workflows.pw.relax import PwRelaxWorkChain +from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import create_kpoints_from_distance +from aiida_mechanical.tools import ( + calculate_surface_area, + get_conventional_structure, + get_cleavaged_structure, +) +from ase.formula import Formula + +from .mixins import ( + StructureGenerationMixin, + EnergyCalculationMixin, + KpointsSetupMixin, + WorkflowInspectionMixin, +) +from .layer_relax import RigidLayerRelaxWorkChain + + +class SFEBaseWorkChain( + ProtocolMixin, + StructureGenerationMixin, + EnergyCalculationMixin, + KpointsSetupMixin, + WorkflowInspectionMixin, + WorkChain +): + """SFEBase WorkChain""" + + _NAMESPACE = 'sfebase' + _RELAX_NAMESPACE = "relax" + _SCF_NAMESPACE = "scf" + _RIGID_LAYER_RELAX_NAMESPACE = "layer_relax" + _SURFACE_ENERGY_NAMESPACE = "surface_energy" + + _RY2eV = 13.605693122990 + _RYA22Jm2 = 4.3597447222071E-18/2 * 1E+20 + _eVA22Jm2 = 1.602176634E-19 * 1E+20 + + @classmethod + def define(cls, spec): + super().define(spec) + + spec.input('n_repeats', valid_type=orm.Int, required=False, default=lambda: orm.Int(4), + help='The number of layers in the supercell') + spec.input('gliding_plane', valid_type=orm.Str, required=False, default=lambda: orm.Str(), + help='The normal vector for the supercell. Note that please always put the z axis at the last.') + spec.input('structure', valid_type=orm.StructureData, required=True,) + spec.input('kpoints_distance', valid_type=orm.Float, required=False, default=lambda: orm.Float(0.3), + help='The distance between kpoints for the kpoints generation') + spec.input('clean_workdir', valid_type=orm.Bool, default=lambda: orm.Bool(False), + help='If `True`, work directories of all called calculation will be cleaned at the end of execution.') + + spec.input('layer_spacings', valid_type=orm.List, required=False, default=lambda: orm.List(list=[0.0]), + help='The layer spacings to add to the structure.') + spec.input('fault_method', valid_type=orm.Str, required=False, default=lambda: orm.Str('removal'), + help="How to generate faulted structures: 'removal', or 'vacuum'.") + spec.input('vacuum_ratio', valid_type=orm.Float, required=False, default=lambda: orm.Float(0.1), + help='Vacuum ratio added along the fault normal when using vacuum gliding.') + + spec.expose_inputs( + PwRelaxWorkChain, + namespace=cls._RELAX_NAMESPACE, + exclude=( + 'structure', + 'clean_workdir', + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwRelaxWorkChain`.' + } + ) + + spec.expose_inputs( + PwBaseWorkChain, + namespace=cls._SCF_NAMESPACE, + exclude=( + 'pw.structure', + 'clean_workdir', + 'kpoints', + 'kpoints_distance' + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwBaseWorkChain`.' + } + ) + + spec.expose_inputs( + RigidLayerRelaxWorkChain, + namespace=cls._RIGID_LAYER_RELAX_NAMESPACE, + exclude=( + 'structure', + 'clean_workdir', + 'kpoints', + 'fault_type', + 'fault_method', + 'vacuum_ratio', + 'gliding_plane', + 'n_repeats', + 'layer_spacings' + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwBaseWorkChain`.' + } + ) + + spec.expose_inputs( + PwBaseWorkChain, + namespace=cls._SURFACE_ENERGY_NAMESPACE, + exclude=( + 'pw.structure', + 'clean_workdir', + 'kpoints', + 'kpoints_distance' + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwBaseWorkChain`.' + } + ) + + spec.outline( + if_(cls.should_run_relax)( + cls.run_relax, + cls.inspect_relax, + ), + cls.generate_structures, + cls.setup, + if_(cls.should_run_scf)( + cls.run_scf, + cls.inspect_scf, + ), + cls.run_layer_relax, + cls.inspect_layer_relax, + if_(cls.should_run_surface_energy)( + cls.run_surface_energy, + cls.inspect_surface_energy, + ), + cls.results, + ) + + spec.expose_outputs( + PwRelaxWorkChain, + namespace=cls._RELAX_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + + spec.expose_outputs( + PwBaseWorkChain, + namespace=cls._SCF_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + + spec.expose_outputs( + PwBaseWorkChain, + namespace=cls._SURFACE_ENERGY_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + + spec.exit_code( + 401, + "ERROR_SUB_PROCESS_FAILED_RELAX", + message='The `PwBaseWorkChain` for the relax run failed.', + ) + spec.exit_code( + 402, + "ERROR_SUB_PROCESS_FAILED_SCF", + message='The `PwBaseWorkChain` for the scf run failed.', + ) + spec.exit_code( + 403, + "ERROR_NO_STRUCTURE_TYPE_DETECTED", + message='The structure type can not be detected.', + ) + spec.exit_code( + 405, + "ERROR_SUB_PROCESS_FAILED_RIGID_LAYER_RELAX", + message='The `RigidLayerRelaxWorkChain` for the rigid layer relaxation failed.', + ) + spec.exit_code( + 406, + "ERROR_SUB_PROCESS_FAILED_SURFACE_ENERGY", + message='The `PwBaseWorkChain` for the surface energy calculation failed.', + ) + + @classmethod + def get_protocol_filepath(cls): + """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" + from importlib_resources import files + from . import protocols + return files(protocols) / f'{cls._NAMESPACE}.yaml' + + @classmethod + def get_protocol_overrides(cls) -> dict: + """Get the ``overrides`` of the default protocol.""" + from importlib_resources import files + import yaml + from . import protocols + + path = files(protocols) / f"{cls._NAMESPACE}.yaml" + with path.open() as file: + return yaml.safe_load(file) + + @classmethod + def get_builder_from_protocol( + cls, + code, + structure, + protocol='moderate', + overrides=None, + **kwargs + ): + """Return a builder prepopulated with inputs selected according to the chosen protocol. + """ + inputs = cls.get_protocol_inputs(protocol, overrides) + args = (code, structure, protocol) + + builder = cls.get_builder() + + # Set up the sub-workchains + for namespace, workchain_type in [ + (cls._RELAX_NAMESPACE, PwRelaxWorkChain), + (cls._SCF_NAMESPACE, PwBaseWorkChain), + (cls._RIGID_LAYER_RELAX_NAMESPACE, RigidLayerRelaxWorkChain), + (cls._SURFACE_ENERGY_NAMESPACE, PwBaseWorkChain), + ]: + overrides = inputs.get(namespace, {}) + + if workchain_type == RigidLayerRelaxWorkChain: + overrides.setdefault('relax', {}).setdefault('base_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + overrides.setdefault('relax', {}).setdefault('base_init_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + elif workchain_type == PwRelaxWorkChain: + overrides.setdefault('base_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + overrides.setdefault('base_init_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + else: + overrides['pseudo_family'] = inputs.get('pseudo_family', None) + sub_builder = workchain_type.get_builder_from_protocol( + *args, + overrides=overrides, + ) + sub_builder.pop('clean_workdir', None) + + builder[namespace]._data = sub_builder._data + + builder[cls._RELAX_NAMESPACE].pop('base_init_relax', None) + + builder.layer_spacings = orm.List(list=inputs.get('layer_spacings', [0.0])) + builder.structure = structure + builder.fault_method = orm.Str(inputs.get('fault_method', 'removal')) + builder.vacuum_ratio = orm.Float(inputs.get('vacuum_ratio', 0.1)) + builder.n_repeats = orm.Int(inputs.get('n_repeats', 4)) + builder.kpoints_distance = orm.Float(inputs['kpoints_distance']) + builder.gliding_plane = orm.Str(inputs.get('gliding_plane', '')) + builder.clean_workdir = orm.Bool(inputs['clean_workdir']) + + return builder + + def should_run_relax(self): + return self._RELAX_NAMESPACE in self.inputs + + def run_relax(self): + + inputs = AttributeDict( + self.exposed_inputs( + PwRelaxWorkChain, + namespace=self._RELAX_NAMESPACE + ) + ) + + inputs.metadata.call_link_label = self._RELAX_NAMESPACE + + inputs.structure = self.inputs.structure + inputs.base_relax.kpoints_distance = self.inputs.kpoints_distance + + running = self.submit(PwRelaxWorkChain, **inputs) + self.report(f'launching PwRelaxWorkChain<{running.pk}> for {self.inputs.structure.get_formula()} unit cell geometry.') + + return ToContext(workchain_relax=running) + + def inspect_relax(self): + workchain = self.ctx.workchain_relax + + if not workchain.is_finished_ok: + self.report( + f"PwRelaxWorkChain<{workchain.pk}> for {self.inputs.structure.get_formula()} unit cell geometry failed with exit status {workchain.exit_status}" + ) + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_RELAX + + self.report(f'PwRelaxWorkChain<{workchain.pk}> for {self.inputs.structure.get_formula()} unit cell geometry finished OK') + + self.ctx.current_structure = workchain.outputs.output_structure + self.out_many( + self.exposed_outputs( + workchain, + PwRelaxWorkChain, + namespace=self._RELAX_NAMESPACE, + ), + ) + self.ctx.total_energy_unit_cell = workchain.outputs.output_parameters.get('energy') + self.report(f"Total energy of unit cell after relaxation: {self.ctx.total_energy_unit_cell / self._RY2eV} Ry") + + def _get_fault_type(self): + """Return the fault type for this workchain. Must be implemented by subclasses.""" + raise NotImplementedError("Subclasses must implement _get_fault_type()") + + def generate_structures(self): + """Generate base structures (conventional and cleavaged). + Subclasses should override generate_faulted_structure() to generate faulted structures.""" + + if 'current_structure' not in self.ctx: + self.ctx.current_structure = self.inputs.structure + + gliding_plane = self.inputs.gliding_plane.value if self.inputs.gliding_plane.value else None + + # Get conventional structure + strukturbericht, conventional_structure = get_conventional_structure( + self.ctx.current_structure.get_ase(), + gliding_plane=gliding_plane, + ) + if strukturbericht: + self.report(f'{strukturbericht} structure is detected.') + else: + self.report(f'Strukturbericht can not be detected.') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + # Get cleavaged structure (based on conventional cell) + _, cleavaged_structure = get_cleavaged_structure( + conventional_structure, + gliding_plane=gliding_plane, + n_unit_cells=self.inputs.n_repeats.value, + ) + + # Store structures directly in context + self.ctx.conventional_structure = conventional_structure + self.ctx.cleavaged_structure = cleavaged_structure + + self.ctx.surface_area = calculate_surface_area(conventional_structure.cell) + + self.report(f'Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2') + + unit_cell_formula = Formula(self.ctx.current_structure.get_ase().get_chemical_formula()) + _, unit_cell_multiplier = unit_cell_formula.reduce() + + # Calculate and store multipliers using helper method + self.ctx.unit_cell_multiplier = self._calculate_structure_multiplier( + self.ctx.current_structure.get_ase() + ) + self.ctx.conventional_multiplier = self._calculate_structure_multiplier( + conventional_structure + ) + self.ctx.surface_multiplier = self._calculate_structure_multiplier( + cleavaged_structure + ) + + def _get_kpoints_scf(self): + """Get or create kpoints_scf. Returns kpoints_scf KpointsData object.""" + if 'kpoints_scf' in self.ctx: + kpoints_scf = self.ctx.kpoints_scf + else: + inputs = { + 'structure': orm.StructureData( + ase=self.ctx.conventional_structure + ), + 'distance': self.inputs.kpoints_distance, + 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), + 'metadata': { + 'call_link_label': 'create_kpoints_from_distance' + } + } + kpoints_scf = create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg + + return kpoints_scf + + def setup(self): + """ + Setup kpoints for supercell calculations. + Common implementation that can be overridden by subclasses if needed. + """ + # Get kpoints_scf + kpoints_scf = self._get_kpoints_scf() + + self.ctx.kpoints_scf = kpoints_scf + # Calculate kpoints for surface energy using helper method + self.ctx.kpoints_surface_energy = self._setup_surface_energy_kpoints(kpoints_scf) + + def should_run_scf(self): + + return self._SCF_NAMESPACE in self.inputs + + def run_scf(self): + inputs = AttributeDict( + self.exposed_inputs( + PwBaseWorkChain, + namespace=self._SCF_NAMESPACE + ) + ) + + inputs.metadata.call_link_label = self._SCF_NAMESPACE + + inputs.pw.structure = orm.StructureData( + ase=self.ctx.conventional_structure + ) + + inputs.kpoints_distance = self.inputs.kpoints_distance + + running = self.submit(PwBaseWorkChain, **inputs) + self.report(f'launching PwBaseWorkChain<{running.pk}> for {self.inputs.structure.get_formula()} conventional geometry.') + + return ToContext(workchain_scf=running) + + def inspect_scf(self): + """Verify that the `PwBaseWorkChain` for the scf run successfully finished.""" + workchain = self.ctx.workchain_scf + + if not workchain.is_finished_ok: + self.report( + f"PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SCF + + self.report(f'PwBaseWorkChain<{workchain.pk}> finished successfully.') + + self.out_many( + self.exposed_outputs( + workchain, + PwBaseWorkChain, + namespace=self._SCF_NAMESPACE, + ), + ) + # Extract and report energy + self.ctx.total_energy_conventional_geometry = self._get_workchain_energy(workchain) + + + # Report energy difference if unit cell energy available + if 'total_energy_unit_cell' in self.ctx: + energy_difference = ( + self.ctx.total_energy_conventional_geometry + - self.ctx.total_energy_unit_cell + / self.ctx.unit_cell_multiplier + * self.ctx.conventional_multiplier + ) + self.report( + f'Energy difference between conventional and unit cell: ' + f'{energy_difference / self._RY2eV} Ry' + ) + + def run_layer_relax(self): + inputs = AttributeDict( + self.exposed_inputs( + RigidLayerRelaxWorkChain, + namespace=self._RIGID_LAYER_RELAX_NAMESPACE + ) + ) + inputs.structure = orm.StructureData( + ase=self.ctx.conventional_structure + ) + inputs.kpoints = self.ctx.kpoints_scf + inputs.n_repeats = self.inputs.n_repeats + inputs.gliding_plane = self.inputs.gliding_plane + inputs.fault_type = self._get_fault_type() + inputs.fault_method = self.inputs.fault_method + inputs.layer_spacings = self.inputs.layer_spacings + inputs.vacuum_ratio = self.inputs.vacuum_ratio + + inputs.metadata.call_link_label = self._RIGID_LAYER_RELAX_NAMESPACE + + running = self.submit(RigidLayerRelaxWorkChain, **inputs) + self.report(f'launching RigidLayerRelaxWorkChain<{running.pk}> for rigid layer relaxation calculations over all spacings.') + + return {f"workchain_layer_relax": running} + + def inspect_layer_relax(self): + """Inspect the RigidLayerRelaxWorkChain results.""" + workchain = self.ctx.workchain_layer_relax + + if not workchain.is_finished_ok: + self.report( + f"RigidLayerRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_RIGID_LAYER_RELAX + + self.report(f'RigidLayerRelaxWorkChain<{workchain.pk}> finished successfully.') + + def should_run_surface_energy(self): + return self._SURFACE_ENERGY_NAMESPACE in self.inputs + + def run_surface_energy(self): + inputs = AttributeDict( + self.exposed_inputs( + PwBaseWorkChain, + namespace=self._SURFACE_ENERGY_NAMESPACE + ) + ) + inputs.metadata.call_link_label = self._SURFACE_ENERGY_NAMESPACE + inputs.pw.structure = orm.StructureData( + ase=self.ctx.cleavaged_structure + ) + inputs.kpoints = self.ctx.kpoints_surface_energy + + running = self.submit(PwBaseWorkChain, **inputs) + self.report(f'launching PwBaseWorkChain<{running.pk}> for cleavaged structure') + + return {f"workchain_surface_energy": running} + + def inspect_surface_energy(self): + """Verify that the surface energy calculation successfully finished.""" + workchain = self.ctx.workchain_surface_energy + + if not workchain.is_finished_ok: + self.report( + f"PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SURFACE_ENERGY + + self.report(f'PwBaseWorkChain<{workchain.pk}> finished successfully.') + + self.out_many( + self.exposed_outputs( + workchain, + PwBaseWorkChain, + namespace=self._SURFACE_ENERGY_NAMESPACE, + ), + ) + # Extract and report energy + total_energy_slab = workchain.outputs.output_parameters.get('energy') + + + # Calculate surface energy + if 'total_energy_conventional_geometry' in self.ctx: + energy_difference = ( + total_energy_slab + - self.ctx.total_energy_conventional_geometry + / self.ctx.conventional_multiplier + * self.ctx.surface_multiplier + ) + surface_energy = energy_difference / self.ctx.surface_area * self._eVA22Jm2 + self.report( + f'Surface energy evaluated from conventional geometry: {surface_energy} J/m^2' + ) + + def results(self): + pass diff --git a/src/aiida_mechanical/workflows/dislocation/surface.py b/src/aiida_mechanical/workflows/dislocation/surface.py new file mode 100644 index 0000000..708ff3c --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/surface.py @@ -0,0 +1,504 @@ +from aiida import orm +from aiida.common import AttributeDict +from aiida.engine import WorkChain, if_, while_ +import typing as ty +from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import create_kpoints_from_distance + +from aiida_quantumespresso.workflows.protocols.utils import ProtocolMixin + +from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain +from aiida_quantumespresso.workflows.pw.relax import PwRelaxWorkChain + +from aiida_mechanical.calculations import generate_cleavaged_structures +from aiida_mechanical.data.cleavaged_structure import CleavagedStructureData + +from .mixins import ( + StructureGenerationMixin, + EnergyCalculationMixin, + KpointsSetupMixin, + WorkflowInspectionMixin, + clean_workchain_calcs, +) + +class SurfaceEnergyWorkChain( + ProtocolMixin, + StructureGenerationMixin, + EnergyCalculationMixin, + KpointsSetupMixin, + WorkflowInspectionMixin, + WorkChain): + """Surface Energy WorkChain""" + + _NAMESPACE = 'surface' + + _RELAX_NAMESPACE = "relax" + _SCF_NAMESPACE = "scf" + _SURFACE_ENERGY_NAMESPACE = "surface_energy" + + _RY2eV = 13.605693122990 + _eVA22Jm2 = 1.602176634E-19 * 1E+20 + + @classmethod + def define(cls, spec): + super().define(spec) + + spec.input('structure', valid_type=orm.StructureData, required=True,) + spec.input( + 'cleavaged_structure_data', + valid_type=CleavagedStructureData, + required=False, + default=lambda: CleavagedStructureData(n_unit_cells=4, vacuum_spacings=[1.0]), + help='Configuration for cleavaged slab generation.', + ) + spec.input('kpoints_distance', valid_type=orm.Float, required=False, default=lambda: orm.Float(0.3), + help='The distance between kpoints for the kpoints generation') + spec.input('clean_workdir', valid_type=orm.Bool, default=lambda: orm.Bool(False), + help='If `True`, work directories of all called calculation will be cleaned at the end of execution.') + + spec.expose_inputs( + PwRelaxWorkChain, + namespace=cls._RELAX_NAMESPACE, + exclude=( + 'structure', + 'clean_workdir', + 'kpoints', + 'kpoints_distance', + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwRelaxWorkChain`.' + } + ) + + spec.expose_inputs( + PwBaseWorkChain, + namespace=cls._SCF_NAMESPACE, + exclude=( + 'pw.structure', + 'clean_workdir', + 'kpoints', + 'kpoints_distance', + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwBaseWorkChain` for SCF calculation.' + } + ) + + + spec.expose_inputs( + PwBaseWorkChain, + namespace=cls._SURFACE_ENERGY_NAMESPACE, + exclude=( + 'pw.structure', + 'clean_workdir', + 'kpoints', + 'kpoints_distance', + ), + namespace_options={ + 'required': False, + 'populate_defaults': False, + 'help': 'Inputs for the `PwBaseWorkChain` for surface energy calculation.' + } + ) + + spec.outline( + if_(cls.should_run_relax)( + cls.run_relax, + cls.inspect_relax, + ), + cls.generate_structures, + cls.setup, + if_(cls.should_run_scf)( + cls.run_scf, + cls.inspect_scf, + ), + while_(cls.should_run_surface_energy)( + cls.run_surface_energy, + cls.inspect_surface_energy, + ), + cls.results, + ) + spec.expose_outputs( + PwRelaxWorkChain, + namespace=cls._RELAX_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + spec.expose_outputs( + PwBaseWorkChain, + namespace=cls._SCF_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + spec.expose_outputs( + PwBaseWorkChain, + namespace=cls._SURFACE_ENERGY_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + spec.output( + 'results', + valid_type=orm.Dict, + required=False, + help='Aggregated surface-energy results for all evaluated vacuum spacings.', + ) + + spec.exit_code( + 401, + "ERROR_SUB_PROCESS_FAILED_RELAX", + message='The `PwBaseWorkChain` for the GSF run failed.', + ) + + spec.exit_code( + 402, + "ERROR_SUB_PROCESS_FAILED_SCF", + message='The `PwBaseWorkChain` for the USF run failed.', + ) + spec.exit_code( + 403, + "ERROR_SUB_PROCESS_FAILED_SURFACE_ENERGY", + message='The `PwBaseWorkChain` for the surface energy run failed.', + ) + spec.exit_code( + 404, + "ERROR_NO_STRUCTURE_TYPE_DETECTED", + message='The structure type is not detected.', + ) + + @classmethod + def get_protocol_overrides(cls) -> dict: + """Get the ``overrides`` of the default protocol.""" + from importlib_resources import files + import yaml + from . import protocols + + path = files(protocols) / f"{cls._NAMESPACE}.yaml" + with path.open() as file: + return yaml.safe_load(file) + + + @classmethod + def get_protocol_filepath(cls): + """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" + from importlib_resources import files + from . import protocols + return files(protocols) / f'{cls._NAMESPACE}.yaml' + + @classmethod + def get_protocol_overrides(cls) -> dict: + """Get the ``overrides`` of the default protocol.""" + from importlib_resources import files + import yaml + from . import protocols + + path = files(protocols) / f"{cls._NAMESPACE}.yaml" + with path.open() as file: + return yaml.safe_load(file) + + @classmethod + def get_builder_from_protocol( + cls, + code, + structure, + protocol='moderate', + overrides=None, + n_repeats: ty.Optional[int | orm.Int] = None, + gliding_plane: ty.Optional[str | orm.Str] = None, + vacuum_spacings: ty.Optional[ty.Sequence[float] | orm.List] = None, + **kwargs + ): + """Return a builder prepopulated with inputs selected according to the chosen protocol. + """ + inputs = cls.get_protocol_inputs(protocol, overrides) + args = (code, structure, protocol) + + builder = cls.get_builder() + + # Set up the sub-workchains + for namespace, workchain_type in [ + (cls._RELAX_NAMESPACE, PwRelaxWorkChain), + (cls._SCF_NAMESPACE, PwBaseWorkChain), + (cls._SURFACE_ENERGY_NAMESPACE, PwBaseWorkChain), + ]: + overrides = inputs.get(namespace, {}) + + if workchain_type == PwRelaxWorkChain: + overrides.setdefault('base_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + overrides.setdefault('base_init_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + else: + overrides['pseudo_family'] = inputs.get('pseudo_family', None) + + sub_builder = workchain_type.get_builder_from_protocol( + *args, + overrides=overrides, + ) + sub_builder.pop('structure', None) + sub_builder.pop('clean_workdir', None) + + if namespace != cls._RELAX_NAMESPACE: + sub_builder.pop('kpoints', None) + sub_builder.pop('kpoints_distance', None) + + builder[namespace]._data = sub_builder._data + + if cls._RELAX_NAMESPACE in builder: + builder[cls._RELAX_NAMESPACE].pop('base_init_relax', None) + if 'base_relax' in builder[cls._RELAX_NAMESPACE]: + builder[cls._RELAX_NAMESPACE]['base_relax'].pop('kpoints', None) + builder[cls._RELAX_NAMESPACE]['base_relax'].pop('kpoints_distance', None) + + builder.structure = structure + resolved_n_repeats = n_repeats.value if isinstance(n_repeats, orm.Int) else n_repeats + resolved_gliding_plane = gliding_plane.value if isinstance(gliding_plane, orm.Str) else gliding_plane + if isinstance(vacuum_spacings, orm.List): + resolved_vacuum_spacings = vacuum_spacings.get_list() + else: + resolved_vacuum_spacings = list(vacuum_spacings) if vacuum_spacings is not None else None + builder.cleavaged_structure_data = CleavagedStructureData( + n_unit_cells=inputs.get('n_repeats', 4) if resolved_n_repeats is None else resolved_n_repeats, + gliding_plane=inputs.get('gliding_plane', '') if resolved_gliding_plane is None else resolved_gliding_plane, + vacuum_spacings=inputs.get('vacuum_spacings', [1.0]) if resolved_vacuum_spacings is None else resolved_vacuum_spacings, + ) + builder.kpoints_distance = orm.Float(inputs['kpoints_distance']) + builder.clean_workdir = orm.Bool(inputs['clean_workdir']) + + return builder + + + def should_run_relax(self): + return self._RELAX_NAMESPACE in self.inputs + + def run_relax(self): + inputs = AttributeDict( + self.exposed_inputs( + PwRelaxWorkChain, + namespace=self._RELAX_NAMESPACE + ) + ) + inputs.metadata.call_link_label = self._RELAX_NAMESPACE + inputs.structure = self.inputs.structure + inputs.base_relax.kpoints_distance = self.inputs.kpoints_distance + running = self.submit(PwRelaxWorkChain, **inputs) + self.report(f'launching PwRelaxWorkChain<{running.pk}> for primitive structure') + return {f"workchain_relax": running} + + def inspect_relax(self): + workchain = self.ctx.workchain_relax + if not workchain.is_finished_ok: + self.report(f'PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_RELAX + + self.report(f'PwRelaxWorkChain<{self.ctx.workchain_relax.pk}> finished') + + self.ctx.current_structure = workchain.outputs.output_structure + + # Expose outputs + self.out_many( + self.exposed_outputs( + workchain, + PwRelaxWorkChain, + namespace=self._RELAX_NAMESPACE + ) + ) + + def generate_structures(self): + """Generate provenance-tracked conventional and slab structures.""" + if 'current_structure' not in self.ctx: + self.ctx.current_structure = self.inputs.structure + + try: + generated_structures = generate_cleavaged_structures( + structure=self.ctx.current_structure, + cleavaged_data=self.inputs.cleavaged_structure_data, + ) + except ValueError as exception: + self.report(f'Failed to generate cleavaged structures: {exception}') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + slab_entries: dict[str, dict[str, ty.Any]] = {} + + for output_label, output_node in generated_structures.items(): + if output_label.startswith('vacuum_spacing_'): + spacing_key = output_label.removeprefix('vacuum_spacing_') + slab_entries.setdefault(spacing_key, {})['vacuum_spacing'] = float(output_node.value) + continue + + if output_label.startswith('slab_'): + spacing_key = output_label.removeprefix('slab_') + slab_entries.setdefault(spacing_key, {})['structure'] = output_node + + self.ctx.generated_structures = [] + for spacing_key, slab_entry in slab_entries.items(): + if 'vacuum_spacing' not in slab_entry or 'structure' not in slab_entry: + self.report(f'Incomplete slab entry generated for spacing key `{spacing_key}`.') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + self.ctx.generated_structures.append({ + 'call_link_label': f'slab_{spacing_key}', + 'structure': slab_entry['structure'], + 'vacuum_spacing': slab_entry['vacuum_spacing'], + }) + + if not self.ctx.generated_structures: + self.report('No slab structures were generated for the selected configuration.') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + self.ctx.number_of_spacings = len(self.ctx.generated_structures) + self.ctx.conventional_structure = generated_structures['conventional_structure'] + self.ctx.surface_area = generated_structures['surface_area'].value + + self.report(f'Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2') + + self.ctx.unit_cell_multiplier = self._calculate_structure_multiplier( + self.ctx.current_structure.get_ase() + ) + self.ctx.conventional_multiplier = self._calculate_structure_multiplier( + self.ctx.conventional_structure + ) + + def _get_kpoints_scf(self): + """Get or create kpoints_scf. Returns kpoints_scf KpointsData object.""" + if 'kpoints_scf' in self.ctx: + kpoints_scf = self.ctx.kpoints_scf + else: + inputs = { + 'structure': self.ctx.conventional_structure, + 'distance': self.inputs.kpoints_distance, + 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), + 'metadata': { + 'call_link_label': 'create_kpoints_from_distance' + } + } + kpoints_scf = create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg + + return kpoints_scf + + def setup(self): + self.ctx.iteration = 0 + self.ctx.results = {} + kpoints_scf = self._get_kpoints_scf() + self.ctx.kpoints_scf = kpoints_scf + + def should_run_scf(self): + return self._SCF_NAMESPACE in self.inputs + + def run_scf(self): + inputs = AttributeDict( + self.exposed_inputs( + PwBaseWorkChain, + namespace=self._SCF_NAMESPACE + ) + ) + + inputs.metadata.call_link_label = self._SCF_NAMESPACE + + inputs.pw.structure = self.ctx.conventional_structure + + inputs.kpoints = self.ctx.kpoints_scf + + running = self.submit(PwBaseWorkChain, **inputs) + self.report(f'launching PwBaseWorkChain<{running.pk}> for conventional structure') + + return {f"workchain_scf": running} + + def inspect_scf(self): + workchain = self.ctx.workchain_scf + if not workchain.is_finished_ok: + self.report(f'PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SCF + + self.report(f'PwBaseWorkChain<{self.ctx.workchain_scf.pk}> finished') + + # Expose outputs + self.out_many( + self.exposed_outputs( + workchain, + PwBaseWorkChain, + namespace=self._SCF_NAMESPACE + ) + ) + self.ctx.total_energy_conventional_geometry = self._get_workchain_energy(workchain) + + def should_run_surface_energy(self): + + if self._SURFACE_ENERGY_NAMESPACE not in self.inputs: + return False + + if self.ctx.iteration >= self.ctx.number_of_spacings: + return False + + current_entry = self.ctx.generated_structures[self.ctx.iteration] + self.ctx.current_structure = current_entry['structure'] + self.ctx.current_spacing = float(current_entry['vacuum_spacing']) + self.ctx.current_call_link_label = current_entry['call_link_label'] + self.ctx.kpoints_surface_energy = self._calculate_kpoints_for_structure( + self.ctx.current_structure, + self.ctx.kpoints_scf, + ) + + return True + + def run_surface_energy(self): + inputs = AttributeDict( + self.exposed_inputs( + PwBaseWorkChain, + namespace=self._SURFACE_ENERGY_NAMESPACE + ) + ) + inputs.metadata.call_link_label = self.ctx.current_call_link_label + inputs.pw.structure = self.ctx.current_structure + inputs.kpoints = self.ctx.kpoints_surface_energy + + running = self.submit(PwBaseWorkChain, **inputs) + self.report( + f'launching PwBaseWorkChain<{running.pk}> for cleavaged structure ' + f'{self.ctx.iteration + 1}/{self.ctx.number_of_spacings} ({self.ctx.current_call_link_label}).' + ) + + return {f"workchain_surface_energy": running} + + def inspect_surface_energy(self): + workchain = self.ctx.workchain_surface_energy + if not workchain.is_finished_ok: + self.report(f'PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SURFACE_ENERGY + + self.report(f'PwBaseWorkChain<{self.ctx.workchain_surface_energy.pk}> finished') + total_energy_slab = self._get_workchain_energy(workchain) + surface_multiplier = self._calculate_structure_multiplier(self.ctx.current_structure) + surface_energy_j_m2 = self._calculate_surface_energy( + total_energy_slab, + surface_multiplier, + ) + + self.ctx.results[self.ctx.current_call_link_label] = { + 'vacuum_spacing': float(self.ctx.current_spacing), + 'structure_uuid': self.ctx.current_structure.uuid, + 'total_energy_ev': float(total_energy_slab), + 'surface_energy_j_m2': float(surface_energy_j_m2) if surface_energy_j_m2 is not None else None, + 'workchain_uuid': workchain.uuid, + } + self.ctx.iteration += 1 + + def results(self): + """Output collected results.""" + self.out('results', orm.Dict(dict=self.ctx.results).store()) + + def on_terminated(self) -> None: + """Clean child calculation working directories if ``clean_workdir`` is enabled.""" + super().on_terminated() + + if self.inputs.clean_workdir.value is False: + self.report('remote folders will not be cleaned') + return + + cleaned_calcs = clean_workchain_calcs(self.node) + + if cleaned_calcs: + self.report(f'cleaned remote folders of calculations: {" ".join(map(str, cleaned_calcs))}') diff --git a/src/aiida_mechanical/workflows/dislocation/twinning.py b/src/aiida_mechanical/workflows/dislocation/twinning.py new file mode 100644 index 0000000..9105862 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/twinning.py @@ -0,0 +1,169 @@ +from .sfebase import SFEBaseWorkChain +from aiida.common import AttributeDict +from aiida.engine import ToContext +from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain +from aiida import orm +from ase.formula import Formula +from aiida_mechanical.tools import ( + get_unstable_faulted_structure, + calculate_surface_area, +) + +class TwinningWorkChain(SFEBaseWorkChain): + """Twinning WorkChain""" + + _SFE_NAMESPACE = "twinning" + + @classmethod + def define(cls, spec): + super().define(spec) + + spec.expose_outputs( + PwBaseWorkChain, + namespace=cls._SFE_NAMESPACE, + namespace_options={ + 'required': False, + } + ) + + spec.exit_code( + 404, + "ERROR_SUB_PROCESS_FAILED_TWINNING", + message='The `PwBaseWorkChain` for the twinning run failed.', + ) + + def setup(self): + super().setup() + self.ctx.twinning_done = False + self.ctx.twinning_data = [] + + def _get_fault_type(self): + """Return the fault type for Twinning workchain. + Twinning is not a fault type, but we need to implement this for the base class. + For twinning, we use 'unstable' as the fault type for structure generation. + """ + return 'unstable' # Use 'unstable' for structure generation purposes + + def generate_structures(self): + """Generate all structures including conventional, cleavaged, and twinning.""" + # First call base to generate conventional and cleavaged + result = super().generate_structures() + if result: + return result + + gliding_plane = self.inputs.gliding_plane.value if self.inputs.gliding_plane.value else None + + # Get twinning structure using get_unstable_faulted_structure + strukturbericht, structures_dict = get_unstable_faulted_structure( + self.ctx.current_structure.get_ase(), + gliding_plane=gliding_plane, + n_unit_cells=self.inputs.n_repeats.value, + ) + + # Verify that twinning structure was generated + if 'twinning' not in structures_dict or structures_dict['twinning'] is None: + self.report('Twinning structure is not available for this gliding system.') + return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED + + # Store twinning structure directly in context + self.ctx.twinning_structure = structures_dict['twinning'] + self.ctx.unfaulted_structure = self.ctx.conventional_structure # unfaulted is the same as conventional + self.ctx.unfaulted_multiplier = self.ctx.conventional_multiplier + + def should_run_sfe(self): + if not self._SFE_NAMESPACE in self.inputs: + return False + if getattr(self.ctx, 'twinning_done', False): + return False + + # Set up current structure and multiplier + if not hasattr(self.ctx, 'twinning_structure'): + raise ValueError('Twinning structure not found in context.') + + current_structure = orm.StructureData(ase=self.ctx.twinning_structure) + self.ctx.current_structure = current_structure + + twinning_formula = Formula(self.ctx.twinning_structure.get_chemical_formula()) + _, twinning_multiplier = twinning_formula.reduce() + self.ctx.twinning_multiplier = twinning_multiplier + + return True + + def run_layer_relax(self): + """Run PwBaseWorkChain directly for twinning (no spacing loop needed).""" + # Setup kpoints for twinning structure + faulted_structure_ase = self.ctx.current_structure.get_ase() + conventional_structure_ase = self.ctx.conventional_structure + + z_ratio = faulted_structure_ase.cell.cellpar()[2] / conventional_structure_ase.cell.cellpar()[2] + kpoints_scf = self._get_kpoints_scf() + + from math import ceil + kpoints_sfe = orm.KpointsData() + kpoints_scf_mesh = kpoints_scf.get_kpoints_mesh()[0] + kpoints_sfe.set_kpoints_mesh(kpoints_scf_mesh[:2] + [ceil(kpoints_scf_mesh[2] / z_ratio)]) + + # Prepare inputs for PwBaseWorkChain + inputs = AttributeDict( + self.exposed_inputs( + PwBaseWorkChain, + namespace=self._SFE_NAMESPACE + ) + ) + + inputs.pw.structure = self.ctx.current_structure + inputs.kpoints = kpoints_sfe + inputs.metadata.call_link_label = self._SFE_NAMESPACE + + running = self.submit(PwBaseWorkChain, **inputs) + self.report(f'launching PwBaseWorkChain<{running.pk}> for twinning faulted geometry.') + + return ToContext(workchain_layer_relax=running) + + def inspect_layer_relax(self): + """Inspect the SFE calculation for twinning.""" + workchain = self.ctx.workchain_layer_relax + + if not workchain.is_finished_ok: + self.report( + f"PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) + return self.exit_codes.ERROR_SUB_PROCESS_FAILED_TWINNING + + self.report( + f'PwBaseWorkChain<{workchain.pk}> for twinning faulted geometry finished OK' + ) + self.out_many( + self.exposed_outputs( + workchain, + PwBaseWorkChain, + namespace=self._SFE_NAMESPACE, + ), + ) + + total_energy_twinning_geometry = self._get_workchain_energy(workchain) + self.ctx.total_energy_twinning_geometry = total_energy_twinning_geometry + self.ctx.total_energy_faulted_geometry = total_energy_twinning_geometry + self._report_energy( + total_energy_twinning_geometry, + self.ctx.twinning_multiplier, + 'twinning faulted geometry', + 'unit cells' + ) + + # Calculate stacking fault energy using helper method + twinning_stacking_fault_energy = self._calculate_stacking_fault_energy( + total_energy_twinning_geometry, + self.ctx.twinning_multiplier, + 'twinning' + ) + + self.ctx.twinning_data.append({ + 'energy_ry': float(total_energy_twinning_geometry), + 'twinning_multiplier': self.ctx.twinning_multiplier, + 'twinning_j_m2': float(twinning_stacking_fault_energy) if twinning_stacking_fault_energy is not None else None, + }) + self.ctx.twinning_done = True + + def results(self): + pass diff --git a/src/aiida_mechanical/workflows/dislocation/usfe.py b/src/aiida_mechanical/workflows/dislocation/usfe.py new file mode 100644 index 0000000..25767f2 --- /dev/null +++ b/src/aiida_mechanical/workflows/dislocation/usfe.py @@ -0,0 +1,41 @@ +from .sfebase import SFEBaseWorkChain +from .layer_relax import RigidLayerRelaxWorkChain +from aiida import orm + +class USFEWorkChain(SFEBaseWorkChain): + """USFE WorkChain""" + + _SFE_NAMESPACE = "usfe" + + @classmethod + def define(cls, spec): + super().define(spec) + + spec.exit_code( + 404, + "ERROR_SUB_PROCESS_FAILED_USF", + message='The `PwBaseWorkChain` for the USF run failed.', + ) + + @classmethod + def get_builder_from_protocol( + cls, + code, + structure, + protocol='moderate', + overrides=None, + **kwargs + ): + inputs = cls.get_protocol_inputs(protocol, overrides) + builder = super().get_builder_from_protocol( + code, structure, protocol, overrides, **kwargs) + return builder + + def _get_fault_type(self): + """Return the fault type for USFE workchain.""" + return 'unstable' + + + def results(self): + """Expose collected USFE data to the caller.""" + pass \ No newline at end of file From 93eaf58f6f643c038b2638f92f579746e0e11f62 Mon Sep 17 00:00:00 2001 From: ymzhang0 Date: Tue, 26 May 2026 16:27:24 +0200 Subject: [PATCH 17/17] ci: format and verify integrated dislocation codebase --- .../calculations/structure.py | 186 ++++--- src/aiida_mechanical/data/__init__.py | 12 +- .../data/cleavaged_structure.py | 93 ++-- .../data/faulted_structure.py | 226 +++++--- src/aiida_mechanical/data/gliding_systems.py | 377 ++++++-------- src/aiida_mechanical/data/system.py | 233 +++++---- src/aiida_mechanical/tools/builder.py | 12 +- src/aiida_mechanical/tools/cut.py | 486 ++++++++++-------- src/aiida_mechanical/tools/gliding_systems.py | 19 +- src/aiida_mechanical/tools/structure.py | 472 +++++++++-------- .../tools/structure_builder.py | 200 ++++--- src/aiida_mechanical/tools/structure_utils.py | 137 +++-- .../workflows/dislocation/__init__.py | 20 +- .../workflows/dislocation/esfe.py | 12 +- .../workflows/dislocation/gsfe.py | 467 +++++++++-------- .../workflows/dislocation/gsfe_relax.py | 474 +++++++++-------- .../workflows/dislocation/isfe.py | 9 +- .../workflows/dislocation/layer_relax.py | 285 +++++----- .../workflows/dislocation/mixins.py | 150 +++--- .../workflows/dislocation/sfebase.py | 415 ++++++++------- .../workflows/dislocation/surface.py | 379 ++++++++------ .../workflows/dislocation/twinning.py | 103 ++-- .../workflows/dislocation/usfe.py | 22 +- 23 files changed, 2666 insertions(+), 2123 deletions(-) diff --git a/src/aiida_mechanical/calculations/structure.py b/src/aiida_mechanical/calculations/structure.py index 4333574..f4257c1 100644 --- a/src/aiida_mechanical/calculations/structure.py +++ b/src/aiida_mechanical/calculations/structure.py @@ -25,46 +25,68 @@ def _normalize_faulted_structure_points( return [] if isinstance(generated, Atoms): - return [{ - 'label': f'sfe_{fault_type}_000', - 'structure': generated, - 'burger_vector': [], - 'total_cell_shift': [], - 'interface_slips': {}, - 'direction_name': fault_type, - 'step_index': 0, - }] + return [ + { + "label": f"sfe_{fault_type}_000", + "structure": generated, + "burger_vector": [], + "total_cell_shift": [], + "interface_slips": {}, + "direction_name": fault_type, + "step_index": 0, + } + ] if isinstance(generated, list): normalized: list[GeneralFaultStructurePoint] = [] for index, item in enumerate(generated): if isinstance(item, Atoms): - normalized.append({ - 'label': f'sfe_{fault_type}_{index:03d}', - 'structure': item, - 'burger_vector': [], - 'total_cell_shift': [], - 'interface_slips': {}, - 'direction_name': fault_type, - 'step_index': index, - }) + normalized.append( + { + "label": f"sfe_{fault_type}_{index:03d}", + "structure": item, + "burger_vector": [], + "total_cell_shift": [], + "interface_slips": {}, + "direction_name": fault_type, + "step_index": index, + } + ) continue - if not isinstance(item, dict) or 'structure' not in item: - raise TypeError('Unsupported faulted structure payload returned by `FaultedStructureData`.') - - normalized.append({ - 'label': str(item.get('label', f'sfe_{item.get("direction_name", fault_type)}_{index:03d}')), - 'structure': item['structure'], - 'burger_vector': [float(value) for value in item.get('burger_vector', [])], - 'total_cell_shift': [float(value) for value in item.get('total_cell_shift', item.get('burger_vector', []))], - 'interface_slips': { - int(interface): [float(value) for value in interface_shift] - for interface, interface_shift in item.get('interface_slips', {}).items() - }, - 'direction_name': item.get('direction_name', fault_type), - 'step_index': int(item.get('step_index', index)), - }) + if not isinstance(item, dict) or "structure" not in item: + raise TypeError( + "Unsupported faulted structure payload returned by `FaultedStructureData`." + ) + + normalized.append( + { + "label": str( + item.get( + "label", + f"sfe_{item.get('direction_name', fault_type)}_{index:03d}", + ) + ), + "structure": item["structure"], + "burger_vector": [ + float(value) for value in item.get("burger_vector", []) + ], + "total_cell_shift": [ + float(value) + for value in item.get( + "total_cell_shift", item.get("burger_vector", []) + ) + ], + "interface_slips": { + int(interface): [float(value) for value in interface_shift] + for interface, interface_shift in item.get( + "interface_slips", {} + ).items() + }, + "direction_name": item.get("direction_name", fault_type), + "step_index": int(item.get("step_index", index)), + } + ) return normalized @@ -74,28 +96,38 @@ def _normalize_faulted_structure_points( for direction_name, steps in general_result.items(): for step_index, entry in sorted(steps.items()): - metadata = entry['metadata'] - normalized.append({ - 'label': metadata['label'], - 'structure': entry['structure'], - 'burger_vector': [float(value) for value in metadata['burger_vector']], - 'total_cell_shift': [float(value) for value in metadata['total_cell_shift']], - 'interface_slips': { - int(interface): [float(value) for value in interface_shift] - for interface, interface_shift in metadata['interface_slips'].items() - }, - 'direction_name': direction_name, - 'step_index': int(step_index), - }) + metadata = entry["metadata"] + normalized.append( + { + "label": metadata["label"], + "structure": entry["structure"], + "burger_vector": [ + float(value) for value in metadata["burger_vector"] + ], + "total_cell_shift": [ + float(value) for value in metadata["total_cell_shift"] + ], + "interface_slips": { + int(interface): [float(value) for value in interface_shift] + for interface, interface_shift in metadata[ + "interface_slips" + ].items() + }, + "direction_name": direction_name, + "step_index": int(step_index), + } + ) return normalized - raise TypeError('Unsupported faulted structure payload returned by `FaultedStructureData`.') + raise TypeError( + "Unsupported faulted structure payload returned by `FaultedStructureData`." + ) def _format_spacing_key(vacuum_spacing: float) -> str: """Return a Dict-safe key for a vacuum spacing.""" - return f'{vacuum_spacing:.6f}'.replace('.', '_') + return f"{vacuum_spacing:.6f}".replace(".", "_") @calcfunction @@ -114,28 +146,36 @@ def generate_faulted_structures( normalized_points = _normalize_faulted_structure_points(generated, fault_type.value) if not normalized_points: - raise ValueError('No faulted structures could be generated for the requested configuration.') + raise ValueError( + "No faulted structures could be generated for the requested configuration." + ) outputs: dict[str, orm.Data] = { - 'conventional_structure': orm.StructureData(ase=builder.get_conventional_structure()), - 'surface_area': orm.Float(float(builder.surface_area)), + "conventional_structure": orm.StructureData( + ase=builder.get_conventional_structure() + ), + "surface_area": orm.Float(float(builder.surface_area)), } for point in normalized_points: - key = point['label'] - structure_node = orm.StructureData(ase=point['structure']) + key = point["label"] + structure_node = orm.StructureData(ase=point["structure"]) structure_node.label = key - structure_node.base.extras.set_many({ - 'label': key, - 'direction_name': point['direction_name'], - 'step_index': int(point['step_index']), - 'burger_vector': [float(value) for value in point['burger_vector']], - 'total_cell_shift': [float(value) for value in point['total_cell_shift']], - 'interface_slips': { - str(interface): [float(value) for value in interface_shift] - for interface, interface_shift in point['interface_slips'].items() - }, - }) + structure_node.base.extras.set_many( + { + "label": key, + "direction_name": point["direction_name"], + "step_index": int(point["step_index"]), + "burger_vector": [float(value) for value in point["burger_vector"]], + "total_cell_shift": [ + float(value) for value in point["total_cell_shift"] + ], + "interface_slips": { + str(interface): [float(value) for value in interface_shift] + for interface, interface_shift in point["interface_slips"].items() + }, + } + ) outputs[key] = structure_node return outputs @@ -151,20 +191,26 @@ def generate_cleavaged_structures( vacuum_spacings = cleavaged_data.vacuum_spacings if not vacuum_spacings: - raise ValueError('No vacuum spacings configured for cleavaged structure generation.') + raise ValueError( + "No vacuum spacings configured for cleavaged structure generation." + ) outputs: dict[str, orm.Data] = { - 'conventional_structure': orm.StructureData(ase=builder.get_conventional_structure()), - 'surface_area': orm.Float(float(builder.surface_area)), + "conventional_structure": orm.StructureData( + ase=builder.get_conventional_structure() + ), + "surface_area": orm.Float(float(builder.surface_area)), } for vacuum_spacing in vacuum_spacings: spacing_key = _format_spacing_key(float(vacuum_spacing)) - slab_key = f'slab_{spacing_key}' - spacing_output_key = f'vacuum_spacing_{spacing_key}' + slab_key = f"slab_{spacing_key}" + spacing_output_key = f"vacuum_spacing_{spacing_key}" if slab_key in outputs or spacing_output_key in outputs: - raise ValueError(f'Duplicate vacuum spacing key generated for {vacuum_spacing}.') + raise ValueError( + f"Duplicate vacuum spacing key generated for {vacuum_spacing}." + ) outputs[spacing_output_key] = orm.Float(float(vacuum_spacing)) outputs[slab_key] = orm.StructureData( diff --git a/src/aiida_mechanical/data/__init__.py b/src/aiida_mechanical/data/__init__.py index 8b67019..da409c4 100644 --- a/src/aiida_mechanical/data/__init__.py +++ b/src/aiida_mechanical/data/__init__.py @@ -1,6 +1,14 @@ from .printer import Printer -from .cleavaged_structure import CleavagedStructure, CleavagedStructureData, PlanarStructure -from .faulted_structure import FaultedStructure, FaultedStructureData, GeneralFaultStructurePoint +from .cleavaged_structure import ( + CleavagedStructure, + CleavagedStructureData, + PlanarStructure, +) +from .faulted_structure import ( + FaultedStructure, + FaultedStructureData, + GeneralFaultStructurePoint, +) __all__ = ( "Printer", diff --git a/src/aiida_mechanical/data/cleavaged_structure.py b/src/aiida_mechanical/data/cleavaged_structure.py index 181676c..32a84df 100644 --- a/src/aiida_mechanical/data/cleavaged_structure.py +++ b/src/aiida_mechanical/data/cleavaged_structure.py @@ -4,6 +4,7 @@ import numpy from aiida.common.exceptions import ModificationNotAllowed +from aiida import orm from aiida.orm import Data from ase import Atoms from ase.build import make_supercell @@ -18,6 +19,7 @@ from aiida_mechanical.tools.structure_builder import build_atoms_surface from aiida_mechanical.tools.structure_utils import get_strukturbericht, group_by_layers + def find_rotation(src_vectors, dst_vectors): """ Finds the rotation matrix R, axis, and angle between two sets of vectors. @@ -27,39 +29,38 @@ def find_rotation(src_vectors, dst_vectors): src_norm = src_vectors / numpy.linalg.norm(src_vectors, axis=0) dst_norm = dst_vectors / numpy.linalg.norm(dst_vectors, axis=0) - + # 1. Compute Covariance Matrix H H = dst_norm @ src_norm.T - + # 2. SVD U, S, Vt = numpy.linalg.svd(H) - + # 3. Compute Rotation Matrix R R = U @ Vt - + # Special reflection check if numpy.linalg.det(R) < 0: U[:, -1] *= -1 R = U @ Vt - + # 4. Extract Angle trace_r = numpy.trace(R) angle = numpy.arccos(numpy.clip((trace_r - 1.0) / 2.0, -1.0, 1.0)) - + # 5. Extract Axis # R - R.T = 2 * sin(theta) * skew_symmetric(axis) if angle < 1e-6: - axis = numpy.array([0.0, 0.0, 1.0]) # Arbitrary axis for zero rotation + axis = numpy.array([0.0, 0.0, 1.0]) # Arbitrary axis for zero rotation else: - axis_vals = numpy.array([ - R[2, 1] - R[1, 2], - R[0, 2] - R[2, 0], - R[1, 0] - R[0, 1] - ]) + axis_vals = numpy.array( + [R[2, 1] - R[1, 2], R[0, 2] - R[2, 0], R[1, 0] - R[0, 1]] + ) axis = axis_vals / (2 * numpy.sin(angle)) axis = axis / numpy.linalg.norm(axis) - - return angle* 180 / numpy.pi, axis + + return angle * 180 / numpy.pi, axis + class PlanarStructure: """Shared helper for conventional cell operations on a selected gliding plane.""" @@ -97,7 +98,7 @@ def strukturbericht(self) -> str: """Get the Strukturbericht designation.""" strukturbericht = get_strukturbericht(self.unit_cell) if not strukturbericht: - raise ValueError('No match found in the provided list of prototypes.') + raise ValueError("No match found in the provided list of prototypes.") return strukturbericht @property @@ -105,7 +106,7 @@ def gliding_system(self) -> GlidingSystem: """Get the gliding system instance.""" gliding_system = get_gliding_system(self.strukturbericht) if not gliding_system: - raise ValueError('No match found in the provided list of prototypes.') + raise ValueError("No match found in the provided list of prototypes.") return gliding_system @property @@ -121,7 +122,10 @@ def wyckoff_elements(self) -> dict[str, str]: pmg_struct = AseAtomsAdaptor.get_structure(self.unit_cell) sga = SpacegroupAnalyzer(pmg_struct, symprec=1e-5) symmetrized = sga.get_symmetrized_structure() - return {w: e.symbol for w, e in zip(symmetrized.wyckoff_letters, symmetrized.elements)} + return { + w: e.symbol + for w, e in zip(symmetrized.wyckoff_letters, symmetrized.elements) + } @property def surface_area(self) -> float: @@ -139,27 +143,30 @@ def _prepare_plane_data(self) -> GlidingPlaneConfig: def get_conventional_structure( self, - P: ty.Optional[ty.Union[list[ty.Any], 'numpy.ndarray']] = None, + P: ty.Optional[ty.Union[list[ty.Any], "numpy.ndarray"]] = None, print_info: bool = False, ) -> Atoms: """Generate the conventional structure.""" if print_info: - print(f'Strukturbericht {self.strukturbericht} detected') + print(f"Strukturbericht {self.strukturbericht} detected") plane_config = self._prepare_plane_data() if P is None: P = plane_config.transformation_matrix else: P = numpy.array(P) - + conventional_structure = make_supercell(self.unit_cell, P) target_unit_vectors = plane_config.target_unit_vectors if target_unit_vectors is not None: - angle, axis = find_rotation(conventional_structure.cell.T, numpy.array(target_unit_vectors).T) - conventional_structure.rotate(angle, axis, rotate_cell =True) + angle, axis = find_rotation( + conventional_structure.cell.T, numpy.array(target_unit_vectors).T + ) + conventional_structure.rotate(angle, axis, rotate_cell=True) return conventional_structure + class CleavagedStructure(PlanarStructure): """Helper for conventional and cleavaged structure generation.""" @@ -170,7 +177,7 @@ def get_cleavaged_structure( ) -> Atoms: """Generate the cleavaged surface structure from the conventional cell.""" if print_info: - print(f'Strukturbericht {self.strukturbericht} detected') + print(f"Strukturbericht {self.strukturbericht} detected") conventional_structure = self.get_conventional_structure() plane_config = self._prepare_plane_data() @@ -178,8 +185,8 @@ def get_cleavaged_structure( if len(layers_dict) != plane_config.n_layers: raise ValueError( - f'Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers} for ' - f'{self.strukturbericht} with gliding plane {self._get_effective_gliding_plane()}.' + f"Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers} for " + f"{self.strukturbericht} with gliding plane {self._get_effective_gliding_plane()}." ) return build_atoms_surface( @@ -194,9 +201,9 @@ def get_cleavaged_structure( class CleavagedStructureData(Data): """Pure configuration node for cleavaged slab generation.""" - N_UNIT_CELLS_KEY = 'n_unit_cells' - GLIDING_PLANE_KEY = 'gliding_plane' - VACUUM_SPACINGS_KEY = 'vacuum_spacings' + N_UNIT_CELLS_KEY = "n_unit_cells" + GLIDING_PLANE_KEY = "gliding_plane" + VACUUM_SPACINGS_KEY = "vacuum_spacings" def __init__( self, @@ -210,8 +217,10 @@ def __init__( return resolved_n_unit_cells = 4 if n_unit_cells is None else int(n_unit_cells) - resolved_gliding_plane = '' if gliding_plane is None else str(gliding_plane) - resolved_vacuum_spacings = [float(value) for value in (vacuum_spacings or [1.0])] + resolved_gliding_plane = "" if gliding_plane is None else str(gliding_plane) + resolved_vacuum_spacings = [ + float(value) for value in (vacuum_spacings or [1.0]) + ] self._set_attribute(self.N_UNIT_CELLS_KEY, resolved_n_unit_cells) self._set_attribute(self.GLIDING_PLANE_KEY, resolved_gliding_plane) @@ -220,7 +229,9 @@ def __init__( def _set_attribute(self, key: str, value: ty.Any) -> None: """Set an attribute before storing the node.""" if self.is_stored: - raise ModificationNotAllowed('`CleavagedStructureData` attributes cannot be modified after storing.') + raise ModificationNotAllowed( + "`CleavagedStructureData` attributes cannot be modified after storing." + ) self.base.attributes.set(key, value) @property @@ -231,18 +242,26 @@ def n_unit_cells(self) -> int: @property def gliding_plane(self) -> str: """Return the configured gliding plane.""" - return str(self.base.attributes.get(self.GLIDING_PLANE_KEY, '')) + return str(self.base.attributes.get(self.GLIDING_PLANE_KEY, "")) @property def vacuum_spacings(self) -> list[float]: """Return the configured vacuum spacings.""" - return [float(value) for value in self.base.attributes.get(self.VACUUM_SPACINGS_KEY, [1.0])] - - def get_structure_builder(self, structure: 'orm.StructureData | Atoms') -> CleavagedStructure: + return [ + float(value) + for value in self.base.attributes.get(self.VACUUM_SPACINGS_KEY, [1.0]) + ] + + def get_structure_builder( + self, structure: "orm.StructureData | Atoms" + ) -> CleavagedStructure: """Return a helper bound to a specific structure.""" - from aiida import orm - ase_atoms = structure.get_ase() if isinstance(structure, orm.StructureData) else structure + ase_atoms = ( + structure.get_ase() + if isinstance(structure, orm.StructureData) + else structure + ) effective_gliding_plane = self.gliding_plane or None return CleavagedStructure( ase_atoms=ase_atoms, diff --git a/src/aiida_mechanical/data/faulted_structure.py b/src/aiida_mechanical/data/faulted_structure.py index 0b588ff..cda0518 100644 --- a/src/aiida_mechanical/data/faulted_structure.py +++ b/src/aiida_mechanical/data/faulted_structure.py @@ -5,6 +5,7 @@ import numpy from aiida.common.exceptions import ModificationNotAllowed +from aiida import orm from aiida.orm import Data from ase import Atoms @@ -15,11 +16,10 @@ ) from aiida_mechanical.tools.structure_builder import ( build_atoms_from_stacking_removal, - build_atoms_from_stacking_mirror, build_atoms_from_burger_vector_with_vacuum, build_atoms_from_burger_vector_general, build_atoms_from_burger_vector, - update_faults + update_faults, ) @@ -54,14 +54,16 @@ class GeneralFaultStructureEntry(ty.TypedDict): GeneralFaultStructureResult = dict[str, dict[int, GeneralFaultStructureEntry]] -FaultedStructureResult = ty.Union[Atoms, list[dict[str, ty.Any]], GeneralFaultStructureResult] +FaultedStructureResult = ty.Union[ + Atoms, list[dict[str, ty.Any]], GeneralFaultStructureResult +] class FaultedStructure(PlanarStructure): """ A class to handle dislocation structures and their manipulations using ASE Atoms. """ - + @staticmethod def _serialize_vector(vector: ty.Union[numpy.ndarray, list[float]]) -> list[float]: """Return a JSON-serializable vector of floats.""" @@ -84,69 +86,83 @@ def _build_general_fault_entry( for interface, interface_shift in interface_slips.items() } metadata: GeneralFaultStructureMetadata = { - 'label': f'sfe_{direction_name}_{step_index:03d}', - 'direction_name': direction_name, - 'step_index': step_index, - 'burger_vector': total_cell_shift_serialized, - 'total_cell_shift': total_cell_shift_serialized, - 'interface_slips': interface_slips_snapshot, + "label": f"sfe_{direction_name}_{step_index:03d}", + "direction_name": direction_name, + "step_index": step_index, + "burger_vector": total_cell_shift_serialized, + "total_cell_shift": total_cell_shift_serialized, + "interface_slips": interface_slips_snapshot, } return { - 'structure': structure, - 'metadata': metadata, + "structure": structure, + "metadata": metadata, } - def get_faulted_structure(self, - fault_mode: str, - fault_type: str, - additional_spacing: float = 0.0, - vacuum_ratio: float = 0.0, - print_info: bool = False, - **kwargs) -> ty.Optional[FaultedStructureResult]: + def get_faulted_structure( + self, + fault_mode: str, + fault_type: str, + additional_spacing: float = 0.0, + vacuum_ratio: float = 0.0, + print_info: bool = False, + **kwargs, + ) -> ty.Optional[FaultedStructureResult]: """ Generate faulted structure. Returns faulted structures for the requested mode. """ - if fault_mode not in ['removal', 'vacuum', 'general']: - raise ValueError(f"fault_mode must be one of 'removal', 'vacuum', 'general', got '{fault_mode}'") + if fault_mode not in ["removal", "vacuum", "general"]: + raise ValueError( + f"fault_mode must be one of 'removal', 'vacuum', 'general', got '{fault_mode}'" + ) - if fault_mode == 'removal' and fault_type not in ['intrinsic', 'unstable', 'extrinsic']: - raise ValueError(f"fault_type must be one of 'intrinsic', 'unstable', or 'extrinsic', got '{fault_type}'") + if fault_mode == "removal" and fault_type not in [ + "intrinsic", + "unstable", + "extrinsic", + ]: + raise ValueError( + f"fault_type must be one of 'intrinsic', 'unstable', or 'extrinsic', got '{fault_type}'" + ) if print_info: - print(f'Strukturbericht {self.strukturbericht} detected') + print(f"Strukturbericht {self.strukturbericht} detected") conventional_structure = self.get_conventional_structure() - + plane_config = self._prepare_plane_data() - + layers_dict = group_by_layers(conventional_structure) - + if len(layers_dict) != plane_config.n_layers: raise ValueError( - f'Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers}.' + f"Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers}." ) fault_config = getattr(plane_config, fault_type) if not fault_config.possible: return None - + faulted_result = None # Removal Mode - if fault_mode == 'removal' and fault_config.removal_layers is not None: + if fault_mode == "removal" and fault_config.removal_layers is not None: structure = build_atoms_from_stacking_removal( conventional_structure, self.n_unit_cells, fault_config.removal_layers, layers_dict, additional_spacing=(fault_config.interface, additional_spacing), - print_info=print_info + print_info=print_info, ) faulted_result = structure # Vacuum Mode - if fault_mode == 'vacuum' and vacuum_ratio > 0.0 and fault_config.burger_vectors is not None: + if ( + fault_mode == "vacuum" + and vacuum_ratio > 0.0 + and fault_config.burger_vectors is not None + ): structures_list = [] for burger_vector in fault_config.burger_vectors: structure = build_atoms_from_burger_vector_with_vacuum( @@ -155,21 +171,27 @@ def get_faulted_structure(self, burger_vector, layers_dict, vacuum_ratio=vacuum_ratio, - print_info=print_info + print_info=print_info, + ) + structures_list.append( + { + "structure": structure, + "burger_vector": burger_vector, + } ) - structures_list.append({ - 'structure': structure, - 'burger_vector': burger_vector, - }) faulted_result = structures_list # General Mode - if fault_mode == 'general' and fault_config.burger_vectors is not None: + if fault_mode == "general" and fault_config.burger_vectors is not None: structures_by_direction: GeneralFaultStructureResult = {} - nsteps = kwargs.get('nsteps', fault_config.nsteps) - stacking_order = ''.join(layers_dict.keys()) - - zs = [(value['z'] + layer) / self.n_unit_cells for layer in range(self.n_unit_cells) for value in layers_dict.values()] + nsteps = kwargs.get("nsteps", fault_config.nsteps) + stacking_order = "".join(layers_dict.keys()) + + zs = [ + (value["z"] + layer) / self.n_unit_cells + for layer in range(self.n_unit_cells) + for value in layers_dict.values() + ] stacking_order_supercell = stacking_order * self.n_unit_cells new_cell = conventional_structure.cell.array.copy() @@ -185,39 +207,59 @@ def get_faulted_structure(self, # Initial state (0 displacement) structure = build_atoms_from_burger_vector_general( - new_cell, deepcopy(zs), layers_dict, stacking_order_supercell, - burgers_vector_for_cell, faults, print_info=print_info + new_cell, + deepcopy(zs), + layers_dict, + stacking_order_supercell, + burgers_vector_for_cell, + faults, + print_info=print_info, ) - structures_by_direction[direction_name][step_index] = self._build_general_fault_entry( - direction_name=direction_name, - step_index=step_index, - structure=structure, - total_cell_shift=burgers_vector_for_cell, - interface_slips=interface_slips, + structures_by_direction[direction_name][step_index] = ( + self._build_general_fault_entry( + direction_name=direction_name, + step_index=step_index, + structure=structure, + total_cell_shift=burgers_vector_for_cell, + interface_slips=interface_slips, + ) ) for interface, burgers_vector in segment: burgers_vector_step = numpy.array(burgers_vector) / nsteps - for _ in range(1, 1+nsteps): + for _ in range(1, 1 + nsteps): step_index += 1 - current_interface_shift = interface_slips.get(interface, numpy.zeros(3)) - interface_slips[interface] = current_interface_shift + burgers_vector_step - faults = update_faults(faults, interface, burgers_vector_step) + current_interface_shift = interface_slips.get( + interface, numpy.zeros(3) + ) + interface_slips[interface] = ( + current_interface_shift + burgers_vector_step + ) + faults = update_faults( + faults, interface, burgers_vector_step + ) burgers_vector_for_cell += burgers_vector_step structure = build_atoms_from_burger_vector_general( - new_cell, deepcopy(zs), layers_dict, stacking_order_supercell, - burgers_vector_for_cell, faults, print_info=print_info + new_cell, + deepcopy(zs), + layers_dict, + stacking_order_supercell, + burgers_vector_for_cell, + faults, + print_info=print_info, ) - structures_by_direction[direction_name][step_index] = self._build_general_fault_entry( - direction_name=direction_name, - step_index=step_index, - structure=structure, - total_cell_shift=burgers_vector_for_cell, - interface_slips=interface_slips, + structures_by_direction[direction_name][step_index] = ( + self._build_general_fault_entry( + direction_name=direction_name, + step_index=step_index, + structure=structure, + total_cell_shift=burgers_vector_for_cell, + interface_slips=interface_slips, + ) ) - + faulted_result = structures_by_direction - + return faulted_result def _build_faulted_structure_helper( @@ -230,32 +272,45 @@ def _build_faulted_structure_helper( """Internal helper for unstable/intrinsic fault building.""" if not config.possible: return None - + if config.removal_layers is not None: structure = build_atoms_from_stacking_removal( - ase_atoms_t, self.n_unit_cells, config.removal_layers, layers_dict, - additional_spacing=(config.interface, 0.0), print_info=print_info + ase_atoms_t, + self.n_unit_cells, + config.removal_layers, + layers_dict, + additional_spacing=(config.interface, 0.0), + print_info=print_info, ) return structure - - if config.burger_vectors is not None and isinstance(config.burger_vectors, list): + + if config.burger_vectors is not None and isinstance( + config.burger_vectors, list + ): structures_list = [] for bv in config.burger_vectors: structure = build_atoms_from_burger_vector( - ase_atoms_t, self.n_unit_cells, bv, layers_dict, print_info=print_info + ase_atoms_t, + self.n_unit_cells, + bv, + layers_dict, + print_info=print_info, + ) + structures_list.append( + { + "structure": structure, + "burger_vector": bv, + } ) - structures_list.append({ - 'structure': structure, - 'burger_vector': bv, - }) return structures_list return None + class FaultedStructureData(Data): """Pure configuration node for faulted-structure generation.""" - N_UNIT_CELLS_KEY = 'n_unit_cells' - GLIDING_PLANE_KEY = 'gliding_plane' + N_UNIT_CELLS_KEY = "n_unit_cells" + GLIDING_PLANE_KEY = "gliding_plane" def __init__( self, @@ -268,7 +323,7 @@ def __init__( return resolved_n_unit_cells = 4 if n_unit_cells is None else int(n_unit_cells) - resolved_gliding_plane = '' if gliding_plane is None else str(gliding_plane) + resolved_gliding_plane = "" if gliding_plane is None else str(gliding_plane) self._set_attribute(self.N_UNIT_CELLS_KEY, resolved_n_unit_cells) self._set_attribute(self.GLIDING_PLANE_KEY, resolved_gliding_plane) @@ -276,7 +331,9 @@ def __init__( def _set_attribute(self, key: str, value: ty.Any) -> None: """Set an attribute before storing the node.""" if self.is_stored: - raise ModificationNotAllowed('`FaultedStructureData` attributes cannot be modified after storing.') + raise ModificationNotAllowed( + "`FaultedStructureData` attributes cannot be modified after storing." + ) self.base.attributes.set(key, value) @property @@ -287,13 +344,18 @@ def n_unit_cells(self) -> int: @property def gliding_plane(self) -> str: """Return the configured gliding plane.""" - return str(self.base.attributes.get(self.GLIDING_PLANE_KEY, '')) + return str(self.base.attributes.get(self.GLIDING_PLANE_KEY, "")) - def get_structure_builder(self, structure: 'orm.StructureData | Atoms') -> FaultedStructure: + def get_structure_builder( + self, structure: "orm.StructureData | Atoms" + ) -> FaultedStructure: """Return a helper bound to a specific structure.""" - from aiida import orm - ase_atoms = structure.get_ase() if isinstance(structure, orm.StructureData) else structure + ase_atoms = ( + structure.get_ase() + if isinstance(structure, orm.StructureData) + else structure + ) effective_gliding_plane = self.gliding_plane or None return FaultedStructure( ase_atoms=ase_atoms, diff --git a/src/aiida_mechanical/data/gliding_systems.py b/src/aiida_mechanical/data/gliding_systems.py index 7bce948..fbd5494 100644 --- a/src/aiida_mechanical/data/gliding_systems.py +++ b/src/aiida_mechanical/data/gliding_systems.py @@ -11,6 +11,7 @@ @dataclass class FaultConfig: """Configuration for a fault type (intrinsic, unstable, or extrinsic).""" + removal_layers: ty.Union[list[int], int] = None burger_vectors: ty.Optional[BurgerVectorConfig] = None periodicity: bool = False @@ -18,9 +19,11 @@ class FaultConfig: interface: int = 0 nsteps: int = 1 + @dataclass class GlidingPlaneConfig: """Configuration for a specific gliding plane.""" + transformation_matrix: list[list[int]] transformation_matrix_c: ty.Optional[list[list[int]]] = None target_unit_vectors: ty.Optional[list[list[float]]] = None @@ -30,56 +33,54 @@ class GlidingPlaneConfig: extrinsic: FaultConfig = field(default_factory=FaultConfig) general: FaultConfig = field(default_factory=FaultConfig) + class GlidingSystem(ABC): """Base class for gliding system configurations.""" - - default_plane: str = '111' # Default gliding plane, can be overridden by subclasses - + + default_plane: str = "111" # Default gliding plane, can be overridden by subclasses + def __init__(self, strukturbericht: str): self.strukturbericht = strukturbericht self._planes: dict[str, GlidingPlaneConfig] = {} self._register_planes() - + @abstractmethod def _register_planes(self): """Register all gliding planes for this system.""" pass - + def get_plane(self, gliding_plane: str) -> GlidingPlaneConfig: """Get configuration for a specific gliding plane.""" if gliding_plane not in self._planes: raise ValueError( - f'Gliding plane {gliding_plane} is not supported for {self.strukturbericht}. ' - f'Supported planes: {list(self._planes.keys())}' + f"Gliding plane {gliding_plane} is not supported for {self.strukturbericht}. " + f"Supported planes: {list(self._planes.keys())}" ) return self._planes[gliding_plane] - + def list_planes(self) -> list[str]: """List all supported gliding planes.""" return list(self._planes.keys()) + # Concrete implementations class A1GlidingSystem(GlidingSystem): """A1 (FCC) gliding system.""" - + def _register_planes(self): - self._planes['100'] = GlidingPlaneConfig( + self._planes["100"] = GlidingPlaneConfig( transformation_matrix=[[1, 0, 0], [0, -1, 1], [-1, 1, 1]], n_layers=2, intrinsic=FaultConfig(possible=False), extrinsic=FaultConfig(possible=False), general=FaultConfig( possible=True, - burger_vectors={ - '100': ( - (2, [1, 0, 0]), - ) - }, + burger_vectors={"100": ((2, [1, 0, 0]),)}, # interface=(2, ), - nsteps = 10 - ) + nsteps=10, + ), ) - self._planes['011'] = GlidingPlaneConfig( + self._planes["011"] = GlidingPlaneConfig( transformation_matrix=[[0, 1, -1], [-1, 1, 1], [1, 0, 0]], transformation_matrix_c=[[0, 1, -1], [-1, 1, 1], [1, 0, 0]], n_layers=2, @@ -87,408 +88,350 @@ def _register_planes(self): extrinsic=FaultConfig(possible=False), general=FaultConfig( possible=True, - burger_vectors={ - '010': ( - (2, [0, 1, 0]), - ) - }, + burger_vectors={"010": ((2, [0, 1, 0]),)}, # interface=(2, ), - nsteps = 10 - ) + nsteps=10, + ), ) - self._planes['111'] = GlidingPlaneConfig( + self._planes["111"] = GlidingPlaneConfig( transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], transformation_matrix_c=[[1, -1, 0], [1, 1, -2], [1, 1, 1]], - target_unit_vectors = [[1, 1, 0], [-1, 1, 0], [0, 0, 1]], + target_unit_vectors=[[1, 1, 0], [-1, 1, 0], [0, 0, 1]], n_layers=3, intrinsic=FaultConfig( possible=True, removal_layers=[3], - burger_vectors=[[1/3, 1/3, 0]], + burger_vectors=[[1 / 3, 1 / 3, 0]], periodicity=False, interface=3, ), extrinsic=FaultConfig( possible=True, removal_layers=[3, 5], - burger_vectors=[[2/3, 2/3, 0]], - periodicity=False + burger_vectors=[[2 / 3, 2 / 3, 0]], + periodicity=False, ), general=FaultConfig( possible=True, burger_vectors={ - '110': ( - (3, [1/3, 1/3, 0]), - (4, [1/3, 1/3, 0]), + "110": ( + (3, [1 / 3, 1 / 3, 0]), + (4, [1 / 3, 1 / 3, 0]), ), }, interface=(3, 4), - nsteps = 8 - ) + nsteps=8, + ), ) + class A2GlidingSystem(GlidingSystem): """A2 (BCC) gliding system.""" - + def _register_planes(self): - self._planes['100'] = GlidingPlaneConfig( + self._planes["100"] = GlidingPlaneConfig( transformation_matrix=[[1, 1, 0], [1, 0, 1], [0, 1, 1]], n_layers=2, - intrinsic=FaultConfig( - removal_layers=[2] - ), - unstable=FaultConfig( - removal_layers=[2] - ), + intrinsic=FaultConfig(removal_layers=[2]), + unstable=FaultConfig(removal_layers=[2]), general=FaultConfig( possible=True, - burger_vectors={ - '100': ( - (2, [1, 0, 0]), - ) - }, + burger_vectors={"100": ((2, [1, 0, 0]),)}, # interface=[2, ], - nsteps = 10 - ) + nsteps=10, + ), ) - self._planes['011'] = GlidingPlaneConfig( + self._planes["011"] = GlidingPlaneConfig( transformation_matrix=[[0, 1, 0], [0, 0, 1], [2, 1, 1]], transformation_matrix_c=[[0, 1, -1], [0, 1, 1], [2, 1, 1]], n_layers=2, - intrinsic=FaultConfig( - removal_layers=[2] - ), - unstable=FaultConfig( - removal_layers=[2] - ), + intrinsic=FaultConfig(removal_layers=[2]), + unstable=FaultConfig(removal_layers=[2]), general=FaultConfig( possible=True, - burger_vectors={ - '100': ( - (2, [1, 0, 0]), - ) - }, + burger_vectors={"100": ((2, [1, 0, 0]),)}, # interface=[2,], - nsteps = 10 - ) + nsteps=10, + ), ) - self._planes['111'] = GlidingPlaneConfig( + self._planes["111"] = GlidingPlaneConfig( transformation_matrix=[[-1, 1, 0], [-1, 0, 1], [1, 1, 1]], transformation_matrix_c=[[-2, 1, 1], [0, -1, 1], [2, 2, 2]], n_layers=3, intrinsic=FaultConfig( - removal_layers=[3], interface=3, - burger_vectors=[[1/3, 1/3, 0]], + removal_layers=[3], + interface=3, + burger_vectors=[[1 / 3, 1 / 3, 0]], periodicity=False, ), extrinsic=FaultConfig( removal_layers=[3, 5], - burger_vectors=[[2/3, 2/3, 0]], + burger_vectors=[[2 / 3, 2 / 3, 0]], periodicity=False, ), general=FaultConfig( possible=True, burger_vectors={ - '110': ( - (3, [1/3, 1/3, 0]), - (4, [1/3, 1/3, 0]), + "110": ( + (3, [1 / 3, 1 / 3, 0]), + (4, [1 / 3, 1 / 3, 0]), ) - }, + }, # interface=(3, 4), - nsteps = 8 - ) + nsteps=8, + ), ) + class B1GlidingSystem(GlidingSystem): """B1 (NaCl) gliding system.""" - + def _register_planes(self): - self._planes['100'] = GlidingPlaneConfig( + self._planes["100"] = GlidingPlaneConfig( transformation_matrix=[[1, 0, 0], [0, -1, 1], [-1, 1, 1]], n_layers=2, general=FaultConfig( possible=True, - burger_vectors={ - '100': ( - (2, [1, 0, 0]), - ) - }, + burger_vectors={"100": ((2, [1, 0, 0]),)}, # interface=[2, ], - nsteps = 10 - ) + nsteps=10, + ), ) - self._planes['011'] = GlidingPlaneConfig( + self._planes["011"] = GlidingPlaneConfig( transformation_matrix=[[0, 1, -1], [-1, 1, 1], [1, 0, 0]], n_layers=2, general=FaultConfig( possible=True, burger_vectors={ - '100': ( - (2, [1, 0, 0]), - ), - '010': ( - (2, [0, 1, 0]), - ), - }, - interface=[2, ], - nsteps = 8 - ) + "100": ((2, [1, 0, 0]),), + "010": ((2, [0, 1, 0]),), + }, + interface=[ + 2, + ], + nsteps=8, + ), ) - self._planes['111'] = GlidingPlaneConfig( + self._planes["111"] = GlidingPlaneConfig( transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], transformation_matrix_c=[[1, -1, 0], [1, 1, -2], [1, 1, 1]], n_layers=6, intrinsic=FaultConfig( - removal_layers=[6, 7, 8, 9], interface=6, - burger_vectors=[[1/3, 1/3, 0]], + removal_layers=[6, 7, 8, 9], + interface=6, + burger_vectors=[[1 / 3, 1 / 3, 0]], periodicity=False, ), general=FaultConfig( possible=True, burger_vectors={ - '110': ( - (6, [1/3, 1/3, 0]), - (7, [1/3, 1/3, 0]), + "110": ( + (6, [1 / 3, 1 / 3, 0]), + (7, [1 / 3, 1 / 3, 0]), ) - }, + }, # interface=(6, ), - nsteps = 10 - ) + nsteps=10, + ), ) + class B2GlidingSystem(GlidingSystem): """B2 (CsCl) gliding system.""" - + def _register_planes(self): - self._planes['100'] = GlidingPlaneConfig( + self._planes["100"] = GlidingPlaneConfig( transformation_matrix=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], n_layers=2, general=FaultConfig( possible=True, - burger_vectors={ - '100': ( - (2, [1, 0, 0]), - ) - }, + burger_vectors={"100": ((2, [1, 0, 0]),)}, # interface=[2, ], - nsteps = 10 - ) + nsteps=10, + ), ) - self._planes['011'] = GlidingPlaneConfig( + self._planes["011"] = GlidingPlaneConfig( transformation_matrix=[[0, 1, -1], [1, 0, 0], [0, 1, 1]], n_layers=2, general=FaultConfig( possible=True, burger_vectors={ - '100': ( - (2, [1, 0, 0]), - ), - '010': ( - (2, [0, 1, 0]), - ), - '110': ( - (2, [1, 1, 0]), - ), - }, + "100": ((2, [1, 0, 0]),), + "010": ((2, [0, 1, 0]),), + "110": ((2, [1, 1, 0]),), + }, # interface=[2, ], - nsteps = 6 - ) + nsteps=6, + ), ) - self._planes['111'] = GlidingPlaneConfig( + self._planes["111"] = GlidingPlaneConfig( transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], transformation_matrix_c=[[1, -1, 0], [1, 1, -2], [1, 1, 1]], n_layers=6, intrinsic=FaultConfig( - removal_layers=[6, 7, 8, 9], interface=6, - burger_vectors=[[1/3, 1/3, 0]], + removal_layers=[6, 7, 8, 9], + interface=6, + burger_vectors=[[1 / 3, 1 / 3, 0]], periodicity=False, ), general=FaultConfig( possible=True, - burger_vectors={ - '110': ( - (6, [1/3, 1/3, 0]), - ) - }, + burger_vectors={"110": ((6, [1 / 3, 1 / 3, 0]),)}, # interface=(6, ), - nsteps = 10 - ) + nsteps=10, + ), ) + class C1bGlidingSystem(GlidingSystem): """C1b (Half-Heusler) gliding system.""" - + def _register_planes(self): - self._planes['100'] = GlidingPlaneConfig( + self._planes["100"] = GlidingPlaneConfig( transformation_matrix=[[0, -1, 1], [1, 0, 0], [-1, 1, 1]], target_unit_vectors=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], n_layers=4, intrinsic=FaultConfig( removal_layers=[2], - burger_vectors=[[1/2, 0, 0], [0, 1/2, 0], [1/2, 1/2, 0]], + burger_vectors=[[1 / 2, 0, 0], [0, 1 / 2, 0], [1 / 2, 1 / 2, 0]], periodicity=False, interface=2, ), general=FaultConfig( possible=True, - burger_vectors={ - '110': ( - (4, [1, 1, 0]), - ) - }, + burger_vectors={"110": ((4, [1, 1, 0]),)}, # interface=(4, 4), - nsteps = 8 - ) + nsteps=8, + ), ) - self._planes['011'] = GlidingPlaneConfig( + self._planes["011"] = GlidingPlaneConfig( transformation_matrix=[[0, 1, -1], [-1, 1, 1], [1, 0, 0]], target_unit_vectors=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], n_layers=2, intrinsic=FaultConfig( removal_layers=[2], - burger_vectors=[[1/2, 0, 0], [0, 1/2, 0], [1/2, 1/2, 0]], + burger_vectors=[[1 / 2, 0, 0], [0, 1 / 2, 0], [1 / 2, 1 / 2, 0]], periodicity=False, interface=2, ), general=FaultConfig( possible=True, burger_vectors={ - '100': ( - (2, [1, 0, 0]), - ), - '010': ( - (2, [0, 1, 0]), - ), - '210': ( - (2, [2, 1, 0]), - ) - }, + "100": ((2, [1, 0, 0]),), + "010": ((2, [0, 1, 0]),), + "210": ((2, [2, 1, 0]),), + }, # interface=(2, 2), - nsteps = 12 - ) + nsteps=12, + ), ) - self._planes['111'] = GlidingPlaneConfig( + self._planes["111"] = GlidingPlaneConfig( transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], transformation_matrix_c=[[1, -1, 0], [1, 1, -2], [1, 1, 1]], n_layers=9, intrinsic=FaultConfig( removal_layers=[9, 10, 11, 12, 13, 14], - burger_vectors=[[1/3, 1/3, 0]], + burger_vectors=[[1 / 3, 1 / 3, 0]], periodicity=False, interface=9, ), general=FaultConfig( possible=True, - burger_vectors={ - '110': ( - (9, [1/3, 1/3, 0]), - ) - }, + burger_vectors={"110": ((9, [1 / 3, 1 / 3, 0]),)}, # interface=9, - nsteps = 10 - ) + nsteps=10, + ), ) + class L21GlidingSystem(GlidingSystem): """L21 (Heusler) gliding system.""" - + def _register_planes(self): - self._planes['100'] = GlidingPlaneConfig( + self._planes["100"] = GlidingPlaneConfig( transformation_matrix=[[0, -1, 1], [1, 0, 0], [-1, 1, 1]], target_unit_vectors=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], n_layers=4, intrinsic=FaultConfig( removal_layers=[2], - burger_vectors=[[1/2, 0, 0], [0, 1/2, 0], [1/2, 1/2, 0]], + burger_vectors=[[1 / 2, 0, 0], [0, 1 / 2, 0], [1 / 2, 1 / 2, 0]], periodicity=False, interface=2, ), general=FaultConfig( possible=True, - burger_vectors={ - '110': ( - (4, [1, 1, 0]), - ) - }, + burger_vectors={"110": ((4, [1, 1, 0]),)}, # interface=(4,), - nsteps = 8 - ) + nsteps=8, + ), ) - self._planes['011'] = GlidingPlaneConfig( + self._planes["011"] = GlidingPlaneConfig( transformation_matrix=[[0, 1, -1], [-1, 1, 1], [1, 0, 0]], target_unit_vectors=[[1, 0, 0], [0, 1, 0], [0, 0, 1]], n_layers=2, intrinsic=FaultConfig( removal_layers=[2], - burger_vectors=[[1/2, 0, 0], [0, 1/2, 0], [1/2, 1/2, 0]], + burger_vectors=[[1 / 2, 0, 0], [0, 1 / 2, 0], [1 / 2, 1 / 2, 0]], periodicity=False, interface=2, ), general=FaultConfig( possible=True, burger_vectors={ - '100': ( - (2, [1, 0, 0]), - ), - '010': ( - (2, [0, 1, 0]), - ), - '210': ( - (2, [2, 1, 0]), - ) - }, + "100": ((2, [1, 0, 0]),), + "010": ((2, [0, 1, 0]),), + "210": ((2, [2, 1, 0]),), + }, # interface=(2, ), - nsteps = 12 - ) + nsteps=12, + ), ) - self._planes['111'] = GlidingPlaneConfig( + self._planes["111"] = GlidingPlaneConfig( transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], transformation_matrix_c=[[1, -1, 0], [1, 1, -2], [1, 1, 1]], n_layers=12, intrinsic=FaultConfig( removal_layers=[9, 10, 11, 12, 13, 14], - burger_vectors=[[1/3, 1/3, 0]], + burger_vectors=[[1 / 3, 1 / 3, 0]], periodicity=False, interface=9, ), general=FaultConfig( possible=True, - burger_vectors={ - '110': ( - (9, [1/3, 1/3, 0]), - ) - }, + burger_vectors={"110": ((9, [1 / 3, 1 / 3, 0]),)}, # interface=9, - nsteps = 10 - ) + nsteps=10, + ), ) + class E21GlidingSystem(GlidingSystem): """E21 (Perovskite) gliding system.""" - + def _register_planes(self): - self._planes['011'] = GlidingPlaneConfig( + self._planes["011"] = GlidingPlaneConfig( transformation_matrix=[[0, 0, 1], [-1, 1, 0], [1, 1, 0]], n_layers=4, intrinsic=FaultConfig(removal_layers=[4, 5]), - extrinsic=FaultConfig(possible=False) + extrinsic=FaultConfig(possible=False), ) - self._planes['111'] = GlidingPlaneConfig( + self._planes["111"] = GlidingPlaneConfig( transformation_matrix=[[1, -1, 0], [1, 0, -1], [1, 1, 1]], n_layers=6, intrinsic=FaultConfig(removal_layers=[6, 7, 8, 9]), - unstable=FaultConfig(removal_layers=[6, 7]) + unstable=FaultConfig(removal_layers=[6, 7]), ) + # Registry for gliding systems _GLIDING_SYSTEM_REGISTRY: dict[str, type[GlidingSystem]] = { - 'A1': A1GlidingSystem, - 'A2': A2GlidingSystem, - 'B1': B1GlidingSystem, - 'B2': B2GlidingSystem, - 'C1_b': C1bGlidingSystem, - 'L2_1': L21GlidingSystem, - 'E_21': E21GlidingSystem, + "A1": A1GlidingSystem, + "A2": A2GlidingSystem, + "B1": B1GlidingSystem, + "B2": B2GlidingSystem, + "C1_b": C1bGlidingSystem, + "L2_1": L21GlidingSystem, + "E_21": E21GlidingSystem, } # Cache for instantiated systems @@ -499,12 +442,12 @@ def get_gliding_system(strukturbericht: str) -> GlidingSystem: """Get or create a gliding system instance.""" if strukturbericht not in _GLIDING_SYSTEM_REGISTRY: raise ValueError( - f'Strukturbericht {strukturbericht} is not supported. ' - f'Supported types: {list(_GLIDING_SYSTEM_REGISTRY.keys())}' + f"Strukturbericht {strukturbericht} is not supported. " + f"Supported types: {list(_GLIDING_SYSTEM_REGISTRY.keys())}" ) - + if strukturbericht not in _GLIDING_SYSTEM_CACHE: system_class = _GLIDING_SYSTEM_REGISTRY[strukturbericht] _GLIDING_SYSTEM_CACHE[strukturbericht] = system_class(strukturbericht) - + return _GLIDING_SYSTEM_CACHE[strukturbericht] diff --git a/src/aiida_mechanical/data/system.py b/src/aiida_mechanical/data/system.py index 84d6eec..a95be1d 100644 --- a/src/aiida_mechanical/data/system.py +++ b/src/aiida_mechanical/data/system.py @@ -1,118 +1,135 @@ _IMPLEMENTED_SLIPPING_SYSTEMS = { - 'A1': { - 'info': 'FCC element crystal . ' - 'Usually, the gliding plane is 111.', - 'possible_gliding_planes': { - '100': {'stacking': 'AB', - 'slipping_direction': '1/2[010]', - 'faulting_possible': True, - }, - '110': {'stacking': 'AB', - 'slipping_direction': '1/2[112]', - 'faulting_possible': True, - }, - '111': {'stacking': 'ABC', - 'slipping_direction': '1/2[110]', - 'faulting_possible': True, - }, - } + "A1": { + "info": "FCC element crystal . " + "Usually, the gliding plane is 111.", + "possible_gliding_planes": { + "100": { + "stacking": "AB", + "slipping_direction": "1/2[010]", + "faulting_possible": True, + }, + "110": { + "stacking": "AB", + "slipping_direction": "1/2[112]", + "faulting_possible": True, + }, + "111": { + "stacking": "ABC", + "slipping_direction": "1/2[110]", + "faulting_possible": True, + }, + }, }, - 'A2': { - 'info': 'FCC element crystal . ' - 'I don\'t know the usual gliding plane. ', - 'possible_gliding_planes': { - '100': {'stacking': 'AB', - 'slipping_direction': '1/2[110]', - 'faulting_possible': True, - }, - '110': {'stacking': 'AB', - 'slipping_direction': '1/2[001]', - 'faulting_possible': True, - }, - '111': {'stacking': 'ABC', - 'slipping_direction': '1/2[011]', - 'faulting_possible': True, - }, - } + "A2": { + "info": "FCC element crystal . " + "I don't know the usual gliding plane. ", + "possible_gliding_planes": { + "100": { + "stacking": "AB", + "slipping_direction": "1/2[110]", + "faulting_possible": True, + }, + "110": { + "stacking": "AB", + "slipping_direction": "1/2[001]", + "faulting_possible": True, + }, + "111": { + "stacking": "ABC", + "slipping_direction": "1/2[011]", + "faulting_possible": True, + }, + }, }, - 'A15': { - 'info': 'A3B crystal . ' - 'I don\'t know the usual gliding plane. ', - 'possible_gliding_planes': { - '100': {'stacking': 'AB', - 'slipping_direction': '1/2[110]', - 'faulting_possible': True, - }, - '110': {'stacking': 'AB', - 'slipping_direction': '1/2[001]', - 'faulting_possible': True, - }, - '111': {'stacking': 'ABC', - 'slipping_direction': '1/2[011]', - 'faulting_possible': True, - }, - } + "A15": { + "info": "A3B crystal . " + "I don't know the usual gliding plane. ", + "possible_gliding_planes": { + "100": { + "stacking": "AB", + "slipping_direction": "1/2[110]", + "faulting_possible": True, + }, + "110": { + "stacking": "AB", + "slipping_direction": "1/2[001]", + "faulting_possible": True, + }, + "111": { + "stacking": "ABC", + "slipping_direction": "1/2[011]", + "faulting_possible": True, + }, + }, }, - 'B1': { - 'info': 'FCC element crystal . ' - 'I don\'t know the usual gliding plane. ', - 'possible_gliding_planes': { - '100': {'stacking': 'AB', - 'slipping_direction': '1/2[010]', - 'faulting_possible': True, - }, - '110': {'stacking': 'AB', - 'slipping_direction': '1/2[112]', - 'faulting_possible': True, - }, - } + "B1": { + "info": "FCC element crystal . " + "I don't know the usual gliding plane. ", + "possible_gliding_planes": { + "100": { + "stacking": "AB", + "slipping_direction": "1/2[010]", + "faulting_possible": True, + }, + "110": { + "stacking": "AB", + "slipping_direction": "1/2[112]", + "faulting_possible": True, + }, + }, }, - 'B2': { - 'info': 'FCC element crystal . ' - 'I don\'t know the usual gliding plane. ', - 'possible_gliding_planes': { - '100': {'stacking': 'AB', - 'slipping_direction': '1/2[010]', - 'faulting_possible': True, - }, - } + "B2": { + "info": "FCC element crystal . " + "I don't know the usual gliding plane. ", + "possible_gliding_planes": { + "100": { + "stacking": "AB", + "slipping_direction": "1/2[010]", + "faulting_possible": True, + }, + }, }, - 'C1': { - 'info': 'We are doing pyrite-type structure. . ' - 'I don\'t know the usual gliding plane. ', - 'possible_gliding_planes': { - '100': {'stacking': 'ABCD', - 'slipping_direction': '1/2[100]', - 'faulting_possible': True, - }, - } + "C1": { + "info": "We are doing pyrite-type structure. . " + "I don't know the usual gliding plane. ", + "possible_gliding_planes": { + "100": { + "stacking": "ABCD", + "slipping_direction": "1/2[100]", + "faulting_possible": True, + }, + }, }, - 'C1b': { - 'info': 'We are doing half-heusler-type structure. . ' - 'I don\'t know the usual gliding plane. ', - 'possible_gliding_planes': { - '100': {'stacking': 'ABCD', - 'slipping_direction': '1/2[100]', - 'faulting_possible': True, - }, - '110': {'stacking': 'AB', - 'slipping_direction': '1/2[110]', - 'faulting_possible': True, - }, - '111': {'stacking': 'ABC', - 'slipping_direction': '1/2[111]', - 'faulting_possible': True, - }, - } + "C1b": { + "info": "We are doing half-heusler-type structure. . " + "I don't know the usual gliding plane. ", + "possible_gliding_planes": { + "100": { + "stacking": "ABCD", + "slipping_direction": "1/2[100]", + "faulting_possible": True, + }, + "110": { + "stacking": "AB", + "slipping_direction": "1/2[110]", + "faulting_possible": True, + }, + "111": { + "stacking": "ABC", + "slipping_direction": "1/2[111]", + "faulting_possible": True, + }, + }, }, - 'E21': { - 'info': 'We are doing perovskite-type structure. . ' - 'I don\'t know the usual gliding plane. ', - 'possible_gliding_planes': { - '100': {'stacking': 'AB', - 'slipping_direction': '1/2[010]', - 'faulting_possible': True, - }, - } + "E21": { + "info": "We are doing perovskite-type structure. . " + "I don't know the usual gliding plane. ", + "possible_gliding_planes": { + "100": { + "stacking": "AB", + "slipping_direction": "1/2[010]", + "faulting_possible": True, + }, + }, }, } diff --git a/src/aiida_mechanical/tools/builder.py b/src/aiida_mechanical/tools/builder.py index 9e3907b..597c091 100644 --- a/src/aiida_mechanical/tools/builder.py +++ b/src/aiida_mechanical/tools/builder.py @@ -1,18 +1,18 @@ from ..workflows import USFEWorkChain, GSFEWorkChain + def get_builder(workchain_type: str, **kwargs): """Get a builder for the specified workchain type. - + :param workchain_type: Type of workchain ('usfe' or 'gsfe') :param kwargs: Additional arguments to pass to get_builder() :return: WorkChain builder """ - if workchain_type == 'usfe': + if workchain_type == "usfe": return USFEWorkChain.get_builder(**kwargs) - elif workchain_type == 'gsfe': + elif workchain_type == "gsfe": return GSFEWorkChain.get_builder(**kwargs) else: raise ValueError( - f"Invalid workchain type: {workchain_type}. " - f"Supported types: 'usfe', 'gsfe'" - ) \ No newline at end of file + f"Invalid workchain type: {workchain_type}. Supported types: 'usfe', 'gsfe'" + ) diff --git a/src/aiida_mechanical/tools/cut.py b/src/aiida_mechanical/tools/cut.py index 3933214..095945a 100644 --- a/src/aiida_mechanical/tools/cut.py +++ b/src/aiida_mechanical/tools/cut.py @@ -1,4 +1,3 @@ -from aiida import orm from ase import Atoms from ase.build import make_supercell import numpy @@ -11,13 +10,13 @@ import numpy as np import matplotlib.pyplot as plt from pymatgen.core import Structure -from matplotlib import patheffects from matplotlib.patches import Circle import string from pymatgen.transformations.standard_transformations import SupercellTransformation LIST_ALPHABET = list(string.ascii_uppercase) + def list_to_tex(indices): """ 将一个整数列表(如 [1, -1, 0])转换为 Miller 指数的 LaTeX 格式, @@ -34,8 +33,18 @@ def list_to_tex(indices): return "[$" + "".join(out) + "$]" -def draw_sphere(ax, x, y, radius, base_color='skyblue', base_alpha=1, - n_rings=150, inner=0.2, outer=0.4, power=1.): +def draw_sphere( + ax, + x, + y, + radius, + base_color="skyblue", + base_alpha=1, + n_rings=150, + inner=0.2, + outer=0.4, + power=1.0, +): """ 在 (x,y) 画一个假 3D 球: - fac = i/n_rings 为环带半径比例,从 1 到 0 递减 @@ -45,11 +54,11 @@ def draw_sphere(ax, x, y, radius, base_color='skyblue', base_alpha=1, - 最外层再加一圈黑边 """ base_rgb = np.array(plt.cm.colors.to_rgb(base_color)) - white = np.array([1.0, 1.0, 1.0]) + white = np.array([1.0, 1.0, 1.0]) for i in range(n_rings, 0, -1): fac = i / n_rings - r = radius * fac + r = radius * fac if fac <= inner: color = white @@ -63,30 +72,30 @@ def draw_sphere(ax, x, y, radius, base_color='skyblue', base_alpha=1, color = base_rgb circ = Circle( - (x, y), r, + (x, y), + r, facecolor=color, - edgecolor='none', - alpha=1.0*base_alpha, - zorder=10 - ) + edgecolor="none", + alpha=1.0 * base_alpha, + zorder=10, + ) ax.add_patch(circ) # 最外层黑色边框 rim = Circle( - (x, y), radius, - facecolor='none', - edgecolor='black', - linewidth=1.5, - zorder=11 - ) + (x, y), radius, facecolor="none", edgecolor="black", linewidth=1.5, zorder=11 + ) ax.add_patch(rim) def draw_edge_arrows( - ax, a2, b2, - frac_pos=0.2, # 在边上位置:0.2→20%处 + ax, + a2, + b2, + frac_pos=0.2, # 在边上位置:0.2→20%处 arrow_frac=0.1, # 箭头长度:边长的 10% - offset=0.02): # 垂直的偏移量(同样是数据坐标) + offset=0.02, +): # 垂直的偏移量(同样是数据坐标) # 计算 a 边的箭头 # 1) 单位方向 ua = a2 / np.linalg.norm(a2) @@ -95,16 +104,22 @@ def draw_edge_arrows( # 3) 在边上的起点 pa = a2 * frac_pos # 4) 垂直向上偏移(顺时针旋转 90°) - na = np.array([-ua[1], ua[0]]) # 单位法向 + na = np.array([-ua[1], ua[0]]) # 单位法向 pa_off = pa + na * offset # 画箭头 ax.arrow( - pa_off[0], pa_off[1], - la[0], la[1], - head_width=-offset*0.5, - head_length=-offset*0.5, - fc='k', ec='k', linewidth=1.2, zorder=15) + pa_off[0], + pa_off[1], + la[0], + la[1], + head_width=-offset * 0.5, + head_length=-offset * 0.5, + fc="k", + ec="k", + linewidth=1.2, + zorder=15, + ) # 同理,b 边的箭头 ub = b2 / np.linalg.norm(b2) @@ -115,23 +130,34 @@ def draw_edge_arrows( pb_off = pb + nb * offset ax.arrow( - pb_off[0], pb_off[1], - lb[0], lb[1], - head_width=-offset*0.5, - head_length=-offset*0.5, - fc='k', ec='k', linewidth=1.2, zorder=15) + pb_off[0], + pb_off[1], + lb[0], + lb[1], + head_width=-offset * 0.5, + head_length=-offset * 0.5, + fc="k", + ec="k", + linewidth=1.2, + zorder=15, + ) + def draw_edge_arrow_with_label( - ax, vec, miller, *, + ax, + vec, + miller, + *, frac_pos=0.2, arrow_frac=0.1, offset=0.03, text_offset=(0.1, 0.1), fontsize=12, - **arrow_kwargs): + **arrow_kwargs, +): """ 在 ax 上沿向量 vec 画一条小箭头,并在箭头旁标注 miller(字符串)。 - + vec : np.array, 2 元素,箭头方向向量 miller : str,要标注的 Miller 指数,如 "[100]" frac_pos : float, 箭头起点在 vec 上的比例 @@ -153,31 +179,39 @@ def draw_edge_arrow_with_label( angle_deg = np.degrees(np.arctan2(delta[1], delta[0])) # 4) 画箭头 ax.arrow( - start[0], start[1], delta[0], delta[1], + start[0], + start[1], + delta[0], + delta[1], head_width=-offset * 0.5, head_length=-offset * 0.5, length_includes_head=True, - **arrow_kwargs - ) + **arrow_kwargs, + ) # 5) 在箭头尾部放文本 # tx = start[0] + text_offset[0] # ty = start[1] + text_offset[1] - - tx = start[0] - ty = start[1] + + tx = start[0] + ty = start[1] ax.text( - tx, ty, miller, + tx, + ty, + miller, fontsize=fontsize, color=arrow_kwargs.get("color", "black"), - va="bottom", ha="left", + va="bottom", + ha="left", # rotation=angle_deg, # rotation_mode="anchor", - zorder=arrow_kwargs.get("zorder", 15)+1 - ) - + zorder=arrow_kwargs.get("zorder", 15) + 1, + ) + + # Constants\ nLIST_ALPHABET = [chr(i) for i in range(ord('A'), ord('Z')+1)] + # Utility to compute 2D projection basis def compute_projection_basis(lattice, axis=0): """ @@ -188,50 +222,55 @@ def compute_projection_basis(lattice, axis=0): b3 = lattice[2] la, lb = np.linalg.norm(a3), np.linalg.norm(b3) costheta = np.dot(a3, b3) / (la * lb) - theta = np.arccos(np.clip(costheta, -1, 1)) + theta = np.arccos(np.clip(costheta, -1, 1)) # print(la, lb, costheta, theta) # B2D: maps frac coords [f_a,f_b] to Cartesian in-plane coords - B2D = np.array([[la, 0], [lb*costheta, lb*np.sin(theta)]]) + B2D = np.array([[la, 0], [lb * costheta, lb * np.sin(theta)]]) a2 = B2D[0] b2 = B2D[1] return B2D, a2, b2 + + def annotate_miller(ax, v1, v2, label1, label2): """ Plots two basis vectors v1 and v2 from the origin and labels their edges with Miller indices. - + Parameters: - v1, v2: 2D numpy arrays or lists representing the basis vectors. - label1, label2: Strings for the Miller index labels corresponding to v1 and v2. """ - + # Calculate midpoints - mid1 = 0.5 * np.array(v1) - [0., 0.2] - mid2 = 0.5 * np.array(v2) - [0.1, 0.] - + mid1 = 0.5 * np.array(v1) - [0.0, 0.2] + mid2 = 0.5 * np.array(v2) - [0.1, 0.0] + # Label the midpoints with Miller indices - ax.text(mid1[0], mid1[1], label1, ha='center', va='bottom', fontsize=20) - ax.text(mid2[0], mid2[1], label2, ha='right', va='center', fontsize=20) - + ax.text(mid1[0], mid1[1], label1, ha="center", va="bottom", fontsize=20) + ax.text(mid2[0], mid2[1], label2, ha="right", va="center", fontsize=20) + + # Function 1: layer grouping def group_structure_layers(struct: Structure, axis=0, tol=1e-6): """ - Partition structure into layers along fractional axis. + Partition structure into layers along fractional axis. Return: layers : unique fractional values sorted layer_masks : list of boolean masks for each layer frac_coords : Nx3 array of fractional coords """ frac_coords = np.array(struct.frac_coords) - vals = frac_coords[:, axis] - rounded = np.round(vals / tol) * tol - layers = np.unique(rounded) - layers = np.sort(layers) + vals = frac_coords[:, axis] + rounded = np.round(vals / tol) * tol + layers = np.unique(rounded) + layers = np.sort(layers) masks = [np.isclose(rounded, lv, atol=tol) for lv in layers] return layers, masks, frac_coords + # Function 2: plot a single layer -def plot_layer(ax, pts2d, species, layer_index, a2, b2, - miller_a, miller_b, alphabet=LIST_ALPHABET): +def plot_layer( + ax, pts2d, species, layer_index, a2, b2, miller_a, miller_b, alphabet=LIST_ALPHABET +): """ Plot atoms of one layer and the parallelogram basis on the given axes: pts2d : Mx2 array of Cartesian 2D coordinates for this layer @@ -244,30 +283,36 @@ def plot_layer(ax, pts2d, species, layer_index, a2, b2, """ # color map unique_species = list(dict.fromkeys(species)) - palette = ['C0','C1','C2','C3','C4','C5','C6','C7','C8','C9'] - color_map = {sp: palette[i % len(palette)] for i, sp in enumerate(unique_species)} + palette = ["C0", "C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9"] + color_map = {sp: palette[i % len(palette)] for i, sp in enumerate(unique_species)} radius = min(np.linalg.norm(a2), np.linalg.norm(b2)) * 0.1 # draw atoms for sp in unique_species: mask = np.array(species) == sp - xs, ys = pts2d[mask,0], pts2d[mask,1] + xs, ys = pts2d[mask, 0], pts2d[mask, 1] col = color_map[sp] - for x,y in zip(xs, ys): + for x, y in zip(xs, ys): draw_sphere(ax, x, y, radius, col, base_alpha=1.0) # label letter letter = alphabet[layer_index] ax.annotate( sp, - xy=(x,y), xytext=(radius+2, radius+2), textcoords='offset points', - fontsize=16, color='black', weight='bold', ha='center', va='center', zorder=10 + xy=(x, y), + xytext=(radius + 2, radius + 2), + textcoords="offset points", + fontsize=16, + color="black", + weight="bold", + ha="center", + va="center", + zorder=10, ) - # draw parallelogram basis origin = np.zeros(2) - quad = np.vstack([origin, a2, a2+b2, b2, origin]) - ax.plot(quad[:,0], quad[:,1], '--', color='black', lw=2, zorder=1) + quad = np.vstack([origin, a2, a2 + b2, b2, origin]) + ax.plot(quad[:, 0], quad[:, 1], "--", color="black", lw=2, zorder=1) # miller labels on edges # draw_edge_arrow_with_label( # ax, a2, list_to_tex(miller_a), frac_pos=0.5, arrow_frac=0.2, offset=-0.3, @@ -279,119 +324,122 @@ def plot_layer(ax, pts2d, species, layer_index, a2, b2, annotate_miller(ax, a2, b2, list_to_tex(miller_a), list_to_tex(miller_b)) for spine in ax.spines.values(): spine.set_visible(False) - ax.set_xticks([]); ax.set_yticks([]) - + ax.set_xticks([]) + ax.set_yticks([]) + letter = alphabet[layer_index] ax.set_title(f"{letter} layer", fontsize=20) -def get_transformed_cell( - old_struct, - slipping_system, - unit_rep_inv - ): - + +def get_transformed_cell(old_struct, slipping_system, unit_rep_inv): M = np.array( [ - np.dot(slipping_system['cut_plane'], unit_rep_inv), - np.dot(slipping_system['a'], unit_rep_inv), - np.dot(slipping_system['b'], unit_rep_inv) - ], - dtype=int - ) + np.dot(slipping_system["cut_plane"], unit_rep_inv), + np.dot(slipping_system["a"], unit_rep_inv), + np.dot(slipping_system["b"], unit_rep_inv), + ], + dtype=int, + ) dst = SupercellTransformation(M) struct = dst.apply_transformation(old_struct) return struct + def plot_layers(struct, axes, slipping_system): - layers, masks, fracs = group_structure_layers(struct, axis=0) sites = list(struct) if len(layers) != len(axes): - raise ValueError(f"Number of layers ({len(layers)}) does not match number of axes ({len(ax)})") - + raise ValueError( + f"Number of layers ({len(layers)}) does not match number of axes ({len(axes)})" + ) + B2D, a2, b2 = compute_projection_basis(struct.lattice.matrix, axis=0) for i, mask in enumerate(masks): # 1) 投影坐标 - pts2d = fracs[mask][:, [1,2]].dot(B2D) + pts2d = fracs[mask][:, [1, 2]].dot(B2D) layer_sites = [site for site, m in zip(sites, mask) if m] - species = [site.species_string for site in layer_sites] + species = [site.species_string for site in layer_sites] # 3) 绘这一层 plot_layer( - axes[i], pts2d, species, i, a2, b2, - slipping_system['a'], slipping_system['b'] + axes[i], + pts2d, + species, + i, + a2, + b2, + slipping_system["a"], + slipping_system["b"], ) - - axes[i].set_aspect('equal', adjustable='box') + + axes[i].set_aspect("equal", adjustable="box") # optional: tighten the data limits so the box really is square - axes[i].autoscale(enable=True, axis='both', tight=True) - -def plot_all_layers_element_colored( - struct, - ax, - miller_a, - miller_b,tol=1e-6 - ): + axes[i].autoscale(enable=True, axis="both", tight=True) + + +def plot_all_layers_element_colored(struct, ax, miller_a, miller_b, tol=1e-6): # 1) 原胞分数坐标和笛卡尔投影 - fracs = np.array(struct.frac_coords) + fracs = np.array(struct.frac_coords) lattice = struct.lattice.matrix - - a3, b3 = lattice[1], lattice[2] - la, lb = np.linalg.norm(a3), np.linalg.norm(b3) - costheta = np.dot(a3, b3)/(la*lb) - theta = np.arccos(np.clip(costheta, -1,1)) - B2D = np.array([[la, 0.0], - [lb*costheta, lb*np.sin(theta)]]) - - sab = fracs[:, [1, 2]] - cart2d = sab.dot(B2D) - vals = fracs[:, 0] - rounded = np.round(vals/tol)*tol - layers = np.unique(rounded); layers.sort() - n_layers = len(layers) - alphas = np.linspace(0.3, 1.0, n_layers) - - species_all = np.array([site.species_string for site in struct]) + + a3, b3 = lattice[1], lattice[2] + la, lb = np.linalg.norm(a3), np.linalg.norm(b3) + costheta = np.dot(a3, b3) / (la * lb) + theta = np.arccos(np.clip(costheta, -1, 1)) + B2D = np.array([[la, 0.0], [lb * costheta, lb * np.sin(theta)]]) + + sab = fracs[:, [1, 2]] + cart2d = sab.dot(B2D) + vals = fracs[:, 0] + rounded = np.round(vals / tol) * tol + layers = np.unique(rounded) + layers.sort() + n_layers = len(layers) + alphas = np.linspace(0.3, 1.0, n_layers) + + species_all = np.array([site.species_string for site in struct]) unique_species = list(dict.fromkeys(species_all)) - palette = ['C0','C1','C2','C3','C4','C5','C6','C7','C8','C9'] - color_map = {sp: palette[i%len(palette)] for i,sp in enumerate(unique_species)} + palette = ["C0", "C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9"] + color_map = {sp: palette[i % len(palette)] for i, sp in enumerate(unique_species)} - radius = min(la, lb)*0.1 + radius = min(la, lb) * 0.1 for i, lv in enumerate(layers): layer_alpha = alphas[i] - mask_layer = np.isclose(rounded, lv, atol=tol) - pts2d = cart2d[mask_layer] - species_l = species_all[mask_layer] + mask_layer = np.isclose(rounded, lv, atol=tol) + pts2d = cart2d[mask_layer] + species_l = species_all[mask_layer] for sp in unique_species: - m_sp = (species_l == sp) - if not np.any(m_sp): continue - xs, ys = pts2d[m_sp,0], pts2d[m_sp,1] - col = color_map[sp] + m_sp = species_l == sp + if not np.any(m_sp): + continue + xs, ys = pts2d[m_sp, 0], pts2d[m_sp, 1] + col = color_map[sp] for x, y in zip(xs, ys): # 把 layer_alpha 传进去 draw_sphere(ax, x, y, radius, col, base_alpha=layer_alpha) ax.annotate( LIST_ALPHABET[i], - xy=(x, y), # 原子中心(数据坐标) - xytext=(radius+2, radius+2), # 在半径 + 2pt 处偏移 - textcoords='offset points', + xy=(x, y), # 原子中心(数据坐标) + xytext=(radius + 2, radius + 2), # 在半径 + 2pt 处偏移 + textcoords="offset points", fontsize=16, - color='black', - weight='bold', - ha='center', va='center', - zorder=10 + color="black", + weight="bold", + ha="center", + va="center", + zorder=10, ) # 绘制平行四边形基矢略… # --- 平行四边形基矢 & Miller --- # origin = np.zeros(2) a2, b2 = B2D[0], B2D[1] - quad = np.array([origin, a2, a2+b2, b2, origin]) - ax.plot(quad[:,0], quad[:,1], '--', color='black', lw=2, zorder=1) + quad = np.array([origin, a2, a2 + b2, b2, origin]) + ax.plot(quad[:, 0], quad[:, 1], "--", color="black", lw=2, zorder=1) # ax.text(a2[0]*1.1, a2[1]*0.9, list_to_tex(miller_a), color='r', va='bottom', ha='left') # ax.text(b2[0]*1.1, b2[1]*0.9, list_to_tex(miller_b), color='b', va='bottom', ha='left') # ax.text(a2[0]+b2[0], a2[1]+b2[1], list_to_tex(a+b), color='k', va='bottom', ha='left') @@ -399,67 +447,74 @@ def plot_all_layers_element_colored( # frac_pos=0.5, # 箭头在 25% 处 # arrow_frac=0.2, # 箭头是边长 20% # offset=-0.3) # 垂直偏移 0.03 个数据单位 - + draw_edge_arrow_with_label( - ax, a2, list_to_tex(miller_a), - frac_pos=0.5, arrow_frac=0.2, offset=-0.3, - text_offset=(0.1, 0.1), - color="black", linewidth=1.5, zorder=15 - ) + ax, + a2, + list_to_tex(miller_a), + frac_pos=0.5, + arrow_frac=0.2, + offset=-0.3, + text_offset=(0.1, 0.1), + color="black", + linewidth=1.5, + zorder=15, + ) draw_edge_arrow_with_label( - ax, b2, list_to_tex(miller_b), - frac_pos=0.5, arrow_frac=0.2, offset=-0.3, - text_offset=(0.1, 0.1), - color="black", linewidth=1.5, zorder=15 - ) + ax, + b2, + list_to_tex(miller_b), + frac_pos=0.5, + arrow_frac=0.2, + offset=-0.3, + text_offset=(0.1, 0.1), + color="black", + linewidth=1.5, + zorder=15, + ) # --- 美化 --- # - ax.set_aspect('equal') + ax.set_aspect("equal") # ax.set_title("All Layers with Sphere Shading") # ax.legend(unique_species, loc='upper right', frameon=False) for spine in ax.spines.values(): spine.set_visible(False) - ax.set_xticks([]); ax.set_yticks([]) + ax.set_xticks([]) + ax.set_yticks([]) - def deprecated(reason: str): def decorator(func): - message = ( - f"Function {func.__name__!r} is deprecated: {reason}" - ) + message = f"Function {func.__name__!r} is deprecated: {reason}" + @functools.wraps(func) def wrapper(*args, **kwargs): - warnings.warn( - message, - category=DeprecationWarning, - stacklevel=2 - ) + warnings.warn(message, category=DeprecationWarning, stacklevel=2) return func(*args, **kwargs) + return wrapper + return decorator -@deprecated("use Pymatgen instead") -def group_by_plane( - ase_atoms: Atoms, - P, - cut_plane: list[float], - tol: float = 1e-6 - ): +@deprecated("use Pymatgen instead") +def group_by_plane(ase_atoms: Atoms, P, cut_plane: list[float], tol: float = 1e-6): grouped_by_plane = {} - + supercell = make_supercell(ase_atoms, P) cut_plane = numpy.dot(cut_plane, ase_atoms.cell) c = numpy.linalg.norm(cut_plane) - + for atom in supercell: z_position = numpy.dot(atom.position, cut_plane) / c match = next( - (k for k in grouped_by_plane.keys() - if numpy.isclose(k, z_position, atol=tol)), - None + ( + k + for k in grouped_by_plane.keys() + if numpy.isclose(k, z_position, atol=tol) + ), + None, ) if match is None: grouped_by_plane[z_position] = [atom] @@ -468,39 +523,39 @@ def group_by_plane( return grouped_by_plane + @deprecated("use Pymatgen instead") def find_plane_pbc( - ase_atoms: Atoms, - cut_plane: list[float], - max_denom: int = 10**6, - tol: float = 1e-6 + ase_atoms: Atoms, cut_plane: list[float], max_denom: int = 10**6, tol: float = 1e-6 ) -> list[list[float]]: """ Find the plane that cuts the atoms in the PBC box. """ cell = ase_atoms.cell.array - # 2. n = numpy.dot(cut_plane, cell) - + n2 = numpy.dot(cell, n) # 3. fracs = [Fraction(x).limit_denominator(max_denom) for x in n2] D = math.lcm(*(f.denominator for f in fracs)) A, B, C = [int(f * D) for f in fracs] g_all = math.gcd(math.gcd(abs(A), abs(B)), abs(C)) - A, B, C = A//g_all, B//g_all, C//g_all + A, B, C = A // g_all, B // g_all, C // g_all print(A, B, C) g = math.gcd(A, B) - v1 = numpy.array([ B//g, -A//g, 0 ], dtype=int) + v1 = numpy.array([B // g, -A // g, 0], dtype=int) v2 = numpy.cross(numpy.array([A, B, C], int), v1) - # - def dot(u,v): return int(u.dot(v)) - def norm2(u): return dot(u,u) + # + def dot(u, v): + return int(u.dot(v)) + + def norm2(u): + return dot(u, u) # 5. while True: @@ -511,17 +566,14 @@ def norm2(u): return dot(u,u) continue break - v3 = numpy.cross(v1, v2) # w = v1 × v2 + v3 = numpy.cross(v1, v2) # w = v1 × v2 g = math.gcd(math.gcd(abs(v3[0]), abs(v3[1])), abs(v3[2])) - n = v3 // g + n = v3 // g return (v1, v2, v3) + @deprecated("use Pymatgen instead") -def basis_transform( - v1, v2, v3, - old_atoms, - tol=1e-6 - ): +def basis_transform(v1, v2, v3, old_atoms, tol=1e-6): """ v1, v2, v3 : np.array(shape=(3,)), 原三维晶格基矢 old_atoms : list of (x,y,z),原胞内所有原子的分数坐标 @@ -532,12 +584,13 @@ def basis_transform( layer_index 是 gamma 四舍五入后的整数层号, real_xyz 是实空间坐标。 """ + a = b = c = numpy.zeros(3) # 2) 计算 M⁻¹(它恰好也是整数矩阵的逆,但用浮点足够精度) - M = numpy.column_stack((v1, v2, v3)) + M = numpy.column_stack((v1, v2, v3)) Minv = numpy.linalg.inv(M) results = [] - for x,y,z in old_atoms: + for x, y, z in old_atoms: f = numpy.array([x, y, z], dtype=float) alpha, beta, gamma = Minv.dot(f) @@ -548,31 +601,26 @@ def basis_transform( layer = int(round(gamma)) # 把 alpha,beta 映射回 [0,1) a_mod = alpha - numpy.floor(alpha) - b_mod = beta - numpy.floor(beta) + b_mod = beta - numpy.floor(beta) # 4) 真实空间坐标 - R = x*a + y*b + z*c + R = x * a + y * b + z * c results.append((a_mod, b_mod, layer, R)) return results + @deprecated("use Pymatgen instead") -def shortest_plane_basis( - ase_atoms: Atoms, - cut_plane: list[float], - tol: float = 1e-6 - ): - +def shortest_plane_basis(ase_atoms: Atoms, cut_plane: list[float], tol: float = 1e-6): v1, v2, v3 = find_plane_pbc(ase_atoms, cut_plane) return basis_transform(v1, v2, v3, ase_atoms.positions) + @deprecated("use Pymatgen instead") def shortest_lattice_and_plane_vectors( - basis: numpy.ndarray, - frac_coords: list[float] + basis: numpy.ndarray, frac_coords: list[float] ) -> tuple[ - numpy.ndarray, tuple[int, int, int], - list[numpy.ndarray], list[tuple[int, int, int]] + numpy.ndarray, tuple[int, int, int], list[numpy.ndarray], list[tuple[int, int, int]] ]: """ 计算给定晶格基矢和倒易空间坐标法向下: @@ -605,9 +653,11 @@ def shortest_lattice_and_plane_vectors( if g == 0: raise ValueError("分数坐标至少需一个非零分量") primitive_hkl_normal = tuple(h // g for h in hkl_int) - d_normal = (primitive_hkl_normal[0] * basis[:, 0] + - primitive_hkl_normal[1] * basis[:, 1] + - primitive_hkl_normal[2] * basis[:, 2]) + d_normal = ( + primitive_hkl_normal[0] * basis[:, 0] + + primitive_hkl_normal[1] * basis[:, 1] + + primitive_hkl_normal[2] * basis[:, 2] + ) # --- 平面部分 --- M = sympy.Matrix([primitive_hkl_normal]) @@ -621,10 +671,7 @@ def shortest_lattice_and_plane_vectors( ints.append(numpy.array(m_int, dtype=int).flatten()) # 构造候选 v1, v2 = ints - candidates = { - tuple(v1), tuple(v2), - tuple(v1 + v2), tuple(v1 - v2), tuple(v2 - v1) - } + candidates = {tuple(v1), tuple(v2), tuple(v1 + v2), tuple(v1 - v2), tuple(v2 - v1)} vecs = [] for hkl in candidates: d = hkl[0] * basis[:, 0] + hkl[1] * basis[:, 1] + hkl[2] * basis[:, 2] @@ -642,6 +689,7 @@ def shortest_lattice_and_plane_vectors( return d_normal, primitive_hkl_normal, d_plane, primitive_hkls_plane + @deprecated("use Pymatgen instead") def map_atoms_to_new_cell(old_basis, new_basis, frac_old, padding=1): """ @@ -661,10 +709,14 @@ def map_atoms_to_new_cell(old_basis, new_basis, frac_old, padding=1): cart_old = frac_old @ old_basis.T # 产生周期平移向量组合 - shifts = numpy.array([[i, j, k] - for i in range(-padding, padding+1) - for j in range(-padding, padding+1) - for k in range(-padding, padding+1)]) + shifts = numpy.array( + [ + [i, j, k] + for i in range(-padding, padding + 1) + for j in range(-padding, padding + 1) + for k in range(-padding, padding + 1) + ] + ) frac_new_list = [] inv_new = numpy.linalg.inv(new_basis) @@ -679,7 +731,7 @@ def map_atoms_to_new_cell(old_basis, new_basis, frac_old, padding=1): frac_new_list.append(frac_new_mod) frac_new_array = numpy.array(frac_new_list) - + # 去重:依靠坐标四舍五入 unique_frac = numpy.unique(numpy.round(frac_new_array, 6), axis=0) diff --git a/src/aiida_mechanical/tools/gliding_systems.py b/src/aiida_mechanical/tools/gliding_systems.py index b2a9403..f9c1893 100644 --- a/src/aiida_mechanical/tools/gliding_systems.py +++ b/src/aiida_mechanical/tools/gliding_systems.py @@ -11,12 +11,21 @@ L21GlidingSystem, E21GlidingSystem, _GLIDING_SYSTEM_REGISTRY, - _GLIDING_SYSTEM_CACHE + _GLIDING_SYSTEM_CACHE, ) __all__ = [ - 'FaultConfig', 'GlidingPlaneConfig', 'GlidingSystem', 'get_gliding_system', - 'A1GlidingSystem', 'A2GlidingSystem', 'B1GlidingSystem', 'B2GlidingSystem', - 'C1bGlidingSystem', 'L21GlidingSystem', 'E21GlidingSystem', - '_GLIDING_SYSTEM_REGISTRY', '_GLIDING_SYSTEM_CACHE' + "FaultConfig", + "GlidingPlaneConfig", + "GlidingSystem", + "get_gliding_system", + "A1GlidingSystem", + "A2GlidingSystem", + "B1GlidingSystem", + "B2GlidingSystem", + "C1bGlidingSystem", + "L21GlidingSystem", + "E21GlidingSystem", + "_GLIDING_SYSTEM_REGISTRY", + "_GLIDING_SYSTEM_CACHE", ] diff --git a/src/aiida_mechanical/tools/structure.py b/src/aiida_mechanical/tools/structure.py index b51745b..e386ead 100644 --- a/src/aiida_mechanical/tools/structure.py +++ b/src/aiida_mechanical/tools/structure.py @@ -1,45 +1,26 @@ from aiida import orm -from math import sqrt, acos, pi, ceil +from math import ceil import numpy -import numpy.linalg as la -import logging from ase import Atoms -from ase.spacegroup import get_spacegroup from ase.build import make_supercell -from pymatgen.symmetry.analyzer import SpacegroupAnalyzer -import pathlib import typing as ty from copy import deepcopy -import itertools -from deprecated import deprecated -from dataclasses import dataclass, field -from abc import ABC, abstractmethod # Import from new modules from .structure_utils import ( - AttributeDict, - group_by_layers, - read_structure_from_file, + AttributeDict, + group_by_layers, + read_structure_from_file, get_strukturbericht, is_primitive_cell, get_elements_for_wyckoff_symbols, - check_bravais_lattice, - calculate_surface_area + calculate_surface_area, ) from .gliding_systems import ( - FaultConfig, - GlidingPlaneConfig, - GlidingSystem, + FaultConfig, + GlidingPlaneConfig, + GlidingSystem, get_gliding_system, - A1GlidingSystem, - A2GlidingSystem, - B1GlidingSystem, - B2GlidingSystem, - C1bGlidingSystem, - L21GlidingSystem, - E21GlidingSystem, - _GLIDING_SYSTEM_REGISTRY, - _GLIDING_SYSTEM_CACHE ) from .structure_builder import ( build_atoms_surface, @@ -48,20 +29,31 @@ build_atoms_from_burger_vector, build_atoms_from_burger_vector_general, build_atoms_from_burger_vector_with_vacuum, - update_faults + update_faults, ) # Re-export necessary functions and classes __all__ = [ - 'FaultConfig', 'GlidingPlaneConfig', 'GlidingSystem', - 'get_gliding_system', 'AttributeDict', 'read_structure_from_file', - 'group_by_layers', 'get_strukturbericht', 'get_unstable_faulted_structure', - 'get_conventional_structure', 'get_cleavaged_structure', - 'get_faulted_structure', 'get_unstable_faulted_structure_and_kpoints', - 'is_primitive_cell', 'get_elements_for_wyckoff_symbols', - 'get_kpoints_mesh_for_supercell', 'calculate_surface_area' + "FaultConfig", + "GlidingPlaneConfig", + "GlidingSystem", + "get_gliding_system", + "AttributeDict", + "read_structure_from_file", + "group_by_layers", + "get_strukturbericht", + "get_unstable_faulted_structure", + "get_conventional_structure", + "get_cleavaged_structure", + "get_faulted_structure", + "get_unstable_faulted_structure_and_kpoints", + "is_primitive_cell", + "get_elements_for_wyckoff_symbols", + "get_kpoints_mesh_for_supercell", + "calculate_surface_area", ] + def _build_base_structure( structure_type: str, ase_atoms_t, @@ -72,7 +64,7 @@ def _build_base_structure( ): """ Build base structures (unfaulted/conventional, cleavaged, twinning). - + Args: structure_type: Type of structure to build ('unfaulted', 'cleavaged', 'twinning') ase_atoms_t: Transformed atoms structure @@ -80,25 +72,31 @@ def _build_base_structure( layers_dict: Dictionary of layers plane_config: GlidingPlaneConfig object print_info: Whether to print debug information - + Returns: Structure (ASE Atoms object) or None """ - if structure_type == 'unfaulted': + if structure_type == "unfaulted": return ase_atoms_t - elif structure_type == 'cleavaged': + elif structure_type == "cleavaged": return build_atoms_surface( - ase_atoms_t, n_unit_cells, layers_dict, print_info=print_info, + ase_atoms_t, + n_unit_cells, + layers_dict, + print_info=print_info, ) - elif structure_type == 'twinning': + elif structure_type == "twinning": if plane_config.n_layers > 2: return build_atoms_from_stacking_mirror( - ase_atoms_t, n_unit_cells, layers_dict, print_info=print_info, + ase_atoms_t, + n_unit_cells, + layers_dict, + print_info=print_info, ) else: return None else: - raise ValueError(f'Unknown base structure type: {structure_type}') + raise ValueError(f"Unknown base structure type: {structure_type}") def _build_faulted_structure( @@ -106,14 +104,14 @@ def _build_faulted_structure( ase_atoms_t, n_unit_cells: int, layers_dict: dict, - print_info: bool = False + print_info: bool = False, ): """ Internal helper to build faulted structure from config. """ if not config.possible: return None - + if config.removal_layers is not None: structure = build_atoms_from_stacking_removal( ase_atoms_t, @@ -121,39 +119,39 @@ def _build_faulted_structure( config.removal_layers, layers_dict, additional_spacing=(config.interface, 0.0), - print_info=print_info + print_info=print_info, ) return { - 'mode': 'removal', - 'structures': [{ - 'structure': structure, - 'layers': config.removal_layers, - }], + "mode": "removal", + "structures": [ + { + "structure": structure, + "layers": config.removal_layers, + } + ], } - + if config.burger_vectors is not None: structures_list = [] # Handle list format (intrinsic/unstable/extrinsic usually) if isinstance(config.burger_vectors, list): for bv in config.burger_vectors: structure = build_atoms_from_burger_vector( - ase_atoms_t, - n_unit_cells, - bv, - layers_dict, - print_info=print_info + ase_atoms_t, n_unit_cells, bv, layers_dict, print_info=print_info + ) + structures_list.append( + { + "structure": structure, + "burger_vector": bv, + } ) - structures_list.append({ - 'structure': structure, - 'burger_vector': bv, - }) if structures_list: return { - 'mode': 'gliding', - 'structures': structures_list, + "mode": "gliding", + "structures": structures_list, } - + return None @@ -165,61 +163,62 @@ def _prepare_structure_data( """ Prepare common structure data for building faulted and cleavaged structures. This function works on conventional cell, not unit cell. - + Args: ase_atoms_conventional: ASE Atoms object representing the conventional cell - gliding_plane: Gliding plane direction (e.g., '111', '011'). + gliding_plane: Gliding plane direction (e.g., '111', '011'). If None, uses the default plane from gliding system print_info: Whether to print debug information - + Returns: Tuple of (strukturbericht, gliding_system, plane_config, layers_dict) """ strukturbericht = get_strukturbericht(ase_atoms_conventional) if not strukturbericht: - raise ValueError('No match found in the provided list of prototypes.') + raise ValueError("No match found in the provided list of prototypes.") if print_info: - print(f'Strukturbericht {strukturbericht} detected') - + print(f"Strukturbericht {strukturbericht} detected") + # Get gliding system using new architecture gliding_system = get_gliding_system(strukturbericht) - + # Use default plane if not provided if not gliding_plane: gliding_plane = gliding_system.default_plane - + plane_config = gliding_system.get_plane(gliding_plane) - + # Group layers from conventional structure layers_dict = group_by_layers(ase_atoms_conventional) - + if len(layers_dict) != plane_config.n_layers: raise ValueError( - f'Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers} for ' - f'{strukturbericht} with gliding plane {gliding_plane}. ' - 'This may indicate wrong initial structure, incorrect structure type, or incorrect transformation matrix.' + f"Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers} for " + f"{strukturbericht} with gliding plane {gliding_plane}. " + "This may indicate wrong initial structure, incorrect structure type, or incorrect transformation matrix." ) - + return (strukturbericht, gliding_system, plane_config, layers_dict) + def get_unstable_faulted_structure( - ase_atoms_uc, - gliding_plane: ty.Optional[str] = None, - P: ty.Optional[ty.Union[list, numpy.ndarray]] = None, - n_unit_cells: int = 3, - print_info: bool = False, - ) -> tuple[str, AttributeDict]: + ase_atoms_uc, + gliding_plane: ty.Optional[str] = None, + P: ty.Optional[ty.Union[list, numpy.ndarray]] = None, + n_unit_cells: int = 3, + print_info: bool = False, +) -> tuple[str, AttributeDict]: """ Generate faulted structures for a given unit cell structure. - + Args: ase_atoms_uc: ASE Atoms object representing the unit cell gliding_plane: Gliding plane direction (e.g., '111', '011'). Defaults to '111' P: Transformation matrix. If None, uses the default from gliding system n_unit_cells: Number of unit cells to repeat print_info: Whether to print debug information - + Returns: Tuple of (strukturbericht, structures_dict) where structures_dict contains: - 'conventional': Conventional structure @@ -232,20 +231,20 @@ def get_unstable_faulted_structure( strukturbericht = get_strukturbericht(ase_atoms_uc) if not strukturbericht: - raise ValueError('No match found in the provided list of prototypes.') + raise ValueError("No match found in the provided list of prototypes.") if print_info: - print(f'Strukturbericht {strukturbericht} detected') - + print(f"Strukturbericht {strukturbericht} detected") + # Get gliding system using new architecture gliding_system = get_gliding_system(strukturbericht) - + # Use default plane if not provided if not gliding_plane: gliding_plane = gliding_system.default_plane - + plane_config = gliding_system.get_plane(gliding_plane) - + # Use provided transformation matrix or default from config if not P: P = plane_config.transformation_matrix @@ -254,84 +253,102 @@ def get_unstable_faulted_structure( ase_atoms_t = make_supercell(ase_atoms_uc, P) layers_dict = group_by_layers(ase_atoms_t) - + if len(layers_dict) != plane_config.n_layers: raise ValueError( - f'Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers} for ' - f'{strukturbericht} with gliding plane {gliding_plane}. ' - 'This may indicate wrong initial structure, incorrect structure type, or incorrect transformation matrix.' + f"Layer count mismatch: found {len(layers_dict)} layers, but expected {plane_config.n_layers} for " + f"{strukturbericht} with gliding plane {gliding_plane}. " + "This may indicate wrong initial structure, incorrect structure type, or incorrect transformation matrix." ) # Build base structures using unified function - structures = AttributeDict({ - 'conventional': _build_base_structure( - 'unfaulted', ase_atoms_t, n_unit_cells, layers_dict, plane_config, print_info - ), - 'twinning': _build_base_structure( - 'twinning', ase_atoms_t, n_unit_cells, layers_dict, plane_config, print_info - ), - 'cleavaged': _build_base_structure( - 'cleavaged', ase_atoms_t, n_unit_cells, layers_dict, plane_config, print_info - ), - }) + structures = AttributeDict( + { + "conventional": _build_base_structure( + "unfaulted", + ase_atoms_t, + n_unit_cells, + layers_dict, + plane_config, + print_info, + ), + "twinning": _build_base_structure( + "twinning", + ase_atoms_t, + n_unit_cells, + layers_dict, + plane_config, + print_info, + ), + "cleavaged": _build_base_structure( + "cleavaged", + ase_atoms_t, + n_unit_cells, + layers_dict, + plane_config, + print_info, + ), + } + ) # Build faulted structures using new architecture intrinsic_fault = _build_faulted_structure( plane_config.intrinsic, ase_atoms_t, n_unit_cells, layers_dict, print_info ) if intrinsic_fault is not None: - structures['intrinsic'] = intrinsic_fault + structures["intrinsic"] = intrinsic_fault unstable_fault = _build_faulted_structure( plane_config.unstable, ase_atoms_t, n_unit_cells, layers_dict, print_info ) if unstable_fault is not None: - structures['unstable'] = unstable_fault + structures["unstable"] = unstable_fault extrinsic_fault = _build_faulted_structure( plane_config.extrinsic, ase_atoms_t, n_unit_cells, layers_dict, print_info ) if extrinsic_fault is not None: - structures['extrinsic'] = extrinsic_fault + structures["extrinsic"] = extrinsic_fault return (strukturbericht, structures) + def get_conventional_structure( - ase_atoms_uc, - gliding_plane: ty.Optional[str] = None, - P: ty.Optional[ty.Union[list, numpy.ndarray]] = None, - print_info: bool = False, + ase_atoms_uc, + gliding_plane: ty.Optional[str] = None, + P: ty.Optional[ty.Union[list, numpy.ndarray]] = None, + print_info: bool = False, ) -> tuple[str, Atoms]: """ Generate conventional (unfaulted) structure from unit cell structure. This is the only function that converts unit cell to conventional cell. - + Args: ase_atoms_uc: ASE Atoms object representing the unit cell - gliding_plane: Gliding plane direction (e.g., '111', '011'). + gliding_plane: Gliding plane direction (e.g., '111', '011'). If None, uses the default plane from gliding system P: Transformation matrix. If None, uses the default from gliding system print_info: Whether to print debug information - + Returns: Tuple of (strukturbericht, conventional_structure) """ strukturbericht = get_strukturbericht(ase_atoms_uc) if not strukturbericht: - raise ValueError('No match found in the provided list of prototypes.') + raise ValueError("No match found in the provided list of prototypes.") if print_info: - print(f'Strukturbericht {strukturbericht} detected') - + print(f"Strukturbericht {strukturbericht} detected") + # Get gliding system using new architecture gliding_system = get_gliding_system(strukturbericht) - + # Use default plane if not provided if not gliding_plane: gliding_plane = gliding_system.default_plane - + plane_config = gliding_system.get_plane(gliding_plane) - + # Use provided transformation matrix or default from config if not P: P = plane_config.transformation_matrix @@ -339,42 +356,46 @@ def get_conventional_structure( P = numpy.array(P) ase_atoms_conventional = make_supercell(ase_atoms_uc, P) - + return (strukturbericht, ase_atoms_conventional) def get_cleavaged_structure( - ase_atoms_conventional, - gliding_plane: ty.Optional[str] = None, - n_unit_cells: int = 3, - print_info: bool = False, - ) -> tuple[str, Atoms]: + ase_atoms_conventional, + gliding_plane: ty.Optional[str] = None, + n_unit_cells: int = 3, + print_info: bool = False, +) -> tuple[str, Atoms]: """ Generate cleavaged surface structure from conventional cell structure. - + Args: ase_atoms_conventional: ASE Atoms object representing the conventional cell - gliding_plane: Gliding plane direction (e.g., '111', '011'). + gliding_plane: Gliding plane direction (e.g., '111', '011'). If None, uses the default plane from gliding system n_unit_cells: Number of unit cells to repeat print_info: Whether to print debug information - + Returns: Tuple of (strukturbericht, cleavaged_structure) """ strukturbericht, _, _, layers_dict = _prepare_structure_data( ase_atoms_conventional, gliding_plane, print_info ) - + cleavaged_structure = build_atoms_surface( - ase_atoms_conventional, n_unit_cells, layers_dict, print_info=print_info, + ase_atoms_conventional, + n_unit_cells, + layers_dict, + print_info=print_info, ) - + return (strukturbericht, cleavaged_structure) class FaultedStructureEntry(ty.TypedDict, total=False): """Container for a single faulted structure variant.""" + structure: Atoms layers: list[int] # only for removal faults burger_vector: list[float] # only for gliding faults @@ -382,31 +403,35 @@ class FaultedStructureEntry(ty.TypedDict, total=False): class FaultedStructureResult(ty.TypedDict): """Normalized return type for faulted structures.""" - mode: ty.Literal['removal', 'gliding'] + + mode: ty.Literal["removal", "gliding"] structures: list[FaultedStructureEntry] + def get_faulted_structure( - ase_atoms_conventional, - fault_mode: str, - fault_type: str, - additional_spacing: float = 0.0, - gliding_plane: ty.Optional[str] = None, - n_unit_cells: int = 3, - vacuum_ratio: float = 0.0, - print_info: bool = False, - **kwargs, - ) -> tuple[str, ty.Optional[FaultedStructureResult]]: + ase_atoms_conventional, + fault_mode: str, + fault_type: str, + additional_spacing: float = 0.0, + gliding_plane: ty.Optional[str] = None, + n_unit_cells: int = 3, + vacuum_ratio: float = 0.0, + print_info: bool = False, + **kwargs, +) -> tuple[str, ty.Optional[FaultedStructureResult]]: """Generate faulted structure of a specific type from conventional cell structure.""" - from copy import deepcopy as dp - - if fault_mode not in ['removal', 'vacuum', 'general']: + if fault_mode not in ["removal", "vacuum", "general"]: raise ValueError( f"fault_mode must be one of 'removal', 'vacuum', 'general', " f"got '{fault_mode}'" ) - if fault_mode == 'removal' and fault_type not in ['intrinsic', 'unstable', 'extrinsic']: + if fault_mode == "removal" and fault_type not in [ + "intrinsic", + "unstable", + "extrinsic", + ]: raise ValueError( f"fault_type must be one of 'intrinsic', 'unstable', or 'extrinsic', " f"got '{fault_type}'" @@ -420,9 +445,9 @@ def get_faulted_structure( if not fault_config.possible: return None - + # Prefer removal mode if available and requested - if fault_mode == 'removal' and fault_config.removal_layers is not None: + if fault_mode == "removal" and fault_config.removal_layers is not None: if fault_config.removal_layers is not None: raise ValueError( f"Fault type {fault_type} is not available for removal mode." @@ -433,18 +458,24 @@ def get_faulted_structure( fault_config.removal_layers, layers_dict, additional_spacing=(fault_config.interface, additional_spacing), - print_info=print_info + print_info=print_info, ) faulted_structure = { - 'mode': 'removal', - 'structures': [{ - 'structure': structure, - 'layers': fault_config.removal_layers, - }], + "mode": "removal", + "structures": [ + { + "structure": structure, + "layers": fault_config.removal_layers, + } + ], } # Use burger vector (gliding/vacuum) mode if available - if fault_mode == 'vacuum' and vacuum_ratio > 0.0 and fault_config.burger_vectors is not None: + if ( + fault_mode == "vacuum" + and vacuum_ratio > 0.0 + and fault_config.burger_vectors is not None + ): structures_list: list[FaultedStructureEntry] = [] for burger_vector in fault_config.burger_vectors: structure = build_atoms_from_burger_vector_with_vacuum( @@ -453,30 +484,38 @@ def get_faulted_structure( burger_vector, layers_dict, vacuum_ratio=vacuum_ratio, - print_info=print_info + print_info=print_info, ) - structures_list.append({ - 'structure': structure, - 'burger_vector': burger_vector, - }) + structures_list.append( + { + "structure": structure, + "burger_vector": burger_vector, + } + ) faulted_structure = { - 'mode': 'vacuum', - 'structures': structures_list, + "mode": "vacuum", + "structures": structures_list, } - - if fault_mode == 'general' and fault_config.burger_vectors is not None: + + if fault_mode == "general" and fault_config.burger_vectors is not None: structures_list: list[FaultedStructureEntry] = [] - nsteps = kwargs.get('nsteps', fault_config.nsteps) - stacking_order = ''.join(layers_dict.keys()) + nsteps = kwargs.get("nsteps", fault_config.nsteps) + stacking_order = "".join(layers_dict.keys()) if not isinstance(n_unit_cells, int) or n_unit_cells < 2: - raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be an integer >= 2.") + raise ValueError( + f"Invalid number of unit cells {n_unit_cells}. Must be an integer >= 2." + ) - zs = [(value['z'] + layer)/n_unit_cells for layer in range(n_unit_cells) for value in layers_dict.values()] + zs = [ + (value["z"] + layer) / n_unit_cells + for layer in range(n_unit_cells) + for value in layers_dict.values() + ] stacking_order_supercell = stacking_order * n_unit_cells new_cell = ase_atoms_conventional.cell.array.copy() - new_cell[-1] *= (n_unit_cells) + new_cell[-1] *= n_unit_cells if isinstance(fault_config.burger_vectors, dict): for _direction_name, segment in fault_config.burger_vectors.items(): @@ -489,15 +528,17 @@ def get_faulted_structure( stacking_order_supercell, burgers_vector_for_cell, faults, - print_info=print_info + print_info=print_info, + ) + structures_list.append( + { + "structure": structure, + "burger_vector": burgers_vector_for_cell.tolist(), + } ) - structures_list.append({ - 'structure': structure, - 'burger_vector': burgers_vector_for_cell.tolist(), - }) for interface, burgers_vector in segment: burgers_vector_step = numpy.array(burgers_vector) / nsteps - for _ in range(1, 1+nsteps): + for _ in range(1, 1 + nsteps): faults = update_faults(faults, interface, burgers_vector_step) burgers_vector_for_cell += burgers_vector_step structure = build_atoms_from_burger_vector_general( @@ -507,21 +548,23 @@ def get_faulted_structure( stacking_order_supercell, burgers_vector_for_cell, faults, - print_info=print_info + print_info=print_info, + ) + + structures_list.append( + { + "structure": structure, + "burger_vector": burgers_vector_for_cell.tolist(), + } ) - - structures_list.append({ - 'structure': structure, - 'burger_vector': burgers_vector_for_cell.tolist(), - }) else: - # Legacy or unhandled format - skipping as per new requirement - pass - + # Legacy or unhandled format - skipping as per new requirement + pass + faulted_structure = { - 'mode': 'gliding', - 'structures': structures_list, + "mode": "gliding", + "structures": structures_list, } return (strukturbericht, faulted_structure) @@ -533,10 +576,10 @@ def get_unstable_faulted_structure_and_kpoints( slipping_system: orm.List, ) -> tuple[orm.StructureData, orm.KpointsData]: """Get unstable faulted structure and corresponding kpoints for GSFE workflow. - + This is a convenience wrapper that extracts structure and calculates kpoints from get_unstable_faulted_structure. - + :param structure_uc: Unit cell structure :param kpoints_uc: Unit cell kpoints :param n_layers: Number of layers @@ -544,48 +587,51 @@ def get_unstable_faulted_structure_and_kpoints( :return: Tuple of (faulted_structure, kpoints) """ structure_type, gliding_plane, _ = slipping_system.get_list() - + # Get unstable faulted structure _, structures_dict = get_unstable_faulted_structure( structure_uc.get_ase(), gliding_plane=gliding_plane if gliding_plane else None, n_unit_cells=n_layers, ) - + # Extract unstable structure - if 'unstable' not in structures_dict or structures_dict['unstable'] is None: - raise ValueError('Unstable fault structure is not available for this gliding system.') - - unstable_data = structures_dict['unstable'] - if not unstable_data.get('structures'): - raise ValueError('Unstable fault structure list is empty.') - - unstable_structure_ase = unstable_data['structures'][0].get('structure') + if "unstable" not in structures_dict or structures_dict["unstable"] is None: + raise ValueError( + "Unstable fault structure is not available for this gliding system." + ) + + unstable_data = structures_dict["unstable"] + if not unstable_data.get("structures"): + raise ValueError("Unstable fault structure list is empty.") + + unstable_structure_ase = unstable_data["structures"][0].get("structure") if unstable_structure_ase is None: - raise ValueError('Unstable fault structure is missing structure data.') - + raise ValueError("Unstable fault structure is missing structure data.") + # Convert to StructureData structure_sc = orm.StructureData(ase=unstable_structure_ase) - + # Calculate kpoints for supercell # Get z-ratio between supercell and unit cell z_ratio = unstable_structure_ase.cell.cellpar()[2] / structure_uc.cell.cellpar()[2] kpoints_mesh_uc = kpoints_uc.get_kpoints_mesh()[0] - + # Adjust kpoints mesh for supercell kpoints_mesh_sc = list(kpoints_mesh_uc) kpoints_mesh_sc[2] = ceil(kpoints_mesh_sc[2] / z_ratio) - + kpoints_sc = orm.KpointsData() kpoints_sc.set_kpoints_mesh(kpoints_mesh_sc) - + return (structure_sc, kpoints_sc) + def get_kpoints_mesh_for_supercell( - kpoints_uc: orm.KpointsData, - n_layers: int, - n_stacking: int, - ) -> orm.KpointsData: + kpoints_uc: orm.KpointsData, + n_layers: int, + n_stacking: int, +) -> orm.KpointsData: """ Get the kpoints mesh for the supercell. Assume scaling by n_layers * n_stacking along Z? @@ -596,7 +642,7 @@ def get_kpoints_mesh_for_supercell( # Total expansion ~ n_layers (if n_layers means n_unit_cells) if n_layers > 0: kpoints_mesh[2] = max(1, int(ceil(kpoints_mesh[2] / n_layers))) - + kpoints = orm.KpointsData() kpoints.set_kpoints_mesh(kpoints_mesh) return kpoints diff --git a/src/aiida_mechanical/tools/structure_builder.py b/src/aiida_mechanical/tools/structure_builder.py index 6fd994a..584924b 100644 --- a/src/aiida_mechanical/tools/structure_builder.py +++ b/src/aiida_mechanical/tools/structure_builder.py @@ -1,31 +1,37 @@ from ase import Atoms from copy import deepcopy -import typing as ty import numpy from deprecated import deprecated import itertools + def build_atoms_surface( ase_atoms_uc, n_unit_cells, layers_dict, - print_info = False, - vacuum_spacing = 1.0, - ): + print_info=False, + vacuum_spacing=1.0, +): atoms = Atoms() if not isinstance(n_unit_cells, int) or n_unit_cells < 1: - raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be a positive integer.") + raise ValueError( + f"Invalid number of unit cells {n_unit_cells}. Must be a positive integer." + ) - stacking_order = n_unit_cells * ''.join(layers_dict.keys()) + stacking_order = n_unit_cells * "".join(layers_dict.keys()) - zs = [(value['z'] + cell)/n_unit_cells/(1+vacuum_spacing) for cell in range(n_unit_cells) for value in layers_dict.values()] + zs = [ + (value["z"] + cell) / n_unit_cells / (1 + vacuum_spacing) + for cell in range(n_unit_cells) + for value in layers_dict.values() + ] new_cell = ase_atoms_uc.cell.array.copy() - new_cell[-1] *= n_unit_cells * (1+vacuum_spacing) + new_cell[-1] *= n_unit_cells * (1 + vacuum_spacing) atoms.set_cell(new_cell) for layer_label, z in zip(stacking_order, zs): - for atom in layers_dict[layer_label]['atoms']: + for atom in layers_dict[layer_label]["atoms"]: scaled_position = atom.scaled_position scaled_position[-1] = z atom.position = scaled_position @ new_cell @@ -33,57 +39,75 @@ def build_atoms_surface( return atoms + def build_atoms_from_stacking_removal( ase_atoms_uc, n_unit_cells, removed_layers, layers_dict, - additional_spacing = (0, 0.0), - print_info = False, - ): - + additional_spacing=(0, 0.0), + print_info=False, +): atoms = Atoms() - stacking_order = n_unit_cells * ''.join(layers_dict.keys()) + stacking_order = n_unit_cells * "".join(layers_dict.keys()) if not isinstance(n_unit_cells, int) or n_unit_cells < 1: - raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be a positive integer.") + raise ValueError( + f"Invalid number of unit cells {n_unit_cells}. Must be a positive integer." + ) if any(layer >= len(stacking_order) for layer in removed_layers): raise ValueError( f"Invalid removed layers {removed_layers}: layer indices must be < {len(stacking_order)} " f"(number of layers in stacking order)" ) - zs = numpy.array([value['z']/n_unit_cells + layer/n_unit_cells for layer in range(n_unit_cells) for value in layers_dict.values()]) + zs = numpy.array( + [ + value["z"] / n_unit_cells + layer / n_unit_cells + for layer in range(n_unit_cells) + for value in layers_dict.values() + ] + ) removed_layers_sorted = sorted(set(removed_layers)) removed_spacing = 0.0 - faulted_stacking = "".join([char for i, char in enumerate(stacking_order) if i not in removed_layers_sorted]) + faulted_stacking = "".join( + [ + char + for i, char in enumerate(stacking_order) + if i not in removed_layers_sorted + ] + ) # Remove layers from the end to avoid index shifts while updating zs for removed_layer in reversed(removed_layers_sorted): spacing = zs[removed_layer] - zs[removed_layer - 1] if spacing + additional_spacing[1] < 0.0: - raise ValueError(f"Spacing between removed layers is less than additional spacing: {spacing} < {additional_spacing}") + raise ValueError( + f"Spacing between removed layers is less than additional spacing: {spacing} < {additional_spacing}" + ) removed_spacing += spacing zs[removed_layer:] -= spacing zs = numpy.delete(zs, removed_layer) # Apply additional spacing if requested if additional_spacing[0] >= len(zs): - raise ValueError(f"additional_spacing layer index {additional_spacing[0]} is out of bounds for remaining layers {len(zs)}") + raise ValueError( + f"additional_spacing layer index {additional_spacing[0]} is out of bounds for remaining layers {len(zs)}" + ) if additional_spacing[1] != 0.0: - zs[additional_spacing[0]:] += additional_spacing[1] + zs[additional_spacing[0] :] += additional_spacing[1] removed_spacing -= additional_spacing[1] - zs /= (1-removed_spacing) + zs /= 1 - removed_spacing if print_info: print(zs) print(faulted_stacking) new_cell = ase_atoms_uc.cell.array.copy() - new_cell[-1] *= (1-removed_spacing) * n_unit_cells + new_cell[-1] *= (1 - removed_spacing) * n_unit_cells atoms.set_cell(new_cell) for layer_label, z in zip(faulted_stacking, zs): - for atom in layers_dict[layer_label]['atoms']: + for atom in layers_dict[layer_label]["atoms"]: new_atom = deepcopy(atom) scaled_position = new_atom.scaled_position scaled_position[-1] = z @@ -91,69 +115,67 @@ def build_atoms_from_stacking_removal( atoms.append(new_atom) return atoms + def build_atoms_from_stacking_mirror( ase_atoms_uc, n_unit_cells, layers_dict, - print_info = False, - ): - + print_info=False, +): atoms = Atoms() cell = ase_atoms_uc.cell.array.copy() z_norm = numpy.linalg.norm(cell[2]) n_layers_uc = len(layers_dict) - stacking_order_uc = ''.join(layers_dict.keys()) + stacking_order_uc = "".join(layers_dict.keys()) stacking_order = n_unit_cells * stacking_order_uc stacking_order_uc_r = stacking_order_uc[::-1] if not isinstance(n_unit_cells, int) or n_unit_cells < 1: - raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be a positive integer.") + raise ValueError( + f"Invalid number of unit cells {n_unit_cells}. Must be a positive integer." + ) # Taking 3 unit cells of 3-layer unit cell as an example # Firstly, we place an 'ABC' stacking as a substrate. spacings = [ - (layers_dict[label]['z'] - layers_dict[prev_label]['z'])*z_norm + (layers_dict[label]["z"] - layers_dict[prev_label]["z"]) * z_norm for label, prev_label in zip(stacking_order_uc[1:], stacking_order_uc[:-1]) - ] - connection_to_next_cell = (1 + layers_dict[stacking_order[0]]['z'] - layers_dict[stacking_order[-1]]['z']) * z_norm + ] + connection_to_next_cell = ( + 1 + layers_dict[stacking_order[0]]["z"] - layers_dict[stacking_order[-1]]["z"] + ) * z_norm if print_info: print(spacings) # Then we calculate the z coordinate of 3 stacked unit cells. # (ABC)ABCABCABC zs = [ - (value['z'] + layer) * z_norm + (value["z"] + layer) * z_norm for layer in range(n_unit_cells) for value in layers_dict.values() - ] + ] # And we calculate the spacing of (ABC)CBACBACBA and reverse it. # We calculate the spacing between the layers. # Note that the first spacing just link the substrate to the reversed layers. # It's convenient then we remove one C layer. # We pop the last spacing between B and A because # it will be calculated later when we do normal stacking. - spacings += [ - z - prev_z - for z, prev_z in zip(zs[1:], zs[:-1]) - ][::-1] + spacings += [z - prev_z for z, prev_z in zip(zs[1:], zs[:-1])][::-1] # spacings.pop() if print_info: - print('zs for reversed layers', zs) - print('spacings for reversed layers', spacings) + print("zs for reversed layers", zs) + print("spacings for reversed layers", spacings) # Here we do the stacking of the rest (n_unit_cells-1) unit cells. # Because we already have one substrate unit cell. # (ABC)(BACBACBA)(BCABC) zs = [ - (value['z'] + layer + n_unit_cells+1) * z_norm - for layer in range(n_unit_cells-1) + (value["z"] + layer + n_unit_cells + 1) * z_norm + for layer in range(n_unit_cells - 1) for value in layers_dict.values() - ] + ] - spacings += [ - z - prev_z - for z, prev_z in zip(zs[1:], zs[:-1]) - ] + spacings += [z - prev_z for z, prev_z in zip(zs[1:], zs[:-1])] if print_info: print(spacings) @@ -165,7 +187,11 @@ def build_atoms_from_stacking_mirror( new_thickness = zs[-1] + connection_to_next_cell - faulted_stacking = stacking_order_uc[:-1] + stacking_order_uc_r * n_unit_cells + (stacking_order_uc * (n_unit_cells-1))[1:] + faulted_stacking = ( + stacking_order_uc[:-1] + + stacking_order_uc_r * n_unit_cells + + (stacking_order_uc * (n_unit_cells - 1))[1:] + ) if print_info: print(faulted_stacking) z_dialation = new_thickness / z_norm @@ -173,7 +199,7 @@ def build_atoms_from_stacking_mirror( new_cell[-1] *= z_dialation atoms.set_cell(new_cell) for layer_label, z in zip(faulted_stacking, zs): - for atom in layers_dict[layer_label]['atoms']: + for atom in layers_dict[layer_label]["atoms"]: new_atom = deepcopy(atom) scaled_position = new_atom.scaled_position scaled_position[-1] = z / new_thickness @@ -182,32 +208,38 @@ def build_atoms_from_stacking_mirror( return atoms + def build_atoms_from_burger_vector( ase_atoms_uc, n_unit_cells, burger_vector, layers_dict, - print_info = False, - ): - + print_info=False, +): atoms = Atoms() - stacking_order = ''.join(layers_dict.keys()) + stacking_order = "".join(layers_dict.keys()) if not isinstance(n_unit_cells, int) or n_unit_cells < 2: - raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be an integer >= 2.") + raise ValueError( + f"Invalid number of unit cells {n_unit_cells}. Must be an integer >= 2." + ) - zs = [(value['z'] + layer)/n_unit_cells/2 for layer in range(2*n_unit_cells) for value in layers_dict.values()][::-1] + zs = [ + (value["z"] + layer) / n_unit_cells / 2 + for layer in range(2 * n_unit_cells) + for value in layers_dict.values() + ][::-1] if print_info: print(zs) new_cell = ase_atoms_uc.cell.array.copy() - new_cell[-1] *= (n_unit_cells*2) + new_cell[-1] *= n_unit_cells * 2 atoms.set_cell(new_cell) for layer_label in stacking_order: z = zs.pop() - for atom in layers_dict[layer_label]['atoms']: + for atom in layers_dict[layer_label]["atoms"]: new_atom = deepcopy(atom) scaled_position = new_atom.scaled_position scaled_position[-1] = z @@ -217,7 +249,7 @@ def build_atoms_from_burger_vector( for layer in range(n_unit_cells): for layer_label in stacking_order: z = zs.pop() - for atom in layers_dict[layer_label]['atoms']: + for atom in layers_dict[layer_label]["atoms"]: new_atom = deepcopy(atom) scaled_position = new_atom.scaled_position scaled_position += numpy.array(burger_vector) @@ -225,10 +257,10 @@ def build_atoms_from_burger_vector( new_atom.position = scaled_position @ new_cell atoms.append(new_atom) - for layer in range(n_unit_cells-1): + for layer in range(n_unit_cells - 1): for layer_label in stacking_order: z = zs.pop() - for atom in layers_dict[layer_label]['atoms']: + for atom in layers_dict[layer_label]["atoms"]: new_atom = deepcopy(atom) scaled_position = new_atom.scaled_position scaled_position[-1] = z @@ -240,15 +272,16 @@ def build_atoms_from_burger_vector( return atoms + def update_faults(faults, interface, burger_vector): """ Update faults list by adding burger_vector to layers at and after interface. - + Args: faults: numpy array of shape (n_layers, 3) containing burger_vectors for each layer interface: Layer index where fault starts (layers at and after this index will be updated) burger_vector: Burger vector to add to layers at/after interface - + Returns: Updated faults array """ @@ -256,6 +289,7 @@ def update_faults(faults, interface, burger_vector): faults[interface:] += burger_vector return faults + def build_atoms_from_burger_vector_general( new_cell, zs, @@ -263,11 +297,11 @@ def build_atoms_from_burger_vector_general( stacking_order_supercell, burger_vector_for_cell, faults, - print_info = False, - ): + print_info=False, +): """ Build atoms structure with burger vector faults. - + Args: new_cell: Cell matrix zs: List of z coordinates for layers @@ -275,23 +309,23 @@ def build_atoms_from_burger_vector_general( stacking_order_supercell: Stacking order for supercell faults: numpy array of shape (n_layers, 3) containing burger_vectors for each layer print_info: Whether to print debug info - + Returns: Atoms object with faults applied """ atoms = Atoms() - + # Calculate cell tilt from total burger_vector in xy plane # Sum all faults to get total burger_vector for cell tilt burger_vector_cart = burger_vector_for_cell[:2] @ new_cell[:2] new_cell_tilted = deepcopy(new_cell) new_cell_tilted[-1] += burger_vector_cart - + atoms.set_cell(new_cell_tilted) for layer_label, fault in zip(stacking_order_supercell, faults): z = zs.pop(0) - for atom in layers_dict[layer_label]['atoms']: + for atom in layers_dict[layer_label]["atoms"]: new_atom = deepcopy(atom) scaled_position = new_atom.scaled_position + fault scaled_position[-1] = z @@ -308,28 +342,36 @@ def build_atoms_from_burger_vector_general( return atoms -@deprecated(reason="This function is not used in any workflow. Use build_atoms_from_burger_vector instead.") + +@deprecated( + reason="This function is not used in any workflow. Use build_atoms_from_burger_vector instead." +) def build_atoms_from_burger_vector_with_vacuum( ase_atoms_uc, n_unit_cells, burger_vector, layers_dict, - vacuum_ratio = 0.0, - print_info = False, - ): - + vacuum_ratio=0.0, + print_info=False, +): atoms = Atoms() - stacking_order = ''.join(layers_dict.keys()) + stacking_order = "".join(layers_dict.keys()) if not isinstance(n_unit_cells, int) or n_unit_cells < 2: - raise ValueError(f"Invalid number of unit cells {n_unit_cells}. Must be an integer >= 2.") + raise ValueError( + f"Invalid number of unit cells {n_unit_cells}. Must be an integer >= 2." + ) new_cell = ase_atoms_uc.cell.array.copy() new_cell[-1] *= n_unit_cells - new_cell[-1] *= (1 + vacuum_ratio) + new_cell[-1] *= 1 + vacuum_ratio atoms.set_cell(new_cell) - zs = [(value['z'] + layer)/n_unit_cells/2/(1 + vacuum_ratio) for layer in range(2*n_unit_cells) for value in layers_dict.values()][::-1] + zs = [ + (value["z"] + layer) / n_unit_cells / 2 / (1 + vacuum_ratio) + for layer in range(2 * n_unit_cells) + for value in layers_dict.values() + ][::-1] # if print_info: # print(zs) @@ -338,7 +380,7 @@ def build_atoms_from_burger_vector_with_vacuum( for layer_label in stacking_order: # z = (layers_dict[layer_label]['z'] + layer)/n_unit_cells/2/(1 + vacuum_ratio) z = zs.pop() - for atom in layers_dict[layer_label]['atoms']: + for atom in layers_dict[layer_label]["atoms"]: new_atom = deepcopy(atom) scaled_position = new_atom.scaled_position scaled_position[-1] = z @@ -349,7 +391,7 @@ def build_atoms_from_burger_vector_with_vacuum( for layer_label in stacking_order: # z = (layers_dict[layer_label]['z'] + layer)/n_unit_cells/2/(1 + vacuum_ratio) z = zs.pop() - for atom in layers_dict[layer_label]['atoms']: + for atom in layers_dict[layer_label]["atoms"]: new_atom = deepcopy(atom) scaled_position = new_atom.scaled_position scaled_position += numpy.array(burger_vector) diff --git a/src/aiida_mechanical/tools/structure_utils.py b/src/aiida_mechanical/tools/structure_utils.py index d4287c5..5bae791 100644 --- a/src/aiida_mechanical/tools/structure_utils.py +++ b/src/aiida_mechanical/tools/structure_utils.py @@ -1,21 +1,23 @@ import numpy -import typing as ty -import pathlib from aiida import orm from copy import deepcopy from deprecated import deprecated import importlib.resources from pymatgen.symmetry.analyzer import SpacegroupAnalyzer + class AttributeDict(dict): """ A dictionary that can be accessed like an attribute. """ + def __getattr__(self, name): try: return self[name] except KeyError: - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) def __setattr__(self, name, value): self[name] = value @@ -24,37 +26,46 @@ def __delattr__(self, name): try: del self[name] except KeyError: - raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + -@deprecated(reason="This function is not used in any workflow. Use ASE's built-in methods instead.") +@deprecated( + reason="This function is not used in any workflow. Use ASE's built-in methods instead." +) def check_bravais_lattice(ase_atoms): bl = ase_atoms.cell.get_bravais_lattice(eps=1e-6) return bl.name + + def __getattr__(name): - if name == 'available_structures': + if name == "available_structures": import importlib.resources + return [ f.stem - for f in importlib.resources.files('aiida_mechanical.data').glob('structures/cif/*.cif') + for f in importlib.resources.files("aiida_mechanical.data").glob( + "structures/cif/*.cif" + ) ] raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -def read_structure_from_file( - formula: str, - store: bool = False - ) -> orm.StructureData: + +def read_structure_from_file(formula: str, store: bool = False) -> orm.StructureData: """Read a cif file by its chemical formula (or path) and return aiida ``StructureData``.""" from ase.io import read as aseread - import importlib.resources formula_str = str(formula) - - if formula_str.endswith('.cif'): - raise ValueError("Please provide the chemical formula without the .cif extension (e.g. 'Al' instead of 'Al.cif')") - if formula_str in __getattr__('available_structures'): - data_path = importlib.resources.files('aiida_mechanical.data') - filename = data_path / f'structures/cif/{formula_str}.cif' + if formula_str.endswith(".cif"): + raise ValueError( + "Please provide the chemical formula without the .cif extension (e.g. 'Al' instead of 'Al.cif')" + ) + + if formula_str in __getattr__("available_structures"): + data_path = importlib.resources.files("aiida_mechanical.data") + filename = data_path / f"structures/cif/{formula_str}.cif" else: filename = formula_str @@ -66,10 +77,11 @@ def read_structure_from_file( return struct + def group_by_layers( ase_atoms, decimals=6, - ): +): """ Splits an ASE Atoms object into multiple layers based on z-coordinates. @@ -83,7 +95,6 @@ def group_by_layers( and values are new Atoms objects, each containing one layer. """ import string - from copy import deepcopy if not ase_atoms: return {} @@ -97,8 +108,10 @@ def group_by_layers( labels = string.ascii_uppercase if len(sorted_unique_z) > len(labels): - print(f"Warning: Number of layers ({len(sorted_unique_z)}) exceeds number of labels ({len(labels)}).") - labels = [f"Layer_{i+1}" for i in range(len(sorted_unique_z))] + print( + f"Warning: Number of layers ({len(sorted_unique_z)}) exceeds number of labels ({len(labels)})." + ) + labels = [f"Layer_{i + 1}" for i in range(len(sorted_unique_z))] labeled_layers_dict = {} @@ -106,20 +119,16 @@ def group_by_layers( layer_label = labels[i] indices = numpy.where(rounded_z == z_val)[0] layer_content = [deepcopy(ase_atoms[idx]) for idx in indices] - labeled_layers_dict[layer_label] = { - 'atoms': layer_content, - 'z': z_val - } + labeled_layers_dict[layer_label] = {"atoms": layer_content, "z": z_val} return labeled_layers_dict + def get_strukturbericht( atoms_to_check, - print_info = False, - ): - import pymatgen.core as mg + print_info=False, +): from pymatgen.analysis.structure_matcher import StructureMatcher - from pymatgen.symmetry.analyzer import SpacegroupAnalyzer from pymatgen.io.ase import AseAtomsAdaptor # This dictionary holds the names of common prototypes and their # corresponding Material IDs (mp-id) in the Materials Project database. @@ -130,17 +139,29 @@ def get_strukturbericht( # In your real code, you would use: struct_to_check = mg.Structure.from_file("your_file.cif") PROTOTYPES = { - "A1": read_structure_from_file('Al').get_pymatgen(), # Copper (Cu) - 'A2': read_structure_from_file('V').get_pymatgen(), # Vandadium (V) - "B1": read_structure_from_file('AsTe').get_pymatgen(), # Arsenic Telluride (AsTe) - "B2": read_structure_from_file('NiTi').get_pymatgen(), # Arsenic Telluride (AsTe) - "B_h": read_structure_from_file('MoN').get_pymatgen(), # Arsenic Telluride (AsTe) - "A15": read_structure_from_file('Nb3Sn').get_pymatgen(), # Nb3Sn (Nb3Sn) - "C1_b": read_structure_from_file('NbCoSb').get_pymatgen(), # Gold-Copper (AuCu3) - "L2_1": read_structure_from_file('HfAlPd2').get_pymatgen(), # Gold-Copper (AuCu3) - "C_7": read_structure_from_file('TaSe2').get_pymatgen(), # Gold-Copper (AuCu3) - "C_32": read_structure_from_file('MgB2').get_pymatgen(), # Gold-Copper (AuCu3) - "E_21": read_structure_from_file('TaRu3C').get_pymatgen(), # Gold-Copper (AuCu3) + "A1": read_structure_from_file("Al").get_pymatgen(), # Copper (Cu) + "A2": read_structure_from_file("V").get_pymatgen(), # Vandadium (V) + "B1": read_structure_from_file( + "AsTe" + ).get_pymatgen(), # Arsenic Telluride (AsTe) + "B2": read_structure_from_file( + "NiTi" + ).get_pymatgen(), # Arsenic Telluride (AsTe) + "B_h": read_structure_from_file( + "MoN" + ).get_pymatgen(), # Arsenic Telluride (AsTe) + "A15": read_structure_from_file("Nb3Sn").get_pymatgen(), # Nb3Sn (Nb3Sn) + "C1_b": read_structure_from_file( + "NbCoSb" + ).get_pymatgen(), # Gold-Copper (AuCu3) + "L2_1": read_structure_from_file( + "HfAlPd2" + ).get_pymatgen(), # Gold-Copper (AuCu3) + "C_7": read_structure_from_file("TaSe2").get_pymatgen(), # Gold-Copper (AuCu3) + "C_32": read_structure_from_file("MgB2").get_pymatgen(), # Gold-Copper (AuCu3) + "E_21": read_structure_from_file( + "TaRu3C" + ).get_pymatgen(), # Gold-Copper (AuCu3) } struct_to_check = AseAtomsAdaptor.get_structure(atoms_to_check) @@ -161,19 +182,26 @@ def get_strukturbericht( # Use the .fit() method to see if they match if matcher.fit_anonymous(struct_to_check, prototype_struct): if print_info: - print(f"✅ Your structure<{atoms_to_check.get_chemical_formula()}> is of the {name} type.") + print( + f"✅ Your structure<{atoms_to_check.get_chemical_formula()}> is of the {name} type." + ) found_match = True return name if not found_match: if print_info: - print(f"\n❌ No match found for structure<{atoms_to_check.get_chemical_formula()}> in the provided list of prototypes.") + print( + f"\n❌ No match found for structure<{atoms_to_check.get_chemical_formula()}> in the provided list of prototypes." + ) return None except Exception as e: print(f"An error occurred: {e}") - print("Please ensure you have a valid structure file or an API key for the Materials Project.") + print( + "Please ensure you have a valid structure file or an API key for the Materials Project." + ) return None + def is_primitive_cell(structure: orm.StructureData) -> bool: """ Check if the structure is a primitive cell @@ -184,30 +212,31 @@ def is_primitive_cell(structure: orm.StructureData) -> bool: return structure_pmg.composition == primivite_structure_pmg.composition + def get_elements_for_wyckoff_symbols( - structure: orm.StructureData, - ) -> dict: + structure: orm.StructureData, +) -> dict: """ Get the symbol of the atom at the given fractional coordinates """ sga = SpacegroupAnalyzer(structure.get_pymatgen_structure(), symprec=1e-5) symmetrized_structure = sga.get_symmetrized_structure() + return { + wyckoff_letter: element.symbol + for wyckoff_letter, element in zip( + symmetrized_structure.wyckoff_letters, symmetrized_structure.elements + ) + } - return {wyckoff_letter: element.symbol - for wyckoff_letter, element in zip( - symmetrized_structure.wyckoff_letters, - symmetrized_structure.elements - ) - } def calculate_surface_area(ase_atoms) -> float: """ Calculate the surface area of the structure (XY plane area). - + Args: ase_atoms: ASE Atoms object - + Returns: float: Surface area in Angstrom^2 """ diff --git a/src/aiida_mechanical/workflows/dislocation/__init__.py b/src/aiida_mechanical/workflows/dislocation/__init__.py index b33aacb..8b883b1 100644 --- a/src/aiida_mechanical/workflows/dislocation/__init__.py +++ b/src/aiida_mechanical/workflows/dislocation/__init__.py @@ -1,6 +1,6 @@ from .isfe import ( ISFEWorkChain, -) +) from .esfe import ( ESFEWorkChain, ) @@ -24,12 +24,12 @@ ) __all__ = ( - 'ISFEWorkChain', - 'ESFEWorkChain', - 'USFEWorkChain', - 'TwinningWorkChain', - 'GSFEWorkChain', - 'GSFERelaxWorkChain', - 'RigidLayerRelaxWorkChain', - 'SurfaceEnergyWorkChain', -) \ No newline at end of file + "ISFEWorkChain", + "ESFEWorkChain", + "USFEWorkChain", + "TwinningWorkChain", + "GSFEWorkChain", + "GSFERelaxWorkChain", + "RigidLayerRelaxWorkChain", + "SurfaceEnergyWorkChain", +) diff --git a/src/aiida_mechanical/workflows/dislocation/esfe.py b/src/aiida_mechanical/workflows/dislocation/esfe.py index c49c378..a3c1143 100644 --- a/src/aiida_mechanical/workflows/dislocation/esfe.py +++ b/src/aiida_mechanical/workflows/dislocation/esfe.py @@ -1,26 +1,24 @@ from .sfebase import SFEBaseWorkChain -from .layer_relax import RigidLayerRelaxWorkChain -from aiida import orm + class ESFEWorkChain(SFEBaseWorkChain): """ESFE WorkChain""" - + _SFE_NAMESPACE = "esfe" @classmethod def define(cls, spec): super().define(spec) - + spec.exit_code( 403, "ERROR_SUB_PROCESS_FAILED_ESF", - message='The `PwBaseWorkChain` for the ESF run failed.', + message="The `PwBaseWorkChain` for the ESF run failed.", ) - def _get_fault_type(self): """Return the fault type for ESFE workchain.""" - return 'extrinsic' + return "extrinsic" def results(self): """Expose collected ESFE data to the caller.""" diff --git a/src/aiida_mechanical/workflows/dislocation/gsfe.py b/src/aiida_mechanical/workflows/dislocation/gsfe.py index 115d4d2..c4e8ac3 100644 --- a/src/aiida_mechanical/workflows/dislocation/gsfe.py +++ b/src/aiida_mechanical/workflows/dislocation/gsfe.py @@ -25,87 +25,102 @@ clean_workchain_calcs, ) + class GSFEWorkChain( ProtocolMixin, StructureGenerationMixin, EnergyCalculationMixin, KpointsSetupMixin, WorkflowInspectionMixin, - WorkChain): + WorkChain, +): """GSFE WorkChain""" - _NAMESPACE = 'gsfe' + _NAMESPACE = "gsfe" _RELAX_NAMESPACE = "relax" _SCF_NAMESPACE = "scf" _SFE_NAMESPACE = "sfe" _SURFACE_ENERGY_NAMESPACE = "surface_energy" - + _RY2eV = 13.605693122990 - _eVA22Jm2 = 1.602176634E-19 * 1E+20 - + _eVA22Jm2 = 1.602176634e-19 * 1e20 + @classmethod def define(cls, spec) -> None: super().define(spec) - spec.input('structure', valid_type=orm.StructureData, required=True,) spec.input( - 'faulted_structure_data', + "structure", + valid_type=orm.StructureData, + required=True, + ) + spec.input( + "faulted_structure_data", valid_type=FaultedStructureData, required=False, default=lambda: FaultedStructureData(n_unit_cells=4), - help='Configuration for GSFE faulted-structure generation.', + help="Configuration for GSFE faulted-structure generation.", + ) + spec.input( + "kpoints_distance", + valid_type=orm.Float, + required=False, + default=lambda: orm.Float(0.3), + help="The distance between kpoints for the kpoints generation", + ) + spec.input( + "clean_workdir", + valid_type=orm.Bool, + default=lambda: orm.Bool(False), + help="If `True`, work directories of all called calculation will be cleaned at the end of execution.", ) - spec.input('kpoints_distance', valid_type=orm.Float, required=False, default=lambda: orm.Float(0.3), - help='The distance between kpoints for the kpoints generation') - spec.input('clean_workdir', valid_type=orm.Bool, default=lambda: orm.Bool(False), - help='If `True`, work directories of all called calculation will be cleaned at the end of execution.') spec.expose_inputs( PwRelaxWorkChain, namespace=cls._RELAX_NAMESPACE, exclude=( - 'structure', - 'clean_workdir', - 'kpoints', - 'kpoints_distance', + "structure", + "clean_workdir", + "kpoints", + "kpoints_distance", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwRelaxWorkChain`.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwRelaxWorkChain`.", + }, ) spec.expose_inputs( PwBaseWorkChain, namespace=cls._SCF_NAMESPACE, exclude=( - 'pw.structure', - 'clean_workdir', - 'kpoints', - 'kpoints_distance', + "pw.structure", + "clean_workdir", + "kpoints", + "kpoints_distance", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwBaseWorkChain` for SCF calculation.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwBaseWorkChain` for SCF calculation.", + }, ) spec.expose_inputs( PwBaseWorkChain, namespace=cls._SFE_NAMESPACE, exclude=( - 'pw.structure', - 'clean_workdir', - 'kpoints', - 'kpoints_distance', + "pw.structure", + "clean_workdir", + "kpoints", + "kpoints_distance", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwBaseWorkChain` for USF calculation.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwBaseWorkChain` for USF calculation.", + }, ) spec.outline( @@ -129,51 +144,52 @@ def define(cls, spec) -> None: PwRelaxWorkChain, namespace=cls._RELAX_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) spec.expose_outputs( PwBaseWorkChain, namespace=cls._SCF_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) spec.output( - 'results', + "results", valid_type=orm.Dict, required=False, - help='Aggregated GSFE results for all evaluated faulted structures.', + help="Aggregated GSFE results for all evaluated faulted structures.", ) - + spec.exit_code( 401, "ERROR_SUB_PROCESS_FAILED_RELAX", - message='The `PwBaseWorkChain` for the GSF run failed.', + message="The `PwBaseWorkChain` for the GSF run failed.", ) - + spec.exit_code( 402, "ERROR_SUB_PROCESS_FAILED_SCF", - message='The `PwBaseWorkChain` for the USF run failed.', + message="The `PwBaseWorkChain` for the USF run failed.", ) spec.exit_code( 403, "ERROR_SUB_PROCESS_FAILED_USF", - message='The `PwBaseWorkChain` for the USF run failed.', + message="The `PwBaseWorkChain` for the USF run failed.", ) spec.exit_code( 405, "ERROR_NO_STRUCTURE_TYPE_DETECTED", - message='The structure type is not detected.', + message="The structure type is not detected.", ) - + @classmethod def get_protocol_filepath(cls): """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" from importlib_resources import files from . import protocols - return files(protocols) / f'{cls._NAMESPACE}.yaml' + + return files(protocols) / f"{cls._NAMESPACE}.yaml" @classmethod def get_protocol_overrides(cls) -> dict[str, ty.Any]: @@ -188,17 +204,16 @@ def get_protocol_overrides(cls) -> dict[str, ty.Any]: @classmethod def get_builder_from_protocol( - cls, - code, - structure, - protocol='moderate', - overrides=None, - n_repeats: ty.Optional[int | orm.Int] = None, - gliding_plane: ty.Optional[str | orm.Str] = None, - **kwargs - ): - """Return a builder prepopulated with inputs selected according to the chosen protocol. - """ + cls, + code, + structure, + protocol="moderate", + overrides=None, + n_repeats: ty.Optional[int | orm.Int] = None, + gliding_plane: ty.Optional[str | orm.Str] = None, + **kwargs, + ): + """Return a builder prepopulated with inputs selected according to the chosen protocol.""" inputs = cls.get_protocol_inputs(protocol, overrides) args = (code, structure, protocol) @@ -213,157 +228,188 @@ def get_builder_from_protocol( overrides = inputs.get(namespace, {}) if workchain_type == PwRelaxWorkChain: - overrides.setdefault('base_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) - overrides.setdefault('base_init_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + overrides.setdefault("base_relax", {})["pseudo_family"] = inputs.get( + "pseudo_family", None + ) + overrides.setdefault("base_init_relax", {})["pseudo_family"] = ( + inputs.get("pseudo_family", None) + ) else: - overrides['pseudo_family'] = inputs.get('pseudo_family', None) + overrides["pseudo_family"] = inputs.get("pseudo_family", None) sub_builder = workchain_type.get_builder_from_protocol( *args, overrides=overrides, ) - sub_builder.pop('structure', None) - sub_builder.pop('clean_workdir', None) + sub_builder.pop("structure", None) + sub_builder.pop("clean_workdir", None) if namespace != cls._RELAX_NAMESPACE: - sub_builder.pop('kpoints', None) - sub_builder.pop('kpoints_distance', None) + sub_builder.pop("kpoints", None) + sub_builder.pop("kpoints_distance", None) builder[namespace]._data = sub_builder._data if cls._RELAX_NAMESPACE in builder: - builder[cls._RELAX_NAMESPACE].pop('base_init_relax', None) - if 'base_relax' in builder[cls._RELAX_NAMESPACE]: - builder[cls._RELAX_NAMESPACE]['base_relax'].pop('kpoints', None) - builder[cls._RELAX_NAMESPACE]['base_relax'].pop('kpoints_distance', None) - + builder[cls._RELAX_NAMESPACE].pop("base_init_relax", None) + if "base_relax" in builder[cls._RELAX_NAMESPACE]: + builder[cls._RELAX_NAMESPACE]["base_relax"].pop("kpoints", None) + builder[cls._RELAX_NAMESPACE]["base_relax"].pop( + "kpoints_distance", None + ) + builder.structure = structure - resolved_n_repeats = n_repeats.value if isinstance(n_repeats, orm.Int) else n_repeats - resolved_gliding_plane = gliding_plane.value if isinstance(gliding_plane, orm.Str) else gliding_plane + resolved_n_repeats = ( + n_repeats.value if isinstance(n_repeats, orm.Int) else n_repeats + ) + resolved_gliding_plane = ( + gliding_plane.value if isinstance(gliding_plane, orm.Str) else gliding_plane + ) builder.faulted_structure_data = FaultedStructureData( - n_unit_cells=inputs.get('n_repeats', 4) if resolved_n_repeats is None else resolved_n_repeats, - gliding_plane=inputs.get('gliding_plane', '') if resolved_gliding_plane is None else resolved_gliding_plane, + n_unit_cells=inputs.get("n_repeats", 4) + if resolved_n_repeats is None + else resolved_n_repeats, + gliding_plane=inputs.get("gliding_plane", "") + if resolved_gliding_plane is None + else resolved_gliding_plane, ) - builder.kpoints_distance = orm.Float(inputs['kpoints_distance']) - builder.clean_workdir = orm.Bool(inputs['clean_workdir']) + builder.kpoints_distance = orm.Float(inputs["kpoints_distance"]) + builder.clean_workdir = orm.Bool(inputs["clean_workdir"]) return builder - def should_run_relax(self) -> bool: return self._RELAX_NAMESPACE in self.inputs def run_relax(self) -> dict[str, orm.ProcessNode]: inputs = AttributeDict( - self.exposed_inputs( - PwRelaxWorkChain, - namespace=self._RELAX_NAMESPACE - ) + self.exposed_inputs(PwRelaxWorkChain, namespace=self._RELAX_NAMESPACE) ) inputs.metadata.call_link_label = self._RELAX_NAMESPACE inputs.structure = self.inputs.structure inputs.base_relax.kpoints_distance = self.inputs.kpoints_distance running = self.submit(PwRelaxWorkChain, **inputs) - self.report(f'launching PwRelaxWorkChain<{running.pk}> for primitive structure') - return {f"workchain_relax": running} + self.report(f"launching PwRelaxWorkChain<{running.pk}> for primitive structure") + return {"workchain_relax": running} def inspect_relax(self) -> ty.Optional[ExitCode]: workchain = self.ctx.workchain_relax if not workchain.is_finished_ok: - self.report(f'PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + self.report( + f"PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_RELAX - self.report(f'PwRelaxWorkChain<{self.ctx.workchain_relax.pk}> finished') + self.report(f"PwRelaxWorkChain<{self.ctx.workchain_relax.pk}> finished") self.ctx.current_structure = workchain.outputs.output_structure - + # Expose outputs self.out_many( self.exposed_outputs( - workchain, - PwRelaxWorkChain, - namespace=self._RELAX_NAMESPACE + workchain, PwRelaxWorkChain, namespace=self._RELAX_NAMESPACE ) ) def generate_structures(self) -> ty.Optional[ExitCode]: """Generate provenance-tracked structures for GSFE calculations.""" - - if 'current_structure' not in self.ctx: + + if "current_structure" not in self.ctx: self.ctx.current_structure = self.inputs.structure try: generated_structures = generate_faulted_structures( structure=self.ctx.current_structure, faulted_data=self.inputs.faulted_structure_data, - fault_mode=orm.Str('general'), - fault_type=orm.Str('general'), + fault_mode=orm.Str("general"), + fault_type=orm.Str("general"), ) except ValueError as exception: - self.report(f'Failed to generate GSFE structures: {exception}') + self.report(f"Failed to generate GSFE structures: {exception}") return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED self.ctx.generated_structures = [] for output_label, output_node in generated_structures.items(): - if output_label in ('conventional_structure', 'surface_area') or not isinstance(output_node, orm.StructureData): + if output_label in ( + "conventional_structure", + "surface_area", + ) or not isinstance(output_node, orm.StructureData): continue - direction_name = output_node.base.extras.get('direction_name', None) - step_index = output_node.base.extras.get('step_index', None) - burger_vector = output_node.base.extras.get('burger_vector', None) - total_cell_shift = output_node.base.extras.get('total_cell_shift', None) - interface_slips = output_node.base.extras.get('interface_slips', None) - - if None in (direction_name, step_index, burger_vector, total_cell_shift, interface_slips): - self.report(f'Incomplete faulted-structure entry generated for `{output_label}`.') + direction_name = output_node.base.extras.get("direction_name", None) + step_index = output_node.base.extras.get("step_index", None) + burger_vector = output_node.base.extras.get("burger_vector", None) + total_cell_shift = output_node.base.extras.get("total_cell_shift", None) + interface_slips = output_node.base.extras.get("interface_slips", None) + + if None in ( + direction_name, + step_index, + burger_vector, + total_cell_shift, + interface_slips, + ): + self.report( + f"Incomplete faulted-structure entry generated for `{output_label}`." + ) return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED - self.ctx.generated_structures.append({ - 'structure_key': output_label, - 'structure': output_node, - 'direction_name': str(direction_name), - 'step_index': int(step_index), - 'burger_vector': [float(value) for value in burger_vector], - 'total_cell_shift': [float(value) for value in total_cell_shift], - 'interface_slips': { - str(interface): [float(value) for value in interface_shift] - for interface, interface_shift in interface_slips.items() - }, - }) + self.ctx.generated_structures.append( + { + "structure_key": output_label, + "structure": output_node, + "direction_name": str(direction_name), + "step_index": int(step_index), + "burger_vector": [float(value) for value in burger_vector], + "total_cell_shift": [float(value) for value in total_cell_shift], + "interface_slips": { + str(interface): [float(value) for value in interface_shift] + for interface, interface_shift in interface_slips.items() + }, + } + ) self.ctx.generated_structures.sort( - key=lambda entry: (entry['direction_name'], entry['step_index']) + key=lambda entry: (entry["direction_name"], entry["step_index"]) ) if not self.ctx.generated_structures: - self.report('No generalized fault path is available for the selected structure and gliding plane.') + self.report( + "No generalized fault path is available for the selected structure and gliding plane." + ) return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED self.ctx.number_of_structures = len(self.ctx.generated_structures) - self.ctx.conventional_structure = generated_structures['conventional_structure'] - self.ctx.surface_area = generated_structures['surface_area'].value - - self.report(f'Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2') + self.ctx.conventional_structure = generated_structures["conventional_structure"] + self.ctx.surface_area = generated_structures["surface_area"].value + + self.report( + f"Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2" + ) - self.ctx.unit_cell_multiplier = self._calculate_structure_multiplier(self.ctx.current_structure) - self.ctx.conventional_multiplier = self._calculate_structure_multiplier(self.ctx.conventional_structure) + self.ctx.unit_cell_multiplier = self._calculate_structure_multiplier( + self.ctx.current_structure + ) + self.ctx.conventional_multiplier = self._calculate_structure_multiplier( + self.ctx.conventional_structure + ) def _get_kpoints_scf(self) -> orm.KpointsData: """Get or create kpoints_scf. Returns kpoints_scf KpointsData object.""" - if 'kpoints_scf' in self.ctx: + if "kpoints_scf" in self.ctx: kpoints_scf = self.ctx.kpoints_scf else: inputs = { - 'structure': self.ctx.conventional_structure, - 'distance': self.inputs.kpoints_distance, - 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), - 'metadata': { - 'call_link_label': 'create_kpoints_from_distance' - } + "structure": self.ctx.conventional_structure, + "distance": self.inputs.kpoints_distance, + "force_parity": self.inputs.get( + "kpoints_force_parity", orm.Bool(False) + ), + "metadata": {"call_link_label": "create_kpoints_from_distance"}, } kpoints_scf = create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg - + return kpoints_scf # def _get_kpoints_sfe(self) -> orm.KpointsData: @@ -388,20 +434,17 @@ def setup(self) -> None: self.ctx.kpoints_scf = self._get_kpoints_scf() # self.ctx.kpoints_sfe = self._get_kpoints_sfe() self.ctx.kpoints_sfe = self._calculate_kpoints_for_structure( - self.ctx.generated_structures[0]['structure'], + self.ctx.generated_structures[0]["structure"], self.ctx.kpoints_scf, ) - + def should_run_scf(self) -> bool: return self._SCF_NAMESPACE in self.inputs def run_scf(self) -> dict[str, orm.ProcessNode]: inputs = AttributeDict( - self.exposed_inputs( - PwBaseWorkChain, - namespace=self._SCF_NAMESPACE - ) - ) + self.exposed_inputs(PwBaseWorkChain, namespace=self._SCF_NAMESPACE) + ) inputs.metadata.call_link_label = self._SCF_NAMESPACE @@ -409,30 +452,33 @@ def run_scf(self) -> dict[str, orm.ProcessNode]: inputs.kpoints = self.ctx.kpoints_scf running = self.submit(PwBaseWorkChain, **inputs) - self.report(f'launching PwBaseWorkChain<{running.pk}> for conventional structure') + self.report( + f"launching PwBaseWorkChain<{running.pk}> for conventional structure" + ) - return {f"workchain_scf": running} + return {"workchain_scf": running} def inspect_scf(self) -> ty.Optional[ExitCode]: workchain = self.ctx.workchain_scf if not workchain.is_finished_ok: - self.report(f'PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + self.report( + f"PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SCF - self.report(f'PwBaseWorkChain<{self.ctx.workchain_scf.pk}> finished') - + self.report(f"PwBaseWorkChain<{self.ctx.workchain_scf.pk}> finished") + # Expose outputs self.out_many( self.exposed_outputs( - workchain, - PwBaseWorkChain, - namespace=self._SCF_NAMESPACE + workchain, PwBaseWorkChain, namespace=self._SCF_NAMESPACE ) ) - self.ctx.total_energy_conventional_geometry = self._get_workchain_energy(workchain) + self.ctx.total_energy_conventional_geometry = self._get_workchain_energy( + workchain + ) def should_run_sfe(self) -> bool: - if self._SFE_NAMESPACE not in self.inputs: return False @@ -440,23 +486,22 @@ def should_run_sfe(self) -> bool: return False current_entry = self.ctx.generated_structures[self.ctx.iteration] - self.ctx.current_structure_key = current_entry['structure_key'] - self.ctx.current_structure = current_entry['structure'] - self.ctx.current_direction_name = current_entry['direction_name'] - self.ctx.current_step_index = current_entry['step_index'] - self.ctx.current_burger_vector = current_entry['burger_vector'] - self.ctx.current_total_cell_shift = current_entry['total_cell_shift'] - self.ctx.current_interface_slips = current_entry['interface_slips'] - self.ctx.current_multiplier = self._calculate_structure_multiplier(self.ctx.current_structure) + self.ctx.current_structure_key = current_entry["structure_key"] + self.ctx.current_structure = current_entry["structure"] + self.ctx.current_direction_name = current_entry["direction_name"] + self.ctx.current_step_index = current_entry["step_index"] + self.ctx.current_burger_vector = current_entry["burger_vector"] + self.ctx.current_total_cell_shift = current_entry["total_cell_shift"] + self.ctx.current_interface_slips = current_entry["interface_slips"] + self.ctx.current_multiplier = self._calculate_structure_multiplier( + self.ctx.current_structure + ) return True def run_sfe(self) -> dict[str, ty.Any]: inputs = AttributeDict( - self.exposed_inputs( - PwBaseWorkChain, - namespace=self._SFE_NAMESPACE - ) + self.exposed_inputs(PwBaseWorkChain, namespace=self._SFE_NAMESPACE) ) inputs.metadata.call_link_label = self.ctx.current_structure_key @@ -465,45 +510,53 @@ def run_sfe(self) -> dict[str, ty.Any]: running = self.submit(PwBaseWorkChain, **inputs) self.report( - f'launching PwBaseWorkChain<{running.pk}> for faulted structure ' - f'{self.ctx.iteration + 1}/{self.ctx.number_of_structures} ({self.ctx.current_structure_key}).' + f"launching PwBaseWorkChain<{running.pk}> for faulted structure " + f"{self.ctx.iteration + 1}/{self.ctx.number_of_structures} ({self.ctx.current_structure_key})." ) - return {f"workchain_sfe": append_(running)} + return {"workchain_sfe": append_(running)} def inspect_sfe(self) -> ty.Optional[ExitCode]: workchain = self.ctx.workchain_sfe[-1] if not workchain.is_finished_ok: - self.report(f'PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + self.report( + f"PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_USF - self.report(f'PwBaseWorkChain<{workchain.pk}> finished') + self.report(f"PwBaseWorkChain<{workchain.pk}> finished") total_energy_faulted_geometry = self._get_workchain_energy(workchain) gsfe_j_m2 = None - if 'total_energy_conventional_geometry' in self.ctx: + if "total_energy_conventional_geometry" in self.ctx: gsfe_j_m2 = self._calculate_stacking_fault_energy( total_energy_faulted_geometry, self.ctx.current_multiplier, - 'generalized stacking fault' + "generalized stacking fault", ) - self.ctx.sfe_results.append({ - 'label': self.ctx.current_structure_key, - 'structure_uuid': self.ctx.current_structure.uuid, - 'direction_name': self.ctx.current_direction_name, - 'step_index': self.ctx.current_step_index, - 'burger_vector': [float(value) for value in self.ctx.current_burger_vector], - 'total_cell_shift': [float(value) for value in self.ctx.current_total_cell_shift], - 'interface_slips': { - str(interface): [float(value) for value in interface_shift] - for interface, interface_shift in self.ctx.current_interface_slips.items() - }, - 'energy': float(total_energy_faulted_geometry), - 'sfe': float(gsfe_j_m2) if gsfe_j_m2 is not None else None, - 'workchain_uuid': workchain.uuid, - }) + self.ctx.sfe_results.append( + { + "label": self.ctx.current_structure_key, + "structure_uuid": self.ctx.current_structure.uuid, + "direction_name": self.ctx.current_direction_name, + "step_index": self.ctx.current_step_index, + "burger_vector": [ + float(value) for value in self.ctx.current_burger_vector + ], + "total_cell_shift": [ + float(value) for value in self.ctx.current_total_cell_shift + ], + "interface_slips": { + str(interface): [float(value) for value in interface_shift] + for interface, interface_shift in self.ctx.current_interface_slips.items() + }, + "energy": float(total_energy_faulted_geometry), + "sfe": float(gsfe_j_m2) if gsfe_j_m2 is not None else None, + "workchain_uuid": workchain.uuid, + } + ) self.ctx.iteration += 1 def results(self) -> None: @@ -511,39 +564,45 @@ def results(self) -> None: nested_results: dict[str, dict[str, dict[str, ty.Any]]] = {} for point_result in self.ctx.sfe_results: - direction_results = nested_results.setdefault(point_result['direction_name'], {}) - direction_results[str(point_result['step_index'])] = { - 'label': point_result['label'], - 'structure_uuid': point_result['structure_uuid'], - 'step_index': point_result['step_index'], - 'burger_vector': point_result['burger_vector'], - 'total_cell_shift': point_result['total_cell_shift'], - 'interface_slips': point_result['interface_slips'], - 'energy': point_result['energy'], - 'sfe': point_result['sfe'], - 'workchain_uuid': point_result['workchain_uuid'], + direction_results = nested_results.setdefault( + point_result["direction_name"], {} + ) + direction_results[str(point_result["step_index"])] = { + "label": point_result["label"], + "structure_uuid": point_result["structure_uuid"], + "step_index": point_result["step_index"], + "burger_vector": point_result["burger_vector"], + "total_cell_shift": point_result["total_cell_shift"], + "interface_slips": point_result["interface_slips"], + "energy": point_result["energy"], + "sfe": point_result["sfe"], + "workchain_uuid": point_result["workchain_uuid"], } results = { - 'results': nested_results, - 'surface_area_angstrom2': float(self.ctx.surface_area), - 'number_of_structures': self.ctx.number_of_structures, + "results": nested_results, + "surface_area_angstrom2": float(self.ctx.surface_area), + "number_of_structures": self.ctx.number_of_structures, } - if 'total_energy_conventional_geometry' in self.ctx: - results['conventional_energy_ev'] = float(self.ctx.total_energy_conventional_geometry) + if "total_energy_conventional_geometry" in self.ctx: + results["conventional_energy_ev"] = float( + self.ctx.total_energy_conventional_geometry + ) - self.out('results', orm.Dict(dict=results).store()) + self.out("results", orm.Dict(dict=results).store()) def on_terminated(self) -> None: """Clean child calculation working directories if ``clean_workdir`` is enabled.""" super().on_terminated() if self.inputs.clean_workdir.value is False: - self.report('remote folders will not be cleaned') + self.report("remote folders will not be cleaned") return cleaned_calcs = clean_workchain_calcs(self.node) if cleaned_calcs: - self.report(f'cleaned remote folders of calculations: {" ".join(map(str, cleaned_calcs))}') + self.report( + f"cleaned remote folders of calculations: {' '.join(map(str, cleaned_calcs))}" + ) diff --git a/src/aiida_mechanical/workflows/dislocation/gsfe_relax.py b/src/aiida_mechanical/workflows/dislocation/gsfe_relax.py index ee1c332..0e77e39 100644 --- a/src/aiida_mechanical/workflows/dislocation/gsfe_relax.py +++ b/src/aiida_mechanical/workflows/dislocation/gsfe_relax.py @@ -26,87 +26,102 @@ clean_workchain_calcs, ) + class GSFERelaxWorkChain( ProtocolMixin, StructureGenerationMixin, EnergyCalculationMixin, KpointsSetupMixin, WorkflowInspectionMixin, - WorkChain): + WorkChain, +): """GSFE WorkChain""" - _NAMESPACE = 'gsfe_relax' + _NAMESPACE = "gsfe_relax" _RELAX_NAMESPACE = "relax" _SCF_NAMESPACE = "scf" _SFE_NAMESPACE = "sfe" _SURFACE_ENERGY_NAMESPACE = "surface_energy" - + _RY2eV = 13.605693122990 - _eVA22Jm2 = 1.602176634E-19 * 1E+20 - + _eVA22Jm2 = 1.602176634e-19 * 1e20 + @classmethod def define(cls, spec) -> None: super().define(spec) - spec.input('structure', valid_type=orm.StructureData, required=True,) spec.input( - 'faulted_structure_data', + "structure", + valid_type=orm.StructureData, + required=True, + ) + spec.input( + "faulted_structure_data", valid_type=FaultedStructureData, required=False, default=lambda: FaultedStructureData(n_unit_cells=4), - help='Configuration for GSFE faulted-structure generation.', + help="Configuration for GSFE faulted-structure generation.", + ) + spec.input( + "kpoints_distance", + valid_type=orm.Float, + required=False, + default=lambda: orm.Float(0.3), + help="The distance between kpoints for the kpoints generation", + ) + spec.input( + "clean_workdir", + valid_type=orm.Bool, + default=lambda: orm.Bool(False), + help="If `True`, work directories of all called calculation will be cleaned at the end of execution.", ) - spec.input('kpoints_distance', valid_type=orm.Float, required=False, default=lambda: orm.Float(0.3), - help='The distance between kpoints for the kpoints generation') - spec.input('clean_workdir', valid_type=orm.Bool, default=lambda: orm.Bool(False), - help='If `True`, work directories of all called calculation will be cleaned at the end of execution.') spec.expose_inputs( PwRelaxWorkChain, namespace=cls._RELAX_NAMESPACE, exclude=( - 'structure', - 'clean_workdir', - 'base_relax.kpoints', - 'base_relax.kpoints_distance', + "structure", + "clean_workdir", + "base_relax.kpoints", + "base_relax.kpoints_distance", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwRelaxWorkChain`.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwRelaxWorkChain`.", + }, ) spec.expose_inputs( PwBaseWorkChain, namespace=cls._SCF_NAMESPACE, exclude=( - 'pw.structure', - 'clean_workdir', - 'kpoints', - 'kpoints_distance', + "pw.structure", + "clean_workdir", + "kpoints", + "kpoints_distance", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwBaseWorkChain` for SCF calculation.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwBaseWorkChain` for SCF calculation.", + }, ) spec.expose_inputs( PwRelaxWorkChain, namespace=cls._SFE_NAMESPACE, exclude=( - 'structure', - 'clean_workdir', - 'base_relax.kpoints', - 'base_relax.kpoints_distance', + "structure", + "clean_workdir", + "base_relax.kpoints", + "base_relax.kpoints_distance", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwRelaxWorkChain` for SFE calculation.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwRelaxWorkChain` for SFE calculation.", + }, ) spec.outline( @@ -130,51 +145,52 @@ def define(cls, spec) -> None: PwRelaxWorkChain, namespace=cls._RELAX_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) spec.expose_outputs( PwBaseWorkChain, namespace=cls._SCF_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) spec.output( - 'results', + "results", valid_type=orm.Dict, required=False, - help='Aggregated GSFE results for all evaluated faulted structures.', + help="Aggregated GSFE results for all evaluated faulted structures.", ) - + spec.exit_code( 401, "ERROR_SUB_PROCESS_FAILED_RELAX", - message='The `PwRelaxWorkChain` for the relax run failed.', + message="The `PwRelaxWorkChain` for the relax run failed.", ) - + spec.exit_code( 402, "ERROR_SUB_PROCESS_FAILED_SCF", - message='The `PwBaseWorkChain` for the SCF run failed.', + message="The `PwBaseWorkChain` for the SCF run failed.", ) spec.exit_code( 403, "ERROR_SUB_PROCESS_FAILED_SFE", - message='The `PwRelaxWorkChain` for the SFE run failed.', + message="The `PwRelaxWorkChain` for the SFE run failed.", ) spec.exit_code( 405, "ERROR_NO_STRUCTURE_TYPE_DETECTED", - message='The structure type is not detected.', + message="The structure type is not detected.", ) - + @classmethod def get_protocol_filepath(cls): """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" from importlib_resources import files from . import protocols - return files(protocols) / f'{cls._NAMESPACE}.yaml' + + return files(protocols) / f"{cls._NAMESPACE}.yaml" @classmethod def get_protocol_overrides(cls) -> dict[str, ty.Any]: @@ -189,17 +205,16 @@ def get_protocol_overrides(cls) -> dict[str, ty.Any]: @classmethod def get_builder_from_protocol( - cls, - code, - structure, - protocol='moderate', - overrides=None, - n_repeats: ty.Optional[int | orm.Int] = None, - gliding_plane: ty.Optional[str | orm.Str] = None, - **kwargs - ): - """Return a builder prepopulated with inputs selected according to the chosen protocol. - """ + cls, + code, + structure, + protocol="moderate", + overrides=None, + n_repeats: ty.Optional[int | orm.Int] = None, + gliding_plane: ty.Optional[str | orm.Str] = None, + **kwargs, + ): + """Return a builder prepopulated with inputs selected according to the chosen protocol.""" inputs = cls.get_protocol_inputs(protocol, overrides) args = (code, structure, protocol) @@ -214,158 +229,186 @@ def get_builder_from_protocol( overrides = inputs.get(namespace, {}) if workchain_type == PwRelaxWorkChain: - overrides.setdefault('base_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) - overrides.setdefault('base_init_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + overrides.setdefault("base_relax", {})["pseudo_family"] = inputs.get( + "pseudo_family", None + ) + overrides.setdefault("base_init_relax", {})["pseudo_family"] = ( + inputs.get("pseudo_family", None) + ) else: - overrides['pseudo_family'] = inputs.get('pseudo_family', None) + overrides["pseudo_family"] = inputs.get("pseudo_family", None) sub_builder = workchain_type.get_builder_from_protocol( *args, overrides=overrides, ) - sub_builder.pop('structure', None) - sub_builder.pop('clean_workdir', None) + sub_builder.pop("structure", None) + sub_builder.pop("clean_workdir", None) if workchain_type == PwBaseWorkChain: - sub_builder.pop('kpoints', None) - sub_builder.pop('kpoints_distance', None) + sub_builder.pop("kpoints", None) + sub_builder.pop("kpoints_distance", None) if workchain_type == PwRelaxWorkChain: - sub_builder.pop('base_init_relax', None) - if 'base_relax' in sub_builder: - sub_builder['base_relax'].pop('kpoints', None) - sub_builder['base_relax'].pop('kpoints_distance', None) - + sub_builder.pop("base_init_relax", None) + if "base_relax" in sub_builder: + sub_builder["base_relax"].pop("kpoints", None) + sub_builder["base_relax"].pop("kpoints_distance", None) builder[namespace]._data = sub_builder._data builder.structure = structure - resolved_n_repeats = n_repeats.value if isinstance(n_repeats, orm.Int) else n_repeats - resolved_gliding_plane = gliding_plane.value if isinstance(gliding_plane, orm.Str) else gliding_plane + resolved_n_repeats = ( + n_repeats.value if isinstance(n_repeats, orm.Int) else n_repeats + ) + resolved_gliding_plane = ( + gliding_plane.value if isinstance(gliding_plane, orm.Str) else gliding_plane + ) builder.faulted_structure_data = FaultedStructureData( - n_unit_cells=inputs.get('n_repeats', 4) if resolved_n_repeats is None else resolved_n_repeats, - gliding_plane=inputs.get('gliding_plane', '') if resolved_gliding_plane is None else resolved_gliding_plane, + n_unit_cells=inputs.get("n_repeats", 4) + if resolved_n_repeats is None + else resolved_n_repeats, + gliding_plane=inputs.get("gliding_plane", "") + if resolved_gliding_plane is None + else resolved_gliding_plane, ) - builder.kpoints_distance = orm.Float(inputs['kpoints_distance']) - builder.clean_workdir = orm.Bool(inputs['clean_workdir']) + builder.kpoints_distance = orm.Float(inputs["kpoints_distance"]) + builder.clean_workdir = orm.Bool(inputs["clean_workdir"]) return builder - def should_run_relax(self) -> bool: return self._RELAX_NAMESPACE in self.inputs def run_relax(self) -> dict[str, orm.ProcessNode]: inputs = AttributeDict( - self.exposed_inputs( - PwRelaxWorkChain, - namespace=self._RELAX_NAMESPACE - ) + self.exposed_inputs(PwRelaxWorkChain, namespace=self._RELAX_NAMESPACE) ) inputs.metadata.call_link_label = self._RELAX_NAMESPACE inputs.structure = self.inputs.structure inputs.base_relax.kpoints_distance = self.inputs.kpoints_distance running = self.submit(PwRelaxWorkChain, **inputs) - self.report(f'launching PwRelaxWorkChain<{running.pk}> for primitive structure') - return {f"workchain_relax": running} + self.report(f"launching PwRelaxWorkChain<{running.pk}> for primitive structure") + return {"workchain_relax": running} def inspect_relax(self) -> ty.Optional[ExitCode]: workchain = self.ctx.workchain_relax if not workchain.is_finished_ok: - self.report(f'PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + self.report( + f"PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_RELAX - self.report(f'PwRelaxWorkChain<{self.ctx.workchain_relax.pk}> finished') + self.report(f"PwRelaxWorkChain<{self.ctx.workchain_relax.pk}> finished") self.ctx.current_structure = workchain.outputs.output_structure - + # Expose outputs self.out_many( self.exposed_outputs( - workchain, - PwRelaxWorkChain, - namespace=self._RELAX_NAMESPACE + workchain, PwRelaxWorkChain, namespace=self._RELAX_NAMESPACE ) ) def generate_structures(self) -> ty.Optional[ExitCode]: """Generate provenance-tracked structures for GSFE calculations.""" - - if 'current_structure' not in self.ctx: + + if "current_structure" not in self.ctx: self.ctx.current_structure = self.inputs.structure try: generated_structures = generate_faulted_structures( structure=self.ctx.current_structure, faulted_data=self.inputs.faulted_structure_data, - fault_mode=orm.Str('general'), - fault_type=orm.Str('general'), + fault_mode=orm.Str("general"), + fault_type=orm.Str("general"), ) except ValueError as exception: - self.report(f'Failed to generate GSFE structures: {exception}') + self.report(f"Failed to generate GSFE structures: {exception}") return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED self.ctx.generated_structures = [] for output_label, output_node in generated_structures.items(): - if output_label in ('conventional_structure', 'surface_area') or not isinstance(output_node, orm.StructureData): + if output_label in ( + "conventional_structure", + "surface_area", + ) or not isinstance(output_node, orm.StructureData): continue - direction_name = output_node.base.extras.get('direction_name', None) - step_index = output_node.base.extras.get('step_index', None) - burger_vector = output_node.base.extras.get('burger_vector', None) - total_cell_shift = output_node.base.extras.get('total_cell_shift', None) - interface_slips = output_node.base.extras.get('interface_slips', None) - - if None in (direction_name, step_index, burger_vector, total_cell_shift, interface_slips): - self.report(f'Incomplete faulted-structure entry generated for `{output_label}`.') + direction_name = output_node.base.extras.get("direction_name", None) + step_index = output_node.base.extras.get("step_index", None) + burger_vector = output_node.base.extras.get("burger_vector", None) + total_cell_shift = output_node.base.extras.get("total_cell_shift", None) + interface_slips = output_node.base.extras.get("interface_slips", None) + + if None in ( + direction_name, + step_index, + burger_vector, + total_cell_shift, + interface_slips, + ): + self.report( + f"Incomplete faulted-structure entry generated for `{output_label}`." + ) return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED - self.ctx.generated_structures.append({ - 'structure_key': output_label, - 'structure': output_node, - 'direction_name': str(direction_name), - 'step_index': int(step_index), - 'burger_vector': [float(value) for value in burger_vector], - 'total_cell_shift': [float(value) for value in total_cell_shift], - 'interface_slips': { - str(interface): [float(value) for value in interface_shift] - for interface, interface_shift in interface_slips.items() - }, - }) + self.ctx.generated_structures.append( + { + "structure_key": output_label, + "structure": output_node, + "direction_name": str(direction_name), + "step_index": int(step_index), + "burger_vector": [float(value) for value in burger_vector], + "total_cell_shift": [float(value) for value in total_cell_shift], + "interface_slips": { + str(interface): [float(value) for value in interface_shift] + for interface, interface_shift in interface_slips.items() + }, + } + ) self.ctx.generated_structures.sort( - key=lambda entry: (entry['direction_name'], entry['step_index']) + key=lambda entry: (entry["direction_name"], entry["step_index"]) ) if not self.ctx.generated_structures: - self.report('No generalized fault path is available for the selected structure and gliding plane.') + self.report( + "No generalized fault path is available for the selected structure and gliding plane." + ) return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED self.ctx.number_of_structures = len(self.ctx.generated_structures) - self.ctx.conventional_structure = generated_structures['conventional_structure'] - self.ctx.surface_area = generated_structures['surface_area'].value - - self.report(f'Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2') + self.ctx.conventional_structure = generated_structures["conventional_structure"] + self.ctx.surface_area = generated_structures["surface_area"].value + + self.report( + f"Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2" + ) - self.ctx.unit_cell_multiplier = self._calculate_structure_multiplier(self.ctx.current_structure) - self.ctx.conventional_multiplier = self._calculate_structure_multiplier(self.ctx.conventional_structure) + self.ctx.unit_cell_multiplier = self._calculate_structure_multiplier( + self.ctx.current_structure + ) + self.ctx.conventional_multiplier = self._calculate_structure_multiplier( + self.ctx.conventional_structure + ) def _get_kpoints_scf(self) -> orm.KpointsData: """Get or create kpoints_scf. Returns kpoints_scf KpointsData object.""" - if 'kpoints_scf' in self.ctx: + if "kpoints_scf" in self.ctx: kpoints_scf = self.ctx.kpoints_scf else: inputs = { - 'structure': self.ctx.conventional_structure, - 'distance': self.inputs.kpoints_distance, - 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), - 'metadata': { - 'call_link_label': 'create_kpoints_from_distance' - } + "structure": self.ctx.conventional_structure, + "distance": self.inputs.kpoints_distance, + "force_parity": self.inputs.get( + "kpoints_force_parity", orm.Bool(False) + ), + "metadata": {"call_link_label": "create_kpoints_from_distance"}, } kpoints_scf = create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg - + return kpoints_scf # def _get_kpoints_sfe(self) -> orm.KpointsData: @@ -390,7 +433,7 @@ def setup(self) -> None: self.ctx.kpoints_scf = self._get_kpoints_scf() # self.ctx.kpoints_sfe = self._get_kpoints_sfe() self.ctx.kpoints_sfe = self._calculate_kpoints_for_structure( - self.ctx.generated_structures[0]['structure'], + self.ctx.generated_structures[0]["structure"], self.ctx.kpoints_scf, ) @@ -399,11 +442,8 @@ def should_run_scf(self) -> bool: def run_scf(self) -> dict[str, orm.ProcessNode]: inputs = AttributeDict( - self.exposed_inputs( - PwBaseWorkChain, - namespace=self._SCF_NAMESPACE - ) - ) + self.exposed_inputs(PwBaseWorkChain, namespace=self._SCF_NAMESPACE) + ) inputs.metadata.call_link_label = self._SCF_NAMESPACE @@ -411,30 +451,33 @@ def run_scf(self) -> dict[str, orm.ProcessNode]: inputs.kpoints = self.ctx.kpoints_scf running = self.submit(PwBaseWorkChain, **inputs) - self.report(f'launching PwBaseWorkChain<{running.pk}> for conventional structure') + self.report( + f"launching PwBaseWorkChain<{running.pk}> for conventional structure" + ) - return {f"workchain_scf": running} + return {"workchain_scf": running} def inspect_scf(self) -> ty.Optional[ExitCode]: workchain = self.ctx.workchain_scf if not workchain.is_finished_ok: - self.report(f'PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + self.report( + f"PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SCF - self.report(f'PwBaseWorkChain<{self.ctx.workchain_scf.pk}> finished') - + self.report(f"PwBaseWorkChain<{self.ctx.workchain_scf.pk}> finished") + # Expose outputs self.out_many( self.exposed_outputs( - workchain, - PwBaseWorkChain, - namespace=self._SCF_NAMESPACE + workchain, PwBaseWorkChain, namespace=self._SCF_NAMESPACE ) ) - self.ctx.total_energy_conventional_geometry = self._get_workchain_energy(workchain) + self.ctx.total_energy_conventional_geometry = self._get_workchain_energy( + workchain + ) def should_run_sfe(self) -> bool: - if self._SFE_NAMESPACE not in self.inputs: return False @@ -442,23 +485,22 @@ def should_run_sfe(self) -> bool: return False current_entry = self.ctx.generated_structures[self.ctx.iteration] - self.ctx.current_structure_key = current_entry['structure_key'] - self.ctx.current_structure = current_entry['structure'] - self.ctx.current_direction_name = current_entry['direction_name'] - self.ctx.current_step_index = current_entry['step_index'] - self.ctx.current_burger_vector = current_entry['burger_vector'] - self.ctx.current_total_cell_shift = current_entry['total_cell_shift'] - self.ctx.current_interface_slips = current_entry['interface_slips'] - self.ctx.current_multiplier = self._calculate_structure_multiplier(self.ctx.current_structure) + self.ctx.current_structure_key = current_entry["structure_key"] + self.ctx.current_structure = current_entry["structure"] + self.ctx.current_direction_name = current_entry["direction_name"] + self.ctx.current_step_index = current_entry["step_index"] + self.ctx.current_burger_vector = current_entry["burger_vector"] + self.ctx.current_total_cell_shift = current_entry["total_cell_shift"] + self.ctx.current_interface_slips = current_entry["interface_slips"] + self.ctx.current_multiplier = self._calculate_structure_multiplier( + self.ctx.current_structure + ) return True def run_sfe(self) -> dict[str, ty.Any]: inputs = AttributeDict( - self.exposed_inputs( - PwRelaxWorkChain, - namespace=self._SFE_NAMESPACE - ) + self.exposed_inputs(PwRelaxWorkChain, namespace=self._SFE_NAMESPACE) ) inputs.metadata.call_link_label = self.ctx.current_structure_key @@ -466,64 +508,72 @@ def run_sfe(self) -> dict[str, ty.Any]: inputs.base_relax.kpoints = self.ctx.kpoints_sfe parameters = inputs.base_relax.pw.parameters.get_dict() - parameters['CELL']['cell_dofree'] = 'z' + parameters["CELL"]["cell_dofree"] = "z" inputs.base_relax.pw.parameters = orm.Dict(parameters) - + # Apply fixed coordinates for relaxation settings = inputs.base_relax.pw.settings.get_dict() - settings['USE_FRACTIONAL'] = False - + settings["USE_FRACTIONAL"] = False + FIXED_COORDS = numpy.full_like( self.ctx.current_structure.get_ase().get_positions(), fill_value=True, - dtype=bool + dtype=bool, ) FIXED_COORDS[:, -1] = False - settings['FIXED_COORDS'] = FIXED_COORDS.tolist() + settings["FIXED_COORDS"] = FIXED_COORDS.tolist() inputs.base_relax.pw.settings = orm.Dict(settings) running = self.submit(PwRelaxWorkChain, **inputs) self.report( - f'launching PwRelaxWorkChain<{running.pk}> for faulted structure ' - f'{self.ctx.iteration + 1}/{self.ctx.number_of_structures} ({self.ctx.current_structure_key}).' + f"launching PwRelaxWorkChain<{running.pk}> for faulted structure " + f"{self.ctx.iteration + 1}/{self.ctx.number_of_structures} ({self.ctx.current_structure_key})." ) - return {f"workchain_sfe": append_(running)} + return {"workchain_sfe": append_(running)} def inspect_sfe(self) -> ty.Optional[ExitCode]: workchain = self.ctx.workchain_sfe[-1] if not workchain.is_finished_ok: - self.report(f'PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + self.report( + f"PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SFE - self.report(f'PwRelaxWorkChain<{workchain.pk}> finished') + self.report(f"PwRelaxWorkChain<{workchain.pk}> finished") total_energy_faulted_geometry = self._get_workchain_energy(workchain) gsfe_j_m2 = None - if 'total_energy_conventional_geometry' in self.ctx: + if "total_energy_conventional_geometry" in self.ctx: gsfe_j_m2 = self._calculate_stacking_fault_energy( total_energy_faulted_geometry, self.ctx.current_multiplier, - 'generalized stacking fault' + "generalized stacking fault", ) - self.ctx.sfe_results.append({ - 'label': self.ctx.current_structure_key, - 'structure_uuid': self.ctx.current_structure.uuid, - 'direction_name': self.ctx.current_direction_name, - 'step_index': self.ctx.current_step_index, - 'burger_vector': [float(value) for value in self.ctx.current_burger_vector], - 'total_cell_shift': [float(value) for value in self.ctx.current_total_cell_shift], - 'interface_slips': { - str(interface): [float(value) for value in interface_shift] - for interface, interface_shift in self.ctx.current_interface_slips.items() - }, - 'energy': float(total_energy_faulted_geometry), - 'sfe': float(gsfe_j_m2) if gsfe_j_m2 is not None else None, - 'workchain_uuid': workchain.uuid, - }) + self.ctx.sfe_results.append( + { + "label": self.ctx.current_structure_key, + "structure_uuid": self.ctx.current_structure.uuid, + "direction_name": self.ctx.current_direction_name, + "step_index": self.ctx.current_step_index, + "burger_vector": [ + float(value) for value in self.ctx.current_burger_vector + ], + "total_cell_shift": [ + float(value) for value in self.ctx.current_total_cell_shift + ], + "interface_slips": { + str(interface): [float(value) for value in interface_shift] + for interface, interface_shift in self.ctx.current_interface_slips.items() + }, + "energy": float(total_energy_faulted_geometry), + "sfe": float(gsfe_j_m2) if gsfe_j_m2 is not None else None, + "workchain_uuid": workchain.uuid, + } + ) self.ctx.iteration += 1 def results(self) -> None: @@ -531,39 +581,45 @@ def results(self) -> None: nested_results: dict[str, dict[str, dict[str, ty.Any]]] = {} for point_result in self.ctx.sfe_results: - direction_results = nested_results.setdefault(point_result['direction_name'], {}) - direction_results[str(point_result['step_index'])] = { - 'label': point_result['label'], - 'structure_uuid': point_result['structure_uuid'], - 'step_index': point_result['step_index'], - 'burger_vector': point_result['burger_vector'], - 'total_cell_shift': point_result['total_cell_shift'], - 'interface_slips': point_result['interface_slips'], - 'energy': point_result['energy'], - 'sfe': point_result['sfe'], - 'workchain_uuid': point_result['workchain_uuid'], + direction_results = nested_results.setdefault( + point_result["direction_name"], {} + ) + direction_results[str(point_result["step_index"])] = { + "label": point_result["label"], + "structure_uuid": point_result["structure_uuid"], + "step_index": point_result["step_index"], + "burger_vector": point_result["burger_vector"], + "total_cell_shift": point_result["total_cell_shift"], + "interface_slips": point_result["interface_slips"], + "energy": point_result["energy"], + "sfe": point_result["sfe"], + "workchain_uuid": point_result["workchain_uuid"], } results = { - 'results': nested_results, - 'surface_area_angstrom2': float(self.ctx.surface_area), - 'number_of_structures': self.ctx.number_of_structures, + "results": nested_results, + "surface_area_angstrom2": float(self.ctx.surface_area), + "number_of_structures": self.ctx.number_of_structures, } - if 'total_energy_conventional_geometry' in self.ctx: - results['conventional_energy_ev'] = float(self.ctx.total_energy_conventional_geometry) + if "total_energy_conventional_geometry" in self.ctx: + results["conventional_energy_ev"] = float( + self.ctx.total_energy_conventional_geometry + ) - self.out('results', orm.Dict(dict=results).store()) + self.out("results", orm.Dict(dict=results).store()) def on_terminated(self) -> None: """Clean child calculation working directories if ``clean_workdir`` is enabled.""" super().on_terminated() if self.inputs.clean_workdir.value is False: - self.report('remote folders will not be cleaned') + self.report("remote folders will not be cleaned") return cleaned_calcs = clean_workchain_calcs(self.node) if cleaned_calcs: - self.report(f'cleaned remote folders of calculations: {" ".join(map(str, cleaned_calcs))}') + self.report( + f"cleaned remote folders of calculations: {' '.join(map(str, cleaned_calcs))}" + ) diff --git a/src/aiida_mechanical/workflows/dislocation/isfe.py b/src/aiida_mechanical/workflows/dislocation/isfe.py index f0f5320..e986075 100644 --- a/src/aiida_mechanical/workflows/dislocation/isfe.py +++ b/src/aiida_mechanical/workflows/dislocation/isfe.py @@ -1,6 +1,5 @@ from .sfebase import SFEBaseWorkChain -from .layer_relax import RigidLayerRelaxWorkChain -from aiida import orm + class ISFEWorkChain(SFEBaseWorkChain): """ISFE WorkChain""" @@ -10,16 +9,16 @@ class ISFEWorkChain(SFEBaseWorkChain): @classmethod def define(cls, spec): super().define(spec) - + spec.exit_code( 404, "ERROR_SUB_PROCESS_FAILED_ISF", - message='The `PwBaseWorkChain` for the ISF run failed.', + message="The `PwBaseWorkChain` for the ISF run failed.", ) def _get_fault_type(self): """Return the fault type for ISFE workchain.""" - return 'intrinsic' + return "intrinsic" def results(self): """Expose collected ISFE data to the caller.""" diff --git a/src/aiida_mechanical/workflows/dislocation/layer_relax.py b/src/aiida_mechanical/workflows/dislocation/layer_relax.py index 43b92dc..eed78a4 100644 --- a/src/aiida_mechanical/workflows/dislocation/layer_relax.py +++ b/src/aiida_mechanical/workflows/dislocation/layer_relax.py @@ -9,58 +9,94 @@ import numpy from aiida_quantumespresso.workflows.protocols.utils import ProtocolMixin + class RigidLayerRelaxWorkChain(ProtocolMixin, WorkChain): """WorkChain for looping over additional_spacings and performing SFE calculations. - + This workchain handles: - Looping over additional_spacings list - For each spacing: generating faulted structure, setting up kpoints, running calculation - Collecting results for all spacings - + It is designed to be called as a sub-workchain from RigidLayerWorkChain or other workflows that need to perform SFE calculations for multiple spacings. """ - - _NAMESPACE = 'layer_relax' - _RELAX_NAMESPACE = 'relax' - + + _NAMESPACE = "layer_relax" + _RELAX_NAMESPACE = "relax" + @classmethod def define(cls, spec): super().define(spec) - - spec.input('structure', valid_type=orm.StructureData, required=True, - help='The conventional structure for generating faulted structures.') - spec.input('layer_spacings', valid_type=orm.List, required=True, - help='List of layer spacings to evaluate.') - spec.input('fault_type', valid_type=orm.Str, required=True, - help="Fault type: 'intrinsic', 'unstable', or 'extrinsic'.") - spec.input('fault_method', valid_type=orm.Str, required=False, - default=lambda: orm.Str('removal'), - help="Fault method: 'removal' or 'vacuum'.") - spec.input('vacuum_ratio', valid_type=orm.Float, required=False, - default=lambda: orm.Float(0.1), - help='Vacuum ratio when using vacuum method.') - spec.input('gliding_plane', valid_type=orm.Str, required=False, - help='Gliding plane direction.') - spec.input('n_repeats', valid_type=orm.Int, required=True, - help='Number of unit cells to repeat.') - spec.input('kpoints', valid_type=orm.KpointsData, required=True, - help='The kpoints mesh for the relaxation calculation.') - spec.input('clean_workdir', valid_type=orm.Bool, default=lambda: orm.Bool(False), - help='If `True`, work directories of all called calculation will be cleaned at the end of execution.') + + spec.input( + "structure", + valid_type=orm.StructureData, + required=True, + help="The conventional structure for generating faulted structures.", + ) + spec.input( + "layer_spacings", + valid_type=orm.List, + required=True, + help="List of layer spacings to evaluate.", + ) + spec.input( + "fault_type", + valid_type=orm.Str, + required=True, + help="Fault type: 'intrinsic', 'unstable', or 'extrinsic'.", + ) + spec.input( + "fault_method", + valid_type=orm.Str, + required=False, + default=lambda: orm.Str("removal"), + help="Fault method: 'removal' or 'vacuum'.", + ) + spec.input( + "vacuum_ratio", + valid_type=orm.Float, + required=False, + default=lambda: orm.Float(0.1), + help="Vacuum ratio when using vacuum method.", + ) + spec.input( + "gliding_plane", + valid_type=orm.Str, + required=False, + help="Gliding plane direction.", + ) + spec.input( + "n_repeats", + valid_type=orm.Int, + required=True, + help="Number of unit cells to repeat.", + ) + spec.input( + "kpoints", + valid_type=orm.KpointsData, + required=True, + help="The kpoints mesh for the relaxation calculation.", + ) + spec.input( + "clean_workdir", + valid_type=orm.Bool, + default=lambda: orm.Bool(False), + help="If `True`, work directories of all called calculation will be cleaned at the end of execution.", + ) spec.expose_inputs( PwRelaxWorkChain, namespace=cls._RELAX_NAMESPACE, - exclude=('structure', 'clean_workdir', 'kpoints', 'kpoints_distance'), + exclude=("structure", "clean_workdir", "kpoints", "kpoints_distance"), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwRelaxWorkChain`.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwRelaxWorkChain`.", + }, ) - - + spec.outline( cls.setup, while_(cls.should_run_relax)( @@ -70,26 +106,26 @@ def define(cls, spec): ), cls.results, ) - + spec.expose_outputs( PwRelaxWorkChain, namespace=cls._RELAX_NAMESPACE, - namespace_options={'required': False} + namespace_options={"required": False}, ) - + spec.exit_code( 400, - 'ERROR_SUB_PROCESS_FAILED', - message='The sub-process failed.', + "ERROR_SUB_PROCESS_FAILED", + message="The sub-process failed.", ) - @classmethod def get_protocol_filepath(cls): """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" from importlib_resources import files from . import protocols - return files(protocols) / f'{cls._NAMESPACE}.yaml' + + return files(protocols) / f"{cls._NAMESPACE}.yaml" @classmethod def get_protocol_overrides(cls) -> dict: @@ -104,15 +140,9 @@ def get_protocol_overrides(cls) -> dict: @classmethod def get_builder_from_protocol( - cls, - code, - structure, - protocol='moderate', - overrides=None, - **kwargs - ): - """Return a builder prepopulated with inputs selected according to the chosen protocol. - """ + cls, code, structure, protocol="moderate", overrides=None, **kwargs + ): + """Return a builder prepopulated with inputs selected according to the chosen protocol.""" inputs = cls.get_protocol_inputs(protocol, overrides) args = (code, structure, protocol) @@ -125,25 +155,25 @@ def get_builder_from_protocol( overrides=inputs.get(cls._RELAX_NAMESPACE, {}), ) # sub_builder.pop('structure', None) - sub_builder.pop('clean_workdir', None) - sub_builder.pop('kpoints', None) - sub_builder.pop('kpoints_distance', None) + sub_builder.pop("clean_workdir", None) + sub_builder.pop("kpoints", None) + sub_builder.pop("kpoints_distance", None) - sub_builder['base_relax'].pop('kpoints', None) - sub_builder['base_relax'].pop('kpoints_distance', None) - sub_builder.pop('base_init_relax', None) + sub_builder["base_relax"].pop("kpoints", None) + sub_builder["base_relax"].pop("kpoints_distance", None) + sub_builder.pop("base_init_relax", None) builder[cls._RELAX_NAMESPACE]._data = sub_builder._data builder.structure = structure - builder.clean_workdir = orm.Bool(inputs['clean_workdir']) + builder.clean_workdir = orm.Bool(inputs["clean_workdir"]) return builder def setup(self): """Initialize context for spacing loop.""" self.ctx.iteration = 1 self.ctx.layer_spacings = self.inputs.layer_spacings.get_list().copy() - + def should_run_relax(self): """Check if there are more spacings to process.""" if self.ctx.layer_spacings == []: @@ -152,22 +182,28 @@ def should_run_relax(self): # Get current spacing current_spacing = self.ctx.layer_spacings.pop(0) self.ctx.current_spacing = current_spacing - + # Generate faulted structure for this spacing fault_type = self.inputs.fault_type.value - fault_method = self.inputs.fault_method.value.lower() if self.inputs.fault_method.value else 'removal' - gliding_plane = self.inputs.gliding_plane.value if self.inputs.gliding_plane.value else None - - if fault_method == 'removal': + fault_method = ( + self.inputs.fault_method.value.lower() + if self.inputs.fault_method.value + else "removal" + ) + gliding_plane = ( + self.inputs.gliding_plane.value if self.inputs.gliding_plane.value else None + ) + + if fault_method == "removal": _, faulted_structure_data = get_faulted_structure( self.inputs.structure.get_ase(), fault_type=fault_type, additional_spacing=current_spacing, gliding_plane=gliding_plane, n_unit_cells=self.inputs.n_repeats.value, - fault_mode='removal', + fault_mode="removal", ) - elif fault_method == 'vacuum': + elif fault_method == "vacuum": vacuum_ratio = float(self.inputs.vacuum_ratio.value) _, faulted_structure_data = get_faulted_structure( self.inputs.structure.get_ase(), @@ -175,109 +211,126 @@ def should_run_relax(self): additional_spacing=current_spacing, gliding_plane=gliding_plane, n_unit_cells=self.inputs.n_repeats.value, - fault_mode='vacuum', + fault_mode="vacuum", vacuum_ratio=vacuum_ratio, ) else: raise ValueError(f"Unsupported fault method: {fault_method}") - + # Validate structure - if faulted_structure_data is None or not faulted_structure_data.get('structures'): - self.report(f'Faulted structure not available for spacing {current_spacing}. Skipping.') + if faulted_structure_data is None or not faulted_structure_data.get( + "structures" + ): + self.report( + f"Faulted structure not available for spacing {current_spacing}. Skipping." + ) return False - + # Extract structure - actual_structure = faulted_structure_data['structures'][0].get('structure') + actual_structure = faulted_structure_data["structures"][0].get("structure") if actual_structure is None: - self.report(f'Faulted structure is missing for spacing {current_spacing}. Skipping.') + self.report( + f"Faulted structure is missing for spacing {current_spacing}. Skipping." + ) return False - + # Store structure and calculate multiplier self.ctx.current_structure_ase = actual_structure self.ctx.current_structure = orm.StructureData(ase=actual_structure) - + # Calculate multiplier from ase.formula import Formula + formula = Formula(actual_structure.get_chemical_formula()) _, multiplier = formula.reduce() self.ctx.current_multiplier = multiplier - + return True - + def setup_supercell_kpoints(self): """Setup kpoints for the current rigid layer structure.""" # Calculate kpoints based on z-ratio between faulted and conventional structures - - z_ratio = self.ctx.current_structure.get_ase().cell.cellpar()[2] / self.inputs.structure.get_ase().cell.cellpar()[2] + + z_ratio = ( + self.ctx.current_structure.get_ase().cell.cellpar()[2] + / self.inputs.structure.get_ase().cell.cellpar()[2] + ) kpoints_mesh = self.inputs.kpoints.get_kpoints_mesh()[0] - + kpoints_relax = orm.KpointsData() - kpoints_relax.set_kpoints_mesh(kpoints_mesh[:2] + [ceil(kpoints_mesh[2] / z_ratio)]) - + kpoints_relax.set_kpoints_mesh( + kpoints_mesh[:2] + [ceil(kpoints_mesh[2] / z_ratio)] + ) + self.ctx.kpoints_relax = kpoints_relax - self.report(f'Kpoints mesh for rigid layer relaxation (spacing {self.ctx.current_spacing}): {kpoints_relax.get_kpoints_mesh()[0]}') - + self.report( + f"Kpoints mesh for rigid layer relaxation (spacing {self.ctx.current_spacing}): {kpoints_relax.get_kpoints_mesh()[0]}" + ) + def run_relax(self): """Run the rigid layer relaxation calculation for current spacing.""" inputs = AttributeDict( - self.exposed_inputs( - PwRelaxWorkChain, - namespace=self._RELAX_NAMESPACE - ) + self.exposed_inputs(PwRelaxWorkChain, namespace=self._RELAX_NAMESPACE) ) - + inputs.structure = self.ctx.current_structure inputs.base_relax.kpoints = self.ctx.kpoints_relax - inputs.metadata.call_link_label = f'relax_{self.ctx.iteration}' - + inputs.metadata.call_link_label = f"relax_{self.ctx.iteration}" + # Apply fault_method specific settings - fault_method = self.inputs.fault_method.value.lower() if self.inputs.fault_method.value else 'removal' + fault_method = ( + self.inputs.fault_method.value.lower() + if self.inputs.fault_method.value + else "removal" + ) parameters = inputs.base_relax.pw.parameters.get_dict() - - if fault_method == 'vacuum': - parameters['CELL']['cell_dofree'] = 'fixc' - - if hasattr(self.ctx, 'nbnd') and self.ctx.nbnd: - parameters['SYSTEM']['nbnd'] = int(self.ctx.nbnd) - + + if fault_method == "vacuum": + parameters["CELL"]["cell_dofree"] = "fixc" + + if hasattr(self.ctx, "nbnd") and self.ctx.nbnd: + parameters["SYSTEM"]["nbnd"] = int(self.ctx.nbnd) + inputs.base_relax.pw.parameters = orm.Dict(parameters) - + # Apply fixed coordinates for relaxation settings = inputs.base_relax.pw.settings.get_dict() - settings['USE_FRACTIONAL'] = True - + settings["USE_FRACTIONAL"] = True + FIXED_COORDS = numpy.full_like( self.ctx.current_structure.get_ase().get_positions(), fill_value=True, - dtype=bool + dtype=bool, ) - settings['FIXED_COORDS'] = FIXED_COORDS.tolist() + settings["FIXED_COORDS"] = FIXED_COORDS.tolist() inputs.base_relax.pw.settings = orm.Dict(settings) - + running = self.submit(PwRelaxWorkChain, **inputs) - self.report(f'launching PwRelaxWorkChain<{running.pk}> for spacing: {self.ctx.current_spacing}.') - - return {f"workchain_relax": append_(running)} - + self.report( + f"launching PwRelaxWorkChain<{running.pk}> for spacing: {self.ctx.current_spacing}." + ) + + return {"workchain_relax": append_(running)} + def inspect_relax(self): """Inspect the rigid layer relaxation calculation results for current spacing.""" workchain = self.ctx.workchain_relax[-1] self.ctx.iteration += 1 - + if not workchain.is_finished_ok: self.report( f"PwRelaxWorkChain<{workchain.pk}> for spacing {self.ctx.current_spacing} " f"failed with exit status {workchain.exit_status}" ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED - - self.report(f'PwRelaxWorkChain<{workchain.pk}> for spacing {self.ctx.current_spacing} finished successfully.') - - # Extract number of bands for next iteration - if 'output_parameters' in workchain.outputs: - self.ctx.nbnd = workchain.outputs.output_parameters.get('number_of_bands') - + self.report( + f"PwRelaxWorkChain<{workchain.pk}> for spacing {self.ctx.current_spacing} finished successfully." + ) + + # Extract number of bands for next iteration + if "output_parameters" in workchain.outputs: + self.ctx.nbnd = workchain.outputs.output_parameters.get("number_of_bands") def results(self): """Output collected results.""" diff --git a/src/aiida_mechanical/workflows/dislocation/mixins.py b/src/aiida_mechanical/workflows/dislocation/mixins.py index 56bdadf..dea8428 100644 --- a/src/aiida_mechanical/workflows/dislocation/mixins.py +++ b/src/aiida_mechanical/workflows/dislocation/mixins.py @@ -17,11 +17,17 @@ class StructureGenerationMixin: @staticmethod def _ensure_ase_structure(structure: orm.StructureData | Atoms) -> Atoms: """Return an ASE structure from either ASE Atoms or `StructureData`.""" - return structure.get_ase() if isinstance(structure, orm.StructureData) else structure - - def _calculate_structure_multiplier(self, structure: orm.StructureData | Atoms) -> int: + return ( + structure.get_ase() + if isinstance(structure, orm.StructureData) + else structure + ) + + def _calculate_structure_multiplier( + self, structure: orm.StructureData | Atoms + ) -> int: """Calculate the multiplier for a given structure. - + :param structure: ASE Atoms object :return: multiplier value """ @@ -29,44 +35,50 @@ def _calculate_structure_multiplier(self, structure: orm.StructureData | Atoms) formula = Formula(ase_structure.get_chemical_formula()) _, multiplier = formula.reduce() return multiplier - - def _store_structure_multiplier(self, structure: orm.StructureData | Atoms, multiplier_name: str) -> int: + + def _store_structure_multiplier( + self, structure: orm.StructureData | Atoms, multiplier_name: str + ) -> int: """Store structure and its multiplier in context. - + :param structure: ASE Atoms object :param multiplier_name: Name for the multiplier in context (e.g., 'intrinsic_multiplier') """ multiplier = self._calculate_structure_multiplier(structure) setattr(self.ctx, multiplier_name, multiplier) return multiplier - + def _validate_faulted_structure( self, faulted_structure_data: ty.Optional[dict[str, ty.Any]], fault_type: str, ) -> tuple[bool, ty.Optional[ExitCode]]: """Validate that a faulted structure was generated. - + :param faulted_structure_data: Result from get_faulted_structure :param fault_type: Type of fault (for error messages) :return: tuple (is_valid, error_code_or_none) """ if faulted_structure_data is None: - self.report(f'{fault_type.capitalize()} fault structure is not available for this gliding system.') + self.report( + f"{fault_type.capitalize()} fault structure is not available for this gliding system." + ) return False, self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED - - structures = faulted_structure_data.get('structures', []) + + structures = faulted_structure_data.get("structures", []) if not structures: - self.report(f'{fault_type.capitalize()} fault structure list is empty.') + self.report(f"{fault_type.capitalize()} fault structure list is empty.") return False, self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED - + first_entry = structures[0] - actual_structure = first_entry.get('structure') - + actual_structure = first_entry.get("structure") + if actual_structure is None: - self.report(f'{fault_type.capitalize()} fault structure is missing structure data.') + self.report( + f"{fault_type.capitalize()} fault structure is missing structure data." + ) return False, self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED - + return True, None @@ -76,43 +88,48 @@ class EnergyCalculationMixin: def _get_physical_constant(self, name: str) -> float: """Return a required physical constant from the workchain.""" if not hasattr(self, name): - raise AttributeError(f'`{type(self).__name__}` must define `{name}` to use energy mixin helpers.') + raise AttributeError( + f"`{type(self).__name__}` must define `{name}` to use energy mixin helpers." + ) return getattr(self, name) - + def _calculate_stacking_fault_energy( - self, - total_energy_faulted: float, - fault_multiplier: int, - fault_type_name: str + self, total_energy_faulted: float, fault_multiplier: int, fault_type_name: str ) -> ty.Optional[float]: """Calculate stacking fault energy from faulted and conventional geometries. - + :param total_energy_faulted: Total energy of faulted geometry :param fault_multiplier: Multiplier for faulted structure :param fault_type_name: Name of fault type (for reporting) :return: Stacking fault energy in J/m^2 or None if conventional energy not available """ - if 'total_energy_conventional_geometry' not in self.ctx: + if "total_energy_conventional_geometry" not in self.ctx: return None - + energy_difference = ( total_energy_faulted - - self.ctx.total_energy_conventional_geometry - / self.ctx.conventional_multiplier + - self.ctx.total_energy_conventional_geometry + / self.ctx.conventional_multiplier * fault_multiplier ) - stacking_fault_energy = energy_difference / self.ctx.surface_area * self._get_physical_constant('_eVA22Jm2') - + stacking_fault_energy = ( + energy_difference + / self.ctx.surface_area + * self._get_physical_constant("_eVA22Jm2") + ) + self.report( - f'{fault_type_name} stacking fault energy evaluated from conventional geometry: ' - f'{stacking_fault_energy} J/m^2' + f"{fault_type_name} stacking fault energy evaluated from conventional geometry: " + f"{stacking_fault_energy} J/m^2" ) - + return stacking_fault_energy - def _calculate_surface_energy(self, total_energy_slab: float, surface_multiplier: int) -> ty.Optional[float]: + def _calculate_surface_energy( + self, total_energy_slab: float, surface_multiplier: int + ) -> ty.Optional[float]: """Calculate a two-surface slab energy in J/m^2.""" - if 'total_energy_conventional_geometry' not in self.ctx: + if "total_energy_conventional_geometry" not in self.ctx: return None energy_difference = ( @@ -121,8 +138,12 @@ def _calculate_surface_energy(self, total_energy_slab: float, surface_multiplier / self.ctx.conventional_multiplier * surface_multiplier ) - return energy_difference / (2 * self.ctx.surface_area) * self._get_physical_constant('_eVA22Jm2') - + return ( + energy_difference + / (2 * self.ctx.surface_area) + * self._get_physical_constant("_eVA22Jm2") + ) + def _report_energy( self, energy: float, @@ -131,55 +152,60 @@ def _report_energy( unit_cells_description: str, ) -> None: """Report energy in a consistent format. - + :param energy: Energy value :param multiplier: Multiplier value :param structure_type: Type of structure (for reporting) :param unit_cells_description: Description of unit cells """ self.report( - f'Total energy of {structure_type} [{multiplier} {unit_cells_description}]: ' - f'{energy / self._get_physical_constant("_RY2eV")} Ry' + f"Total energy of {structure_type} [{multiplier} {unit_cells_description}]: " + f"{energy / self._get_physical_constant('_RY2eV')} Ry" ) class KpointsSetupMixin: """Mixin for kpoints setup related methods.""" - + def _calculate_kpoints_for_structure( self, structure: orm.StructureData | Atoms, kpoints_scf: orm.KpointsData, ) -> orm.KpointsData: """Calculate kpoints mesh for a given structure based on z-ratio. - + :param structure: ASE Atoms object :param kpoints_scf_mesh: Base kpoints mesh from SCF calculation :return: KpointsData object """ kpoints_scf_mesh = kpoints_scf.get_kpoints_mesh()[0] structure_ase = StructureGenerationMixin._ensure_ase_structure(structure) - conventional_ase = StructureGenerationMixin._ensure_ase_structure(self.ctx.conventional_structure) + conventional_ase = StructureGenerationMixin._ensure_ase_structure( + self.ctx.conventional_structure + ) z_ratio = structure_ase.cell.cellpar()[2] / conventional_ase.cell.cellpar()[2] kpoints = orm.KpointsData() - kpoints.set_kpoints_mesh(kpoints_scf_mesh[:2] + [ceil(kpoints_scf_mesh[2] / z_ratio)]) + kpoints.set_kpoints_mesh( + kpoints_scf_mesh[:2] + [ceil(kpoints_scf_mesh[2] / z_ratio)] + ) return kpoints - - def _setup_surface_energy_kpoints(self, kpoints_scf: orm.KpointsData) -> orm.KpointsData: + + def _setup_surface_energy_kpoints( + self, kpoints_scf: orm.KpointsData + ) -> orm.KpointsData: """Setup kpoints for surface energy calculation. - + :param kpoints_scf_mesh: Base kpoints mesh from SCF calculation :return: KpointsData object for surface energy """ return self._calculate_kpoints_for_structure( - self.ctx.cleavaged_structure, - kpoints_scf + self.ctx.cleavaged_structure, kpoints_scf ) class WorkflowInspectionMixin: """Mixin for workflow inspection and error handling.""" - + def _inspect_workchain( self, workchain: orm.ProcessNode, @@ -187,10 +213,10 @@ def _inspect_workchain( structure_type: str, exit_code_on_failure: ExitCode, namespace: ty.Optional[str] = None, - workchain_class: ty.Optional[type] = None + workchain_class: ty.Optional[type] = None, ) -> ty.Optional[ExitCode]: """Generic method to inspect a workchain and handle outputs. - + :param workchain: The workchain node to inspect :param workchain_type_name: Name of workchain type (for reporting) :param structure_type: Type of structure (for reporting) @@ -205,25 +231,25 @@ def _inspect_workchain( f"failed with exit status {workchain.exit_status}" ) return exit_code_on_failure - + self.report( - f'{workchain_type_name}<{workchain.pk}> for {structure_type} finished OK' + f"{workchain_type_name}<{workchain.pk}> for {structure_type} finished OK" ) - + if namespace and workchain_class: self.out_many( self.exposed_outputs(workchain, workchain_class, namespace=namespace) ) - + return None - + def _get_workchain_energy(self, workchain: orm.ProcessNode) -> float: """Extract energy from workchain outputs. - + :param workchain: Workchain node :return: Energy value """ - return float(workchain.outputs.output_parameters.get('energy')) + return float(workchain.outputs.output_parameters.get("energy")) def clean_calcjob_remote(node: orm.CalcJobNode) -> bool: @@ -244,7 +270,9 @@ def clean_workchain_calcs(workchain: orm.WorkChainNode) -> list[int]: cleaned_calcs: list[int] = [] for called_descendant in workchain.called_descendants: - if isinstance(called_descendant, orm.CalcJobNode) and clean_calcjob_remote(called_descendant): + if isinstance(called_descendant, orm.CalcJobNode) and clean_calcjob_remote( + called_descendant + ): cleaned_calcs.append(called_descendant.pk) return cleaned_calcs diff --git a/src/aiida_mechanical/workflows/dislocation/sfebase.py b/src/aiida_mechanical/workflows/dislocation/sfebase.py index 51075b5..59608d0 100644 --- a/src/aiida_mechanical/workflows/dislocation/sfebase.py +++ b/src/aiida_mechanical/workflows/dislocation/sfebase.py @@ -6,9 +6,11 @@ from aiida_quantumespresso.workflows.pw.base import PwBaseWorkChain from aiida_quantumespresso.workflows.pw.relax import PwRelaxWorkChain -from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import create_kpoints_from_distance +from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import ( + create_kpoints_from_distance, +) from aiida_mechanical.tools import ( - calculate_surface_area, + calculate_surface_area, get_conventional_structure, get_cleavaged_structure, ) @@ -29,106 +31,134 @@ class SFEBaseWorkChain( EnergyCalculationMixin, KpointsSetupMixin, WorkflowInspectionMixin, - WorkChain + WorkChain, ): """SFEBase WorkChain""" - _NAMESPACE = 'sfebase' + _NAMESPACE = "sfebase" _RELAX_NAMESPACE = "relax" _SCF_NAMESPACE = "scf" _RIGID_LAYER_RELAX_NAMESPACE = "layer_relax" _SURFACE_ENERGY_NAMESPACE = "surface_energy" - _RY2eV = 13.605693122990 - _RYA22Jm2 = 4.3597447222071E-18/2 * 1E+20 - _eVA22Jm2 = 1.602176634E-19 * 1E+20 + _RY2eV = 13.605693122990 + _RYA22Jm2 = 4.3597447222071e-18 / 2 * 1e20 + _eVA22Jm2 = 1.602176634e-19 * 1e20 @classmethod def define(cls, spec): super().define(spec) - spec.input('n_repeats', valid_type=orm.Int, required=False, default=lambda: orm.Int(4), - help='The number of layers in the supercell') - spec.input('gliding_plane', valid_type=orm.Str, required=False, default=lambda: orm.Str(), - help='The normal vector for the supercell. Note that please always put the z axis at the last.') - spec.input('structure', valid_type=orm.StructureData, required=True,) - spec.input('kpoints_distance', valid_type=orm.Float, required=False, default=lambda: orm.Float(0.3), - help='The distance between kpoints for the kpoints generation') - spec.input('clean_workdir', valid_type=orm.Bool, default=lambda: orm.Bool(False), - help='If `True`, work directories of all called calculation will be cleaned at the end of execution.') - - spec.input('layer_spacings', valid_type=orm.List, required=False, default=lambda: orm.List(list=[0.0]), - help='The layer spacings to add to the structure.') - spec.input('fault_method', valid_type=orm.Str, required=False, default=lambda: orm.Str('removal'), - help="How to generate faulted structures: 'removal', or 'vacuum'.") - spec.input('vacuum_ratio', valid_type=orm.Float, required=False, default=lambda: orm.Float(0.1), - help='Vacuum ratio added along the fault normal when using vacuum gliding.') + spec.input( + "n_repeats", + valid_type=orm.Int, + required=False, + default=lambda: orm.Int(4), + help="The number of layers in the supercell", + ) + spec.input( + "gliding_plane", + valid_type=orm.Str, + required=False, + default=lambda: orm.Str(), + help="The normal vector for the supercell. Note that please always put the z axis at the last.", + ) + spec.input( + "structure", + valid_type=orm.StructureData, + required=True, + ) + spec.input( + "kpoints_distance", + valid_type=orm.Float, + required=False, + default=lambda: orm.Float(0.3), + help="The distance between kpoints for the kpoints generation", + ) + spec.input( + "clean_workdir", + valid_type=orm.Bool, + default=lambda: orm.Bool(False), + help="If `True`, work directories of all called calculation will be cleaned at the end of execution.", + ) + + spec.input( + "layer_spacings", + valid_type=orm.List, + required=False, + default=lambda: orm.List(list=[0.0]), + help="The layer spacings to add to the structure.", + ) + spec.input( + "fault_method", + valid_type=orm.Str, + required=False, + default=lambda: orm.Str("removal"), + help="How to generate faulted structures: 'removal', or 'vacuum'.", + ) + spec.input( + "vacuum_ratio", + valid_type=orm.Float, + required=False, + default=lambda: orm.Float(0.1), + help="Vacuum ratio added along the fault normal when using vacuum gliding.", + ) spec.expose_inputs( PwRelaxWorkChain, namespace=cls._RELAX_NAMESPACE, exclude=( - 'structure', - 'clean_workdir', + "structure", + "clean_workdir", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwRelaxWorkChain`.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwRelaxWorkChain`.", + }, ) spec.expose_inputs( PwBaseWorkChain, namespace=cls._SCF_NAMESPACE, - exclude=( - 'pw.structure', - 'clean_workdir', - 'kpoints', - 'kpoints_distance' - ), + exclude=("pw.structure", "clean_workdir", "kpoints", "kpoints_distance"), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwBaseWorkChain`.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwBaseWorkChain`.", + }, ) spec.expose_inputs( RigidLayerRelaxWorkChain, namespace=cls._RIGID_LAYER_RELAX_NAMESPACE, exclude=( - 'structure', - 'clean_workdir', - 'kpoints', - 'fault_type', - 'fault_method', - 'vacuum_ratio', - 'gliding_plane', - 'n_repeats', - 'layer_spacings' + "structure", + "clean_workdir", + "kpoints", + "fault_type", + "fault_method", + "vacuum_ratio", + "gliding_plane", + "n_repeats", + "layer_spacings", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwBaseWorkChain`.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwBaseWorkChain`.", + }, ) spec.expose_inputs( PwBaseWorkChain, namespace=cls._SURFACE_ENERGY_NAMESPACE, - exclude=( - 'pw.structure', - 'clean_workdir', - 'kpoints', - 'kpoints_distance' - ), + exclude=("pw.structure", "clean_workdir", "kpoints", "kpoints_distance"), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwBaseWorkChain`.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwBaseWorkChain`.", + }, ) spec.outline( @@ -155,50 +185,50 @@ def define(cls, spec): PwRelaxWorkChain, namespace=cls._RELAX_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) spec.expose_outputs( PwBaseWorkChain, namespace=cls._SCF_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) - + spec.expose_outputs( PwBaseWorkChain, namespace=cls._SURFACE_ENERGY_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) spec.exit_code( 401, "ERROR_SUB_PROCESS_FAILED_RELAX", - message='The `PwBaseWorkChain` for the relax run failed.', + message="The `PwBaseWorkChain` for the relax run failed.", ) spec.exit_code( 402, "ERROR_SUB_PROCESS_FAILED_SCF", - message='The `PwBaseWorkChain` for the scf run failed.', + message="The `PwBaseWorkChain` for the scf run failed.", ) spec.exit_code( 403, "ERROR_NO_STRUCTURE_TYPE_DETECTED", - message='The structure type can not be detected.', + message="The structure type can not be detected.", ) spec.exit_code( 405, "ERROR_SUB_PROCESS_FAILED_RIGID_LAYER_RELAX", - message='The `RigidLayerRelaxWorkChain` for the rigid layer relaxation failed.', + message="The `RigidLayerRelaxWorkChain` for the rigid layer relaxation failed.", ) spec.exit_code( 406, "ERROR_SUB_PROCESS_FAILED_SURFACE_ENERGY", - message='The `PwBaseWorkChain` for the surface energy calculation failed.', + message="The `PwBaseWorkChain` for the surface energy calculation failed.", ) @classmethod @@ -206,7 +236,8 @@ def get_protocol_filepath(cls): """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" from importlib_resources import files from . import protocols - return files(protocols) / f'{cls._NAMESPACE}.yaml' + + return files(protocols) / f"{cls._NAMESPACE}.yaml" @classmethod def get_protocol_overrides(cls) -> dict: @@ -221,15 +252,9 @@ def get_protocol_overrides(cls) -> dict: @classmethod def get_builder_from_protocol( - cls, - code, - structure, - protocol='moderate', - overrides=None, - **kwargs - ): - """Return a builder prepopulated with inputs selected according to the chosen protocol. - """ + cls, code, structure, protocol="moderate", overrides=None, **kwargs + ): + """Return a builder prepopulated with inputs selected according to the chosen protocol.""" inputs = cls.get_protocol_inputs(protocol, overrides) args = (code, structure, protocol) @@ -243,47 +268,51 @@ def get_builder_from_protocol( (cls._SURFACE_ENERGY_NAMESPACE, PwBaseWorkChain), ]: overrides = inputs.get(namespace, {}) - + if workchain_type == RigidLayerRelaxWorkChain: - overrides.setdefault('relax', {}).setdefault('base_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) - overrides.setdefault('relax', {}).setdefault('base_init_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + overrides.setdefault("relax", {}).setdefault("base_relax", {})[ + "pseudo_family" + ] = inputs.get("pseudo_family", None) + overrides.setdefault("relax", {}).setdefault("base_init_relax", {})[ + "pseudo_family" + ] = inputs.get("pseudo_family", None) elif workchain_type == PwRelaxWorkChain: - overrides.setdefault('base_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) - overrides.setdefault('base_init_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + overrides.setdefault("base_relax", {})["pseudo_family"] = inputs.get( + "pseudo_family", None + ) + overrides.setdefault("base_init_relax", {})["pseudo_family"] = ( + inputs.get("pseudo_family", None) + ) else: - overrides['pseudo_family'] = inputs.get('pseudo_family', None) + overrides["pseudo_family"] = inputs.get("pseudo_family", None) sub_builder = workchain_type.get_builder_from_protocol( *args, overrides=overrides, ) - sub_builder.pop('clean_workdir', None) + sub_builder.pop("clean_workdir", None) builder[namespace]._data = sub_builder._data - - builder[cls._RELAX_NAMESPACE].pop('base_init_relax', None) - builder.layer_spacings = orm.List(list=inputs.get('layer_spacings', [0.0])) + builder[cls._RELAX_NAMESPACE].pop("base_init_relax", None) + + builder.layer_spacings = orm.List(list=inputs.get("layer_spacings", [0.0])) builder.structure = structure - builder.fault_method = orm.Str(inputs.get('fault_method', 'removal')) - builder.vacuum_ratio = orm.Float(inputs.get('vacuum_ratio', 0.1)) - builder.n_repeats = orm.Int(inputs.get('n_repeats', 4)) - builder.kpoints_distance = orm.Float(inputs['kpoints_distance']) - builder.gliding_plane = orm.Str(inputs.get('gliding_plane', '')) - builder.clean_workdir = orm.Bool(inputs['clean_workdir']) + builder.fault_method = orm.Str(inputs.get("fault_method", "removal")) + builder.vacuum_ratio = orm.Float(inputs.get("vacuum_ratio", 0.1)) + builder.n_repeats = orm.Int(inputs.get("n_repeats", 4)) + builder.kpoints_distance = orm.Float(inputs["kpoints_distance"]) + builder.gliding_plane = orm.Str(inputs.get("gliding_plane", "")) + builder.clean_workdir = orm.Bool(inputs["clean_workdir"]) return builder def should_run_relax(self): return self._RELAX_NAMESPACE in self.inputs - - def run_relax(self): + def run_relax(self): inputs = AttributeDict( - self.exposed_inputs( - PwRelaxWorkChain, - namespace=self._RELAX_NAMESPACE - ) - ) + self.exposed_inputs(PwRelaxWorkChain, namespace=self._RELAX_NAMESPACE) + ) inputs.metadata.call_link_label = self._RELAX_NAMESPACE @@ -291,7 +320,9 @@ def run_relax(self): inputs.base_relax.kpoints_distance = self.inputs.kpoints_distance running = self.submit(PwRelaxWorkChain, **inputs) - self.report(f'launching PwRelaxWorkChain<{running.pk}> for {self.inputs.structure.get_formula()} unit cell geometry.') + self.report( + f"launching PwRelaxWorkChain<{running.pk}> for {self.inputs.structure.get_formula()} unit cell geometry." + ) return ToContext(workchain_relax=running) @@ -304,7 +335,9 @@ def inspect_relax(self): ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_RELAX - self.report(f'PwRelaxWorkChain<{workchain.pk}> for {self.inputs.structure.get_formula()} unit cell geometry finished OK') + self.report( + f"PwRelaxWorkChain<{workchain.pk}> for {self.inputs.structure.get_formula()} unit cell geometry finished OK" + ) self.ctx.current_structure = workchain.outputs.output_structure self.out_many( @@ -314,31 +347,37 @@ def inspect_relax(self): namespace=self._RELAX_NAMESPACE, ), ) - self.ctx.total_energy_unit_cell = workchain.outputs.output_parameters.get('energy') - self.report(f"Total energy of unit cell after relaxation: {self.ctx.total_energy_unit_cell / self._RY2eV} Ry") + self.ctx.total_energy_unit_cell = workchain.outputs.output_parameters.get( + "energy" + ) + self.report( + f"Total energy of unit cell after relaxation: {self.ctx.total_energy_unit_cell / self._RY2eV} Ry" + ) def _get_fault_type(self): """Return the fault type for this workchain. Must be implemented by subclasses.""" raise NotImplementedError("Subclasses must implement _get_fault_type()") def generate_structures(self): - """Generate base structures (conventional and cleavaged). + """Generate base structures (conventional and cleavaged). Subclasses should override generate_faulted_structure() to generate faulted structures.""" - - if 'current_structure' not in self.ctx: + + if "current_structure" not in self.ctx: self.ctx.current_structure = self.inputs.structure - - gliding_plane = self.inputs.gliding_plane.value if self.inputs.gliding_plane.value else None - + + gliding_plane = ( + self.inputs.gliding_plane.value if self.inputs.gliding_plane.value else None + ) + # Get conventional structure strukturbericht, conventional_structure = get_conventional_structure( self.ctx.current_structure.get_ase(), gliding_plane=gliding_plane, ) if strukturbericht: - self.report(f'{strukturbericht} structure is detected.') + self.report(f"{strukturbericht} structure is detected.") else: - self.report(f'Strukturbericht can not be detected.') + self.report("Strukturbericht can not be detected.") return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED # Get cleavaged structure (based on conventional cell) @@ -353,12 +392,16 @@ def generate_structures(self): self.ctx.cleavaged_structure = cleavaged_structure self.ctx.surface_area = calculate_surface_area(conventional_structure.cell) - - self.report(f'Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2') - - unit_cell_formula = Formula(self.ctx.current_structure.get_ase().get_chemical_formula()) + + self.report( + f"Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2" + ) + + unit_cell_formula = Formula( + self.ctx.current_structure.get_ase().get_chemical_formula() + ) _, unit_cell_multiplier = unit_cell_formula.reduce() - + # Calculate and store multipliers using helper method self.ctx.unit_cell_multiplier = self._calculate_structure_multiplier( self.ctx.current_structure.get_ase() @@ -372,21 +415,19 @@ def generate_structures(self): def _get_kpoints_scf(self): """Get or create kpoints_scf. Returns kpoints_scf KpointsData object.""" - if 'kpoints_scf' in self.ctx: + if "kpoints_scf" in self.ctx: kpoints_scf = self.ctx.kpoints_scf else: inputs = { - 'structure': orm.StructureData( - ase=self.ctx.conventional_structure - ), - 'distance': self.inputs.kpoints_distance, - 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), - 'metadata': { - 'call_link_label': 'create_kpoints_from_distance' - } + "structure": orm.StructureData(ase=self.ctx.conventional_structure), + "distance": self.inputs.kpoints_distance, + "force_parity": self.inputs.get( + "kpoints_force_parity", orm.Bool(False) + ), + "metadata": {"call_link_label": "create_kpoints_from_distance"}, } kpoints_scf = create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg - + return kpoints_scf def setup(self): @@ -396,48 +437,46 @@ def setup(self): """ # Get kpoints_scf kpoints_scf = self._get_kpoints_scf() - + self.ctx.kpoints_scf = kpoints_scf # Calculate kpoints for surface energy using helper method - self.ctx.kpoints_surface_energy = self._setup_surface_energy_kpoints(kpoints_scf) + self.ctx.kpoints_surface_energy = self._setup_surface_energy_kpoints( + kpoints_scf + ) def should_run_scf(self): - return self._SCF_NAMESPACE in self.inputs - + def run_scf(self): inputs = AttributeDict( - self.exposed_inputs( - PwBaseWorkChain, - namespace=self._SCF_NAMESPACE - ) - ) + self.exposed_inputs(PwBaseWorkChain, namespace=self._SCF_NAMESPACE) + ) inputs.metadata.call_link_label = self._SCF_NAMESPACE - inputs.pw.structure = orm.StructureData( - ase=self.ctx.conventional_structure - ) + inputs.pw.structure = orm.StructureData(ase=self.ctx.conventional_structure) inputs.kpoints_distance = self.inputs.kpoints_distance running = self.submit(PwBaseWorkChain, **inputs) - self.report(f'launching PwBaseWorkChain<{running.pk}> for {self.inputs.structure.get_formula()} conventional geometry.') + self.report( + f"launching PwBaseWorkChain<{running.pk}> for {self.inputs.structure.get_formula()} conventional geometry." + ) return ToContext(workchain_scf=running) def inspect_scf(self): """Verify that the `PwBaseWorkChain` for the scf run successfully finished.""" workchain = self.ctx.workchain_scf - + if not workchain.is_finished_ok: self.report( f"PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SCF - self.report(f'PwBaseWorkChain<{workchain.pk}> finished successfully.') - + self.report(f"PwBaseWorkChain<{workchain.pk}> finished successfully.") + self.out_many( self.exposed_outputs( workchain, @@ -446,32 +485,30 @@ def inspect_scf(self): ), ) # Extract and report energy - self.ctx.total_energy_conventional_geometry = self._get_workchain_energy(workchain) - + self.ctx.total_energy_conventional_geometry = self._get_workchain_energy( + workchain + ) # Report energy difference if unit cell energy available - if 'total_energy_unit_cell' in self.ctx: + if "total_energy_unit_cell" in self.ctx: energy_difference = ( - self.ctx.total_energy_conventional_geometry - - self.ctx.total_energy_unit_cell - / self.ctx.unit_cell_multiplier + self.ctx.total_energy_conventional_geometry + - self.ctx.total_energy_unit_cell + / self.ctx.unit_cell_multiplier * self.ctx.conventional_multiplier ) self.report( - f'Energy difference between conventional and unit cell: ' - f'{energy_difference / self._RY2eV} Ry' + f"Energy difference between conventional and unit cell: " + f"{energy_difference / self._RY2eV} Ry" ) def run_layer_relax(self): inputs = AttributeDict( self.exposed_inputs( - RigidLayerRelaxWorkChain, - namespace=self._RIGID_LAYER_RELAX_NAMESPACE + RigidLayerRelaxWorkChain, namespace=self._RIGID_LAYER_RELAX_NAMESPACE ) ) - inputs.structure = orm.StructureData( - ase=self.ctx.conventional_structure - ) + inputs.structure = orm.StructureData(ase=self.ctx.conventional_structure) inputs.kpoints = self.ctx.kpoints_scf inputs.n_repeats = self.inputs.n_repeats inputs.gliding_plane = self.inputs.gliding_plane @@ -479,59 +516,58 @@ def run_layer_relax(self): inputs.fault_method = self.inputs.fault_method inputs.layer_spacings = self.inputs.layer_spacings inputs.vacuum_ratio = self.inputs.vacuum_ratio - + inputs.metadata.call_link_label = self._RIGID_LAYER_RELAX_NAMESPACE running = self.submit(RigidLayerRelaxWorkChain, **inputs) - self.report(f'launching RigidLayerRelaxWorkChain<{running.pk}> for rigid layer relaxation calculations over all spacings.') - - return {f"workchain_layer_relax": running} - + self.report( + f"launching RigidLayerRelaxWorkChain<{running.pk}> for rigid layer relaxation calculations over all spacings." + ) + + return {"workchain_layer_relax": running} + def inspect_layer_relax(self): """Inspect the RigidLayerRelaxWorkChain results.""" workchain = self.ctx.workchain_layer_relax - + if not workchain.is_finished_ok: self.report( f"RigidLayerRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_RIGID_LAYER_RELAX - - self.report(f'RigidLayerRelaxWorkChain<{workchain.pk}> finished successfully.') - + + self.report(f"RigidLayerRelaxWorkChain<{workchain.pk}> finished successfully.") + def should_run_surface_energy(self): return self._SURFACE_ENERGY_NAMESPACE in self.inputs def run_surface_energy(self): inputs = AttributeDict( self.exposed_inputs( - PwBaseWorkChain, - namespace=self._SURFACE_ENERGY_NAMESPACE - ) + PwBaseWorkChain, namespace=self._SURFACE_ENERGY_NAMESPACE ) + ) inputs.metadata.call_link_label = self._SURFACE_ENERGY_NAMESPACE - inputs.pw.structure = orm.StructureData( - ase=self.ctx.cleavaged_structure - ) + inputs.pw.structure = orm.StructureData(ase=self.ctx.cleavaged_structure) inputs.kpoints = self.ctx.kpoints_surface_energy running = self.submit(PwBaseWorkChain, **inputs) - self.report(f'launching PwBaseWorkChain<{running.pk}> for cleavaged structure') + self.report(f"launching PwBaseWorkChain<{running.pk}> for cleavaged structure") - return {f"workchain_surface_energy": running} + return {"workchain_surface_energy": running} def inspect_surface_energy(self): """Verify that the surface energy calculation successfully finished.""" workchain = self.ctx.workchain_surface_energy - + if not workchain.is_finished_ok: self.report( f"PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SURFACE_ENERGY - self.report(f'PwBaseWorkChain<{workchain.pk}> finished successfully.') - + self.report(f"PwBaseWorkChain<{workchain.pk}> finished successfully.") + self.out_many( self.exposed_outputs( workchain, @@ -540,20 +576,19 @@ def inspect_surface_energy(self): ), ) # Extract and report energy - total_energy_slab = workchain.outputs.output_parameters.get('energy') + total_energy_slab = workchain.outputs.output_parameters.get("energy") - # Calculate surface energy - if 'total_energy_conventional_geometry' in self.ctx: + if "total_energy_conventional_geometry" in self.ctx: energy_difference = ( - total_energy_slab - - self.ctx.total_energy_conventional_geometry - / self.ctx.conventional_multiplier + total_energy_slab + - self.ctx.total_energy_conventional_geometry + / self.ctx.conventional_multiplier * self.ctx.surface_multiplier ) surface_energy = energy_difference / self.ctx.surface_area * self._eVA22Jm2 self.report( - f'Surface energy evaluated from conventional geometry: {surface_energy} J/m^2' + f"Surface energy evaluated from conventional geometry: {surface_energy} J/m^2" ) def results(self): diff --git a/src/aiida_mechanical/workflows/dislocation/surface.py b/src/aiida_mechanical/workflows/dislocation/surface.py index 708ff3c..1c7f1c9 100644 --- a/src/aiida_mechanical/workflows/dislocation/surface.py +++ b/src/aiida_mechanical/workflows/dislocation/surface.py @@ -2,7 +2,9 @@ from aiida.common import AttributeDict from aiida.engine import WorkChain, if_, while_ import typing as ty -from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import create_kpoints_from_distance +from aiida_quantumespresso.calculations.functions.create_kpoints_from_distance import ( + create_kpoints_from_distance, +) from aiida_quantumespresso.workflows.protocols.utils import ProtocolMixin @@ -20,88 +22,104 @@ clean_workchain_calcs, ) + class SurfaceEnergyWorkChain( ProtocolMixin, StructureGenerationMixin, EnergyCalculationMixin, KpointsSetupMixin, WorkflowInspectionMixin, - WorkChain): + WorkChain, +): """Surface Energy WorkChain""" - _NAMESPACE = 'surface' + _NAMESPACE = "surface" _RELAX_NAMESPACE = "relax" _SCF_NAMESPACE = "scf" _SURFACE_ENERGY_NAMESPACE = "surface_energy" - + _RY2eV = 13.605693122990 - _eVA22Jm2 = 1.602176634E-19 * 1E+20 + _eVA22Jm2 = 1.602176634e-19 * 1e20 @classmethod def define(cls, spec): super().define(spec) - spec.input('structure', valid_type=orm.StructureData, required=True,) spec.input( - 'cleavaged_structure_data', + "structure", + valid_type=orm.StructureData, + required=True, + ) + spec.input( + "cleavaged_structure_data", valid_type=CleavagedStructureData, required=False, - default=lambda: CleavagedStructureData(n_unit_cells=4, vacuum_spacings=[1.0]), - help='Configuration for cleavaged slab generation.', + default=lambda: CleavagedStructureData( + n_unit_cells=4, vacuum_spacings=[1.0] + ), + help="Configuration for cleavaged slab generation.", + ) + spec.input( + "kpoints_distance", + valid_type=orm.Float, + required=False, + default=lambda: orm.Float(0.3), + help="The distance between kpoints for the kpoints generation", + ) + spec.input( + "clean_workdir", + valid_type=orm.Bool, + default=lambda: orm.Bool(False), + help="If `True`, work directories of all called calculation will be cleaned at the end of execution.", ) - spec.input('kpoints_distance', valid_type=orm.Float, required=False, default=lambda: orm.Float(0.3), - help='The distance between kpoints for the kpoints generation') - spec.input('clean_workdir', valid_type=orm.Bool, default=lambda: orm.Bool(False), - help='If `True`, work directories of all called calculation will be cleaned at the end of execution.') spec.expose_inputs( PwRelaxWorkChain, namespace=cls._RELAX_NAMESPACE, exclude=( - 'structure', - 'clean_workdir', - 'kpoints', - 'kpoints_distance', + "structure", + "clean_workdir", + "kpoints", + "kpoints_distance", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwRelaxWorkChain`.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwRelaxWorkChain`.", + }, ) spec.expose_inputs( PwBaseWorkChain, namespace=cls._SCF_NAMESPACE, exclude=( - 'pw.structure', - 'clean_workdir', - 'kpoints', - 'kpoints_distance', + "pw.structure", + "clean_workdir", + "kpoints", + "kpoints_distance", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwBaseWorkChain` for SCF calculation.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwBaseWorkChain` for SCF calculation.", + }, ) - spec.expose_inputs( PwBaseWorkChain, namespace=cls._SURFACE_ENERGY_NAMESPACE, exclude=( - 'pw.structure', - 'clean_workdir', - 'kpoints', - 'kpoints_distance', + "pw.structure", + "clean_workdir", + "kpoints", + "kpoints_distance", ), namespace_options={ - 'required': False, - 'populate_defaults': False, - 'help': 'Inputs for the `PwBaseWorkChain` for surface energy calculation.' - } + "required": False, + "populate_defaults": False, + "help": "Inputs for the `PwBaseWorkChain` for surface energy calculation.", + }, ) spec.outline( @@ -125,52 +143,52 @@ def define(cls, spec): PwRelaxWorkChain, namespace=cls._RELAX_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) spec.expose_outputs( PwBaseWorkChain, namespace=cls._SCF_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) spec.expose_outputs( PwBaseWorkChain, namespace=cls._SURFACE_ENERGY_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) spec.output( - 'results', + "results", valid_type=orm.Dict, required=False, - help='Aggregated surface-energy results for all evaluated vacuum spacings.', + help="Aggregated surface-energy results for all evaluated vacuum spacings.", ) - + spec.exit_code( 401, "ERROR_SUB_PROCESS_FAILED_RELAX", - message='The `PwBaseWorkChain` for the GSF run failed.', + message="The `PwBaseWorkChain` for the GSF run failed.", ) - + spec.exit_code( 402, "ERROR_SUB_PROCESS_FAILED_SCF", - message='The `PwBaseWorkChain` for the USF run failed.', + message="The `PwBaseWorkChain` for the USF run failed.", ) spec.exit_code( 403, "ERROR_SUB_PROCESS_FAILED_SURFACE_ENERGY", - message='The `PwBaseWorkChain` for the surface energy run failed.', + message="The `PwBaseWorkChain` for the surface energy run failed.", ) spec.exit_code( 404, "ERROR_NO_STRUCTURE_TYPE_DETECTED", - message='The structure type is not detected.', + message="The structure type is not detected.", ) - + @classmethod def get_protocol_overrides(cls) -> dict: """Get the ``overrides`` of the default protocol.""" @@ -182,39 +200,27 @@ def get_protocol_overrides(cls) -> dict: with path.open() as file: return yaml.safe_load(file) - @classmethod def get_protocol_filepath(cls): """Return ``pathlib.Path`` to the ``.yaml`` file that defines the protocols.""" from importlib_resources import files from . import protocols - return files(protocols) / f'{cls._NAMESPACE}.yaml' - - @classmethod - def get_protocol_overrides(cls) -> dict: - """Get the ``overrides`` of the default protocol.""" - from importlib_resources import files - import yaml - from . import protocols - path = files(protocols) / f"{cls._NAMESPACE}.yaml" - with path.open() as file: - return yaml.safe_load(file) + return files(protocols) / f"{cls._NAMESPACE}.yaml" @classmethod def get_builder_from_protocol( - cls, - code, - structure, - protocol='moderate', - overrides=None, - n_repeats: ty.Optional[int | orm.Int] = None, - gliding_plane: ty.Optional[str | orm.Str] = None, - vacuum_spacings: ty.Optional[ty.Sequence[float] | orm.List] = None, - **kwargs - ): - """Return a builder prepopulated with inputs selected according to the chosen protocol. - """ + cls, + code, + structure, + protocol="moderate", + overrides=None, + n_repeats: ty.Optional[int | orm.Int] = None, + gliding_plane: ty.Optional[str | orm.Str] = None, + vacuum_spacings: ty.Optional[ty.Sequence[float] | orm.List] = None, + **kwargs, + ): + """Return a builder prepopulated with inputs selected according to the chosen protocol.""" inputs = cls.get_protocol_inputs(protocol, overrides) args = (code, structure, protocol) @@ -229,87 +235,101 @@ def get_builder_from_protocol( overrides = inputs.get(namespace, {}) if workchain_type == PwRelaxWorkChain: - overrides.setdefault('base_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) - overrides.setdefault('base_init_relax', {})['pseudo_family'] = inputs.get('pseudo_family', None) + overrides.setdefault("base_relax", {})["pseudo_family"] = inputs.get( + "pseudo_family", None + ) + overrides.setdefault("base_init_relax", {})["pseudo_family"] = ( + inputs.get("pseudo_family", None) + ) else: - overrides['pseudo_family'] = inputs.get('pseudo_family', None) + overrides["pseudo_family"] = inputs.get("pseudo_family", None) sub_builder = workchain_type.get_builder_from_protocol( *args, overrides=overrides, ) - sub_builder.pop('structure', None) - sub_builder.pop('clean_workdir', None) + sub_builder.pop("structure", None) + sub_builder.pop("clean_workdir", None) if namespace != cls._RELAX_NAMESPACE: - sub_builder.pop('kpoints', None) - sub_builder.pop('kpoints_distance', None) + sub_builder.pop("kpoints", None) + sub_builder.pop("kpoints_distance", None) builder[namespace]._data = sub_builder._data if cls._RELAX_NAMESPACE in builder: - builder[cls._RELAX_NAMESPACE].pop('base_init_relax', None) - if 'base_relax' in builder[cls._RELAX_NAMESPACE]: - builder[cls._RELAX_NAMESPACE]['base_relax'].pop('kpoints', None) - builder[cls._RELAX_NAMESPACE]['base_relax'].pop('kpoints_distance', None) + builder[cls._RELAX_NAMESPACE].pop("base_init_relax", None) + if "base_relax" in builder[cls._RELAX_NAMESPACE]: + builder[cls._RELAX_NAMESPACE]["base_relax"].pop("kpoints", None) + builder[cls._RELAX_NAMESPACE]["base_relax"].pop( + "kpoints_distance", None + ) builder.structure = structure - resolved_n_repeats = n_repeats.value if isinstance(n_repeats, orm.Int) else n_repeats - resolved_gliding_plane = gliding_plane.value if isinstance(gliding_plane, orm.Str) else gliding_plane + resolved_n_repeats = ( + n_repeats.value if isinstance(n_repeats, orm.Int) else n_repeats + ) + resolved_gliding_plane = ( + gliding_plane.value if isinstance(gliding_plane, orm.Str) else gliding_plane + ) if isinstance(vacuum_spacings, orm.List): resolved_vacuum_spacings = vacuum_spacings.get_list() else: - resolved_vacuum_spacings = list(vacuum_spacings) if vacuum_spacings is not None else None + resolved_vacuum_spacings = ( + list(vacuum_spacings) if vacuum_spacings is not None else None + ) builder.cleavaged_structure_data = CleavagedStructureData( - n_unit_cells=inputs.get('n_repeats', 4) if resolved_n_repeats is None else resolved_n_repeats, - gliding_plane=inputs.get('gliding_plane', '') if resolved_gliding_plane is None else resolved_gliding_plane, - vacuum_spacings=inputs.get('vacuum_spacings', [1.0]) if resolved_vacuum_spacings is None else resolved_vacuum_spacings, + n_unit_cells=inputs.get("n_repeats", 4) + if resolved_n_repeats is None + else resolved_n_repeats, + gliding_plane=inputs.get("gliding_plane", "") + if resolved_gliding_plane is None + else resolved_gliding_plane, + vacuum_spacings=inputs.get("vacuum_spacings", [1.0]) + if resolved_vacuum_spacings is None + else resolved_vacuum_spacings, ) - builder.kpoints_distance = orm.Float(inputs['kpoints_distance']) - builder.clean_workdir = orm.Bool(inputs['clean_workdir']) + builder.kpoints_distance = orm.Float(inputs["kpoints_distance"]) + builder.clean_workdir = orm.Bool(inputs["clean_workdir"]) return builder - def should_run_relax(self): return self._RELAX_NAMESPACE in self.inputs def run_relax(self): inputs = AttributeDict( - self.exposed_inputs( - PwRelaxWorkChain, - namespace=self._RELAX_NAMESPACE - ) + self.exposed_inputs(PwRelaxWorkChain, namespace=self._RELAX_NAMESPACE) ) inputs.metadata.call_link_label = self._RELAX_NAMESPACE inputs.structure = self.inputs.structure inputs.base_relax.kpoints_distance = self.inputs.kpoints_distance running = self.submit(PwRelaxWorkChain, **inputs) - self.report(f'launching PwRelaxWorkChain<{running.pk}> for primitive structure') - return {f"workchain_relax": running} + self.report(f"launching PwRelaxWorkChain<{running.pk}> for primitive structure") + return {"workchain_relax": running} def inspect_relax(self): workchain = self.ctx.workchain_relax if not workchain.is_finished_ok: - self.report(f'PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + self.report( + f"PwRelaxWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_RELAX - self.report(f'PwRelaxWorkChain<{self.ctx.workchain_relax.pk}> finished') + self.report(f"PwRelaxWorkChain<{self.ctx.workchain_relax.pk}> finished") self.ctx.current_structure = workchain.outputs.output_structure - + # Expose outputs self.out_many( self.exposed_outputs( - workchain, - PwRelaxWorkChain, - namespace=self._RELAX_NAMESPACE + workchain, PwRelaxWorkChain, namespace=self._RELAX_NAMESPACE ) ) def generate_structures(self): """Generate provenance-tracked conventional and slab structures.""" - if 'current_structure' not in self.ctx: + if "current_structure" not in self.ctx: self.ctx.current_structure = self.inputs.structure try: @@ -318,42 +338,52 @@ def generate_structures(self): cleavaged_data=self.inputs.cleavaged_structure_data, ) except ValueError as exception: - self.report(f'Failed to generate cleavaged structures: {exception}') + self.report(f"Failed to generate cleavaged structures: {exception}") return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED slab_entries: dict[str, dict[str, ty.Any]] = {} for output_label, output_node in generated_structures.items(): - if output_label.startswith('vacuum_spacing_'): - spacing_key = output_label.removeprefix('vacuum_spacing_') - slab_entries.setdefault(spacing_key, {})['vacuum_spacing'] = float(output_node.value) + if output_label.startswith("vacuum_spacing_"): + spacing_key = output_label.removeprefix("vacuum_spacing_") + slab_entries.setdefault(spacing_key, {})["vacuum_spacing"] = float( + output_node.value + ) continue - if output_label.startswith('slab_'): - spacing_key = output_label.removeprefix('slab_') - slab_entries.setdefault(spacing_key, {})['structure'] = output_node + if output_label.startswith("slab_"): + spacing_key = output_label.removeprefix("slab_") + slab_entries.setdefault(spacing_key, {})["structure"] = output_node self.ctx.generated_structures = [] for spacing_key, slab_entry in slab_entries.items(): - if 'vacuum_spacing' not in slab_entry or 'structure' not in slab_entry: - self.report(f'Incomplete slab entry generated for spacing key `{spacing_key}`.') + if "vacuum_spacing" not in slab_entry or "structure" not in slab_entry: + self.report( + f"Incomplete slab entry generated for spacing key `{spacing_key}`." + ) return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED - self.ctx.generated_structures.append({ - 'call_link_label': f'slab_{spacing_key}', - 'structure': slab_entry['structure'], - 'vacuum_spacing': slab_entry['vacuum_spacing'], - }) + self.ctx.generated_structures.append( + { + "call_link_label": f"slab_{spacing_key}", + "structure": slab_entry["structure"], + "vacuum_spacing": slab_entry["vacuum_spacing"], + } + ) if not self.ctx.generated_structures: - self.report('No slab structures were generated for the selected configuration.') + self.report( + "No slab structures were generated for the selected configuration." + ) return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED self.ctx.number_of_spacings = len(self.ctx.generated_structures) - self.ctx.conventional_structure = generated_structures['conventional_structure'] - self.ctx.surface_area = generated_structures['surface_area'].value + self.ctx.conventional_structure = generated_structures["conventional_structure"] + self.ctx.surface_area = generated_structures["surface_area"].value - self.report(f'Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2') + self.report( + f"Surface area of the conventional geometry: {self.ctx.surface_area} Angstrom^2" + ) self.ctx.unit_cell_multiplier = self._calculate_structure_multiplier( self.ctx.current_structure.get_ase() @@ -364,19 +394,19 @@ def generate_structures(self): def _get_kpoints_scf(self): """Get or create kpoints_scf. Returns kpoints_scf KpointsData object.""" - if 'kpoints_scf' in self.ctx: + if "kpoints_scf" in self.ctx: kpoints_scf = self.ctx.kpoints_scf else: inputs = { - 'structure': self.ctx.conventional_structure, - 'distance': self.inputs.kpoints_distance, - 'force_parity': self.inputs.get('kpoints_force_parity', orm.Bool(False)), - 'metadata': { - 'call_link_label': 'create_kpoints_from_distance' - } + "structure": self.ctx.conventional_structure, + "distance": self.inputs.kpoints_distance, + "force_parity": self.inputs.get( + "kpoints_force_parity", orm.Bool(False) + ), + "metadata": {"call_link_label": "create_kpoints_from_distance"}, } kpoints_scf = create_kpoints_from_distance(**inputs) # pylint: disable=unexpected-keyword-arg - + return kpoints_scf def setup(self): @@ -390,11 +420,8 @@ def should_run_scf(self): def run_scf(self): inputs = AttributeDict( - self.exposed_inputs( - PwBaseWorkChain, - namespace=self._SCF_NAMESPACE - ) - ) + self.exposed_inputs(PwBaseWorkChain, namespace=self._SCF_NAMESPACE) + ) inputs.metadata.call_link_label = self._SCF_NAMESPACE @@ -403,30 +430,33 @@ def run_scf(self): inputs.kpoints = self.ctx.kpoints_scf running = self.submit(PwBaseWorkChain, **inputs) - self.report(f'launching PwBaseWorkChain<{running.pk}> for conventional structure') + self.report( + f"launching PwBaseWorkChain<{running.pk}> for conventional structure" + ) - return {f"workchain_scf": running} + return {"workchain_scf": running} def inspect_scf(self): workchain = self.ctx.workchain_scf if not workchain.is_finished_ok: - self.report(f'PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + self.report( + f"PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SCF - self.report(f'PwBaseWorkChain<{self.ctx.workchain_scf.pk}> finished') - + self.report(f"PwBaseWorkChain<{self.ctx.workchain_scf.pk}> finished") + # Expose outputs self.out_many( self.exposed_outputs( - workchain, - PwBaseWorkChain, - namespace=self._SCF_NAMESPACE + workchain, PwBaseWorkChain, namespace=self._SCF_NAMESPACE ) ) - self.ctx.total_energy_conventional_geometry = self._get_workchain_energy(workchain) + self.ctx.total_energy_conventional_geometry = self._get_workchain_energy( + workchain + ) def should_run_surface_energy(self): - if self._SURFACE_ENERGY_NAMESPACE not in self.inputs: return False @@ -434,9 +464,9 @@ def should_run_surface_energy(self): return False current_entry = self.ctx.generated_structures[self.ctx.iteration] - self.ctx.current_structure = current_entry['structure'] - self.ctx.current_spacing = float(current_entry['vacuum_spacing']) - self.ctx.current_call_link_label = current_entry['call_link_label'] + self.ctx.current_structure = current_entry["structure"] + self.ctx.current_spacing = float(current_entry["vacuum_spacing"]) + self.ctx.current_call_link_label = current_entry["call_link_label"] self.ctx.kpoints_surface_energy = self._calculate_kpoints_for_structure( self.ctx.current_structure, self.ctx.kpoints_scf, @@ -447,58 +477,65 @@ def should_run_surface_energy(self): def run_surface_energy(self): inputs = AttributeDict( self.exposed_inputs( - PwBaseWorkChain, - namespace=self._SURFACE_ENERGY_NAMESPACE + PwBaseWorkChain, namespace=self._SURFACE_ENERGY_NAMESPACE ) ) inputs.metadata.call_link_label = self.ctx.current_call_link_label inputs.pw.structure = self.ctx.current_structure - inputs.kpoints = self.ctx.kpoints_surface_energy + inputs.kpoints = self.ctx.kpoints_surface_energy running = self.submit(PwBaseWorkChain, **inputs) self.report( - f'launching PwBaseWorkChain<{running.pk}> for cleavaged structure ' - f'{self.ctx.iteration + 1}/{self.ctx.number_of_spacings} ({self.ctx.current_call_link_label}).' + f"launching PwBaseWorkChain<{running.pk}> for cleavaged structure " + f"{self.ctx.iteration + 1}/{self.ctx.number_of_spacings} ({self.ctx.current_call_link_label})." ) - return {f"workchain_surface_energy": running} + return {"workchain_surface_energy": running} def inspect_surface_energy(self): workchain = self.ctx.workchain_surface_energy if not workchain.is_finished_ok: - self.report(f'PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}') + self.report( + f"PwBaseWorkChain<{workchain.pk}> failed with exit status {workchain.exit_status}" + ) return self.exit_codes.ERROR_SUB_PROCESS_FAILED_SURFACE_ENERGY - self.report(f'PwBaseWorkChain<{self.ctx.workchain_surface_energy.pk}> finished') + self.report(f"PwBaseWorkChain<{self.ctx.workchain_surface_energy.pk}> finished") total_energy_slab = self._get_workchain_energy(workchain) - surface_multiplier = self._calculate_structure_multiplier(self.ctx.current_structure) + surface_multiplier = self._calculate_structure_multiplier( + self.ctx.current_structure + ) surface_energy_j_m2 = self._calculate_surface_energy( total_energy_slab, surface_multiplier, ) self.ctx.results[self.ctx.current_call_link_label] = { - 'vacuum_spacing': float(self.ctx.current_spacing), - 'structure_uuid': self.ctx.current_structure.uuid, - 'total_energy_ev': float(total_energy_slab), - 'surface_energy_j_m2': float(surface_energy_j_m2) if surface_energy_j_m2 is not None else None, - 'workchain_uuid': workchain.uuid, + "vacuum_spacing": float(self.ctx.current_spacing), + "structure_uuid": self.ctx.current_structure.uuid, + "total_energy_ev": float(total_energy_slab), + "surface_energy_j_m2": float(surface_energy_j_m2) + if surface_energy_j_m2 is not None + else None, + "workchain_uuid": workchain.uuid, } self.ctx.iteration += 1 def results(self): """Output collected results.""" - self.out('results', orm.Dict(dict=self.ctx.results).store()) + self.out("results", orm.Dict(dict=self.ctx.results).store()) def on_terminated(self) -> None: """Clean child calculation working directories if ``clean_workdir`` is enabled.""" super().on_terminated() if self.inputs.clean_workdir.value is False: - self.report('remote folders will not be cleaned') + self.report("remote folders will not be cleaned") return cleaned_calcs = clean_workchain_calcs(self.node) if cleaned_calcs: - self.report(f'cleaned remote folders of calculations: {" ".join(map(str, cleaned_calcs))}') + self.report( + f"cleaned remote folders of calculations: {' '.join(map(str, cleaned_calcs))}" + ) diff --git a/src/aiida_mechanical/workflows/dislocation/twinning.py b/src/aiida_mechanical/workflows/dislocation/twinning.py index 9105862..8236f72 100644 --- a/src/aiida_mechanical/workflows/dislocation/twinning.py +++ b/src/aiida_mechanical/workflows/dislocation/twinning.py @@ -6,9 +6,9 @@ from ase.formula import Formula from aiida_mechanical.tools import ( get_unstable_faulted_structure, - calculate_surface_area, ) + class TwinningWorkChain(SFEBaseWorkChain): """Twinning WorkChain""" @@ -22,14 +22,14 @@ def define(cls, spec): PwBaseWorkChain, namespace=cls._SFE_NAMESPACE, namespace_options={ - 'required': False, - } + "required": False, + }, ) - + spec.exit_code( 404, "ERROR_SUB_PROCESS_FAILED_TWINNING", - message='The `PwBaseWorkChain` for the twinning run failed.', + message="The `PwBaseWorkChain` for the twinning run failed.", ) def setup(self): @@ -42,7 +42,7 @@ def _get_fault_type(self): Twinning is not a fault type, but we need to implement this for the base class. For twinning, we use 'unstable' as the fault type for structure generation. """ - return 'unstable' # Use 'unstable' for structure generation purposes + return "unstable" # Use 'unstable' for structure generation purposes def generate_structures(self): """Generate all structures including conventional, cleavaged, and twinning.""" @@ -50,9 +50,11 @@ def generate_structures(self): result = super().generate_structures() if result: return result - - gliding_plane = self.inputs.gliding_plane.value if self.inputs.gliding_plane.value else None - + + gliding_plane = ( + self.inputs.gliding_plane.value if self.inputs.gliding_plane.value else None + ) + # Get twinning structure using get_unstable_faulted_structure strukturbericht, structures_dict = get_unstable_faulted_structure( self.ctx.current_structure.get_ase(), @@ -61,32 +63,34 @@ def generate_structures(self): ) # Verify that twinning structure was generated - if 'twinning' not in structures_dict or structures_dict['twinning'] is None: - self.report('Twinning structure is not available for this gliding system.') + if "twinning" not in structures_dict or structures_dict["twinning"] is None: + self.report("Twinning structure is not available for this gliding system.") return self.exit_codes.ERROR_NO_STRUCTURE_TYPE_DETECTED # Store twinning structure directly in context - self.ctx.twinning_structure = structures_dict['twinning'] - self.ctx.unfaulted_structure = self.ctx.conventional_structure # unfaulted is the same as conventional + self.ctx.twinning_structure = structures_dict["twinning"] + self.ctx.unfaulted_structure = ( + self.ctx.conventional_structure + ) # unfaulted is the same as conventional self.ctx.unfaulted_multiplier = self.ctx.conventional_multiplier def should_run_sfe(self): - if not self._SFE_NAMESPACE in self.inputs: + if self._SFE_NAMESPACE not in self.inputs: return False - if getattr(self.ctx, 'twinning_done', False): + if getattr(self.ctx, "twinning_done", False): return False - + # Set up current structure and multiplier - if not hasattr(self.ctx, 'twinning_structure'): - raise ValueError('Twinning structure not found in context.') - + if not hasattr(self.ctx, "twinning_structure"): + raise ValueError("Twinning structure not found in context.") + current_structure = orm.StructureData(ase=self.ctx.twinning_structure) self.ctx.current_structure = current_structure - + twinning_formula = Formula(self.ctx.twinning_structure.get_chemical_formula()) _, twinning_multiplier = twinning_formula.reduce() self.ctx.twinning_multiplier = twinning_multiplier - + return True def run_layer_relax(self): @@ -94,30 +98,35 @@ def run_layer_relax(self): # Setup kpoints for twinning structure faulted_structure_ase = self.ctx.current_structure.get_ase() conventional_structure_ase = self.ctx.conventional_structure - - z_ratio = faulted_structure_ase.cell.cellpar()[2] / conventional_structure_ase.cell.cellpar()[2] + + z_ratio = ( + faulted_structure_ase.cell.cellpar()[2] + / conventional_structure_ase.cell.cellpar()[2] + ) kpoints_scf = self._get_kpoints_scf() - + from math import ceil + kpoints_sfe = orm.KpointsData() kpoints_scf_mesh = kpoints_scf.get_kpoints_mesh()[0] - kpoints_sfe.set_kpoints_mesh(kpoints_scf_mesh[:2] + [ceil(kpoints_scf_mesh[2] / z_ratio)]) - + kpoints_sfe.set_kpoints_mesh( + kpoints_scf_mesh[:2] + [ceil(kpoints_scf_mesh[2] / z_ratio)] + ) + # Prepare inputs for PwBaseWorkChain inputs = AttributeDict( - self.exposed_inputs( - PwBaseWorkChain, - namespace=self._SFE_NAMESPACE - ) + self.exposed_inputs(PwBaseWorkChain, namespace=self._SFE_NAMESPACE) ) - + inputs.pw.structure = self.ctx.current_structure inputs.kpoints = kpoints_sfe inputs.metadata.call_link_label = self._SFE_NAMESPACE - + running = self.submit(PwBaseWorkChain, **inputs) - self.report(f'launching PwBaseWorkChain<{running.pk}> for twinning faulted geometry.') - + self.report( + f"launching PwBaseWorkChain<{running.pk}> for twinning faulted geometry." + ) + return ToContext(workchain_layer_relax=running) def inspect_layer_relax(self): @@ -131,7 +140,7 @@ def inspect_layer_relax(self): return self.exit_codes.ERROR_SUB_PROCESS_FAILED_TWINNING self.report( - f'PwBaseWorkChain<{workchain.pk}> for twinning faulted geometry finished OK' + f"PwBaseWorkChain<{workchain.pk}> for twinning faulted geometry finished OK" ) self.out_many( self.exposed_outputs( @@ -147,22 +156,24 @@ def inspect_layer_relax(self): self._report_energy( total_energy_twinning_geometry, self.ctx.twinning_multiplier, - 'twinning faulted geometry', - 'unit cells' + "twinning faulted geometry", + "unit cells", ) - + # Calculate stacking fault energy using helper method twinning_stacking_fault_energy = self._calculate_stacking_fault_energy( - total_energy_twinning_geometry, - self.ctx.twinning_multiplier, - 'twinning' + total_energy_twinning_geometry, self.ctx.twinning_multiplier, "twinning" ) - self.ctx.twinning_data.append({ - 'energy_ry': float(total_energy_twinning_geometry), - 'twinning_multiplier': self.ctx.twinning_multiplier, - 'twinning_j_m2': float(twinning_stacking_fault_energy) if twinning_stacking_fault_energy is not None else None, - }) + self.ctx.twinning_data.append( + { + "energy_ry": float(total_energy_twinning_geometry), + "twinning_multiplier": self.ctx.twinning_multiplier, + "twinning_j_m2": float(twinning_stacking_fault_energy) + if twinning_stacking_fault_energy is not None + else None, + } + ) self.ctx.twinning_done = True def results(self): diff --git a/src/aiida_mechanical/workflows/dislocation/usfe.py b/src/aiida_mechanical/workflows/dislocation/usfe.py index 25767f2..b18d228 100644 --- a/src/aiida_mechanical/workflows/dislocation/usfe.py +++ b/src/aiida_mechanical/workflows/dislocation/usfe.py @@ -1,6 +1,5 @@ from .sfebase import SFEBaseWorkChain -from .layer_relax import RigidLayerRelaxWorkChain -from aiida import orm + class USFEWorkChain(SFEBaseWorkChain): """USFE WorkChain""" @@ -14,28 +13,23 @@ def define(cls, spec): spec.exit_code( 404, "ERROR_SUB_PROCESS_FAILED_USF", - message='The `PwBaseWorkChain` for the USF run failed.', + message="The `PwBaseWorkChain` for the USF run failed.", ) @classmethod def get_builder_from_protocol( - cls, - code, - structure, - protocol='moderate', - overrides=None, - **kwargs - ): + cls, code, structure, protocol="moderate", overrides=None, **kwargs + ): inputs = cls.get_protocol_inputs(protocol, overrides) builder = super().get_builder_from_protocol( - code, structure, protocol, overrides, **kwargs) + code, structure, protocol, overrides, **kwargs + ) return builder def _get_fault_type(self): """Return the fault type for USFE workchain.""" - return 'unstable' - + return "unstable" def results(self): """Expose collected USFE data to the caller.""" - pass \ No newline at end of file + pass