diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index ded6faf..7793d5f 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -1,26 +1,26 @@ name: documentation -on: [push, pull_request, workflow_dispatch] - -permissions: - contents: write +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: jobs: docs: runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.9' + python-version: '3.10' - name: Install dependencies - run: | - pip install sphinx sphinx_rtd_theme myst_parser autodoc - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install -e . + run: pip install -e ".[docs]" - name: Sphinx build - run: | - sphinx-build docs _build + run: sphinx-build docs _build - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v3 if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} diff --git a/.github/workflows/draft-pdf.yaml b/.github/workflows/draft-pdf.yaml deleted file mode 100644 index fd18457..0000000 --- a/.github/workflows/draft-pdf.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: Draft PDF -on: [push] - -jobs: - paper: - runs-on: ubuntu-latest - name: Paper Draft - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Build draft PDF - uses: openjournals/openjournals-draft-action@master - with: - journal: joss - # This should be the path to the paper within your repo. - paper-path: paper.md - - name: Upload - uses: actions/upload-artifact@v3 - with: - name: paper - # This is the output path where Pandoc will write the compiled - # PDF. Note, this should be the same directory as the input - # paper.md - path: paper.pdf - - name: Commit PDF to repository - uses: EndBug/add-and-commit@v9 - with: - message: '(auto) Paper PDF Draft' - # This should be the path to the paper within your repo. - add: 'paper.pdf' # 'paper/*.pdf' to commit all PDFs in the paper directory diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 3289313..9cf776a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,6 +1,9 @@ name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI -on: push +on: + push: + tags: + - 'v*' jobs: build: @@ -14,11 +17,7 @@ jobs: with: python-version: "3.10" - name: Install pypa/build - run: >- - python3 -m - pip install - build - --user + run: python3 -m pip install build --user - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages @@ -27,16 +26,16 @@ jobs: name: python-package-distributions path: dist/ - publish-to-pypi: - name: >- - Publish Python 🐍 distribution 📦 to PyPI - if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes + publish-to-testpypi: + name: Publish Python 🐍 distribution 📦 to TestPyPI needs: - build runs-on: ubuntu-latest + environment: - name: pypi - url: https://pypi.org/p/asimtools # Replace with your PyPI project name + name: testpypi + url: https://test.pypi.org/p/asimtools + permissions: id-token: write # IMPORTANT: mandatory for trusted publishing @@ -46,73 +45,68 @@ jobs: with: name: python-package-distributions path: dist/ - - name: Publish distribution 📦 to PyPI + - name: Publish distribution 📦 to TestPyPI uses: pypa/gh-action-pypi-publish@release/v1 + with: + verbose: true + repository-url: https://test.pypi.org/legacy/ - # github-release: - # name: >- - # Sign the Python 🐍 distribution 📦 with Sigstore - # and upload them to GitHub Release - # needs: - # - publish-to-pypi - # runs-on: ubuntu-latest - - # permissions: - # contents: write # IMPORTANT: mandatory for making GitHub Releases - # id-token: write # IMPORTANT: mandatory for sigstore - - # steps: - # - name: Download all the dists - # uses: actions/download-artifact@v4 - # with: - # name: python-package-distributions - # path: dist/ - # - name: Sign the dists with Sigstore - # uses: sigstore/gh-action-sigstore-python@v2.1.1 - # with: - # inputs: >- - # ./dist/*.tar.gz - # ./dist/*.whl - # - name: Create GitHub Release - # env: - # GITHUB_TOKEN: ${{ github.token }} - # run: >- - # gh release create - # '${{ github.ref_name }}' - # --repo '${{ github.repository }}' - # --notes "" - # - name: Upload artifact signatures to GitHub Release - # env: - # GITHUB_TOKEN: ${{ github.token }} - # # Upload to GitHub Release using the `gh` CLI. - # # `dist/` contains the built packages, and the - # # sigstore-produced signatures and certificates. - # run: >- - # gh release upload - # '${{ github.ref_name }}' dist/** - # --repo '${{ github.repository }}' + publish-to-pypi: + name: Publish Python 🐍 distribution 📦 to PyPI + needs: + - publish-to-testpypi + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/asimtools + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing - # publish-to-testpypi: - # name: Publish Python 🐍 distribution 📦 to TestPyPI - # needs: - # - build - # runs-on: ubuntu-latest + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 - # environment: - # name: testpypi - # url: https://test.pypi.org/p/asimtools + github-release: + name: >- + Sign the Python 🐍 distribution 📦 with Sigstore + and upload them to GitHub Release + needs: + - publish-to-pypi + runs-on: ubuntu-latest - # permissions: - # id-token: write # IMPORTANT: mandatory for trusted publishing + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore - # steps: - # - name: Download all the dists - # uses: actions/download-artifact@v4 - # with: - # name: python-package-distributions - # path: dist/ - # - name: Publish distribution 📦 to TestPyPI - # uses: pypa/gh-action-pypi-publish@release/v1 - # with: - # verbose: true - # repository-url: https://test.pypi.org/legacy/ + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v2.1.1 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release create + '${{ github.ref_name }}' + --repo '${{ github.repository }}' + --notes "" + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' diff --git a/.github/workflows/static.yaml b/.github/workflows/static.yaml deleted file mode 100644 index 37a4402..0000000 --- a/.github/workflows/static.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# Simple workflow for deploying static content to GitHub Pages -name: Deploy static content to Pages - -on: - # Runs on pushes targeting the default branch - push: - branches: ["gh-pages"] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - # Single deploy job since we're just deploying - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Setup Pages - uses: actions/configure-pages@v3 - - name: Upload artifact - uses: actions/upload-pages-artifact@v2 - with: - # Upload entire repository - path: 'build' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v2 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 216eed7..147603e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -20,10 +20,9 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: "3.10" - cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..642c344 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: local + hooks: + - id: pylint + name: pylint + entry: conda run -n asimtools-dev pylint + language: system + types: [python] + files: ^src/asimtools/ + exclude: ^src/asimtools/asimmodules/ + args: [--rcfile=.pylintrc] + + - id: pytest + name: pytest + entry: conda run -n asimtools-dev pytest tests/unit/ + language: system + pass_filenames: false + always_run: true diff --git a/.pylintrc b/.pylintrc index 9abf5d4..bd1b1fe 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,285 +1,75 @@ [MASTER] - -# Specify a configuration file. -#rcfile= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook='import sys; sys.path.extend(["/Users/xiez/.virtualenvs/seahub/lib/python2.7/site-packages", "/usr/local/lib/python2.7/site-packages/"])' - -# Profiled execution. -profile=no - -# Add files or directories to the blacklist. They should be base names, not -# paths. -ignore=CVS - -# Pickle collected data for later comparisons. +ignore=CVS,.git,__pycache__ persistent=yes - -# List of plugins (as comma separated values of python modules names) to load, -# usually to register additional checkers. load-plugins= - [MESSAGES CONTROL] - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time. -#enable= - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). -# -# These warnings are annoying, disable them: -# C0111: Missing docstring -# C0301: line too long -# C0302: Too many lines in module (NN) -# I0011: Locally disabling W0000 -# R0201: Method could be a function -# R0801: Similar lines in N files -# R0902: Too many instance attributes (N/7) -# R0903: Too few public methods (N/2) -# R0904: Too many public methods (N/20) -# R0911: Too many return statements (N/6) -# R0912: Too many branches (NN/12) -# R0913: Too many arguments (NN/5) -# R0914: Too many local variables (NN/15) -# R0915: Too many statements (NN/50) -# W0511: TODO -# W0401: Wildcard import FOO -# W0141: Used builtin function 'map' -# W0142: Used * or ** magic -# W0232: Class has no __init__ method -# W0603: Using the global statement -# W0614: Unused import FOO from wildcard import -# W0703: Catch "Exception" -# W1201: Specify string format arguments as logging function parameters -# E1121: Too many positional arguments for function call -# -# These warnings are genuine, we should add them back, by potentially only -# disabling at the lines generating a false positive: -# C0103: Invalid name "FOO" (should match [a-z_][a-z0-9_]{2,30}$) -# E1103: Instance of 'FOO' has no 'BAR' member (but some types could not be inferred) -# W0621: Redefining name 'FOO' from outer scope (line NN) -# W0622: Redefining built-in 'FOO' -# W0702: No exception type(s) specified -# -disable=C0103,C0111,C0302,E1103,I0011,R0201,R0801,R0904,R0911,R0912,R0913,R0914,R0915,W0141,W0142,W0232,W0401,W0603,W0613,W0614,W0621,W0622,W0702,W0703,W1201,E1121 - +disable= + R0801, # Similar lines in N files + W0511, # TODO/FIXME notes [REPORTS] - -# Set the output format. Available formats are text, parseable, colorized, msvs -# (visual studio) and html -output-format=text - -# Include message's id in output -include-ids=yes - -# Put messages in a separate file for each module / package specified on the -# command line instead of printing them on stdout. Reports (if any) will be -# written in a file name "pylint_global.[txt|html]". -files-output=no - -# Tells whether to display a full report or only the messages +output-format=colorized reports=no - -# Python expression which should return a note less than 10 (10 is the highest -# note). You have access to the variables errors warning, statement which -# respectively contain the number of errors / warnings messages and the total -# number of statements analyzed. This is used by the global evaluation report -# (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Add a comment according to your evaluation note. This is used by the global -# evaluation report (RP0004). -comment=no - - -[VARIABLES] - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# A regular expression matching the beginning of the name of dummy variables -# (i.e. not used). -dummy-variables-rgx=_|dummy - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid to define new builtins when possible. -additional-builtins= - - -[TYPECHECK] - -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes - -# List of classes names for which member attributes should not be checked -# (useful for classes with attributes dynamically set). -ignored-classes=django.db.models.Model,django.forms.Form,seahub.avatar.models.AvatarBase - -# When zope mode is activated, add a predefined set of Zope acquired attributes -# to generated-members. -zope=no - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E0201 when accessed. Python regular -# expressions are accepted. -generated-members=objects,DoesNotExist,cleaned_data,is_valid,errors - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME,XXX,TODO - - -[SIMILARITIES] - -# Minimum lines number of a similarity. -min-similarity-lines=4 - -# Ignore comments when computing similarities. -ignore-comments=yes - -# Ignore docstrings when computing similarities. -ignore-docstrings=yes - +score=yes [FORMAT] - -# Maximum number of characters on a single line. -max-line-length=80 - -# Maximum number of lines in a module -max-module-lines=1000 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -# In rietveld, 2 spaces indents are used. +max-line-length=100 +max-module-lines=1500 indent-string=' ' - +indent-after-paren=4 [BASIC] +# Module names: lowercase with underscores +module-rgx=[a-z_][a-z0-9_]*$ -# Required attributes for module, separated by a comma -required-attributes= - -# List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter,apply,input - -# Regular expression which should only match correct module names -module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ - -# Regular expression which should only match correct module level names -const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ - -# Regular expression which should only match correct class names -class-rgx=[A-Z_][a-zA-Z0-9]+$ - -# Regular expression which should only match correct function names -function-rgx=[a-z_][a-z0-9_]{2,30}$ +# Constants: ALL_CAPS or TypeVar-style (e.g. Atoms = TypeVar('Atoms')) +const-rgx=(([A-Z_][A-Z0-9_]*)|([A-Z][a-zA-Z0-9]+)|(__.*__))$ -# Regular expression which should only match correct method names -method-rgx=[a-z_][a-z0-9_]{2,30}$ +# Classes: PascalCase +class-rgx=[A-Z][a-zA-Z0-9]+$ -# Regular expression which should only match correct instance attribute names -attr-rgx=[a-z_][a-z0-9_]{2,30}$ +# Functions and methods: snake_case, up to 40 chars +function-rgx=[a-z_][a-z0-9_]{1,40}$ +method-rgx=(_?[a-z_][a-z0-9_]{1,40}|__[a-z]+__)$ -# Regular expression which should only match correct argument names -argument-rgx=[a-z_][a-z0-9_]{2,30}$ +# Attributes, arguments, variables: snake_case +attr-rgx=[a-z_][a-z0-9_]{1,40}$ +argument-rgx=[a-z_][a-z0-9_]{1,40}$ +variable-rgx=[a-z_][a-z0-9_]{1,40}$ -# Regular expression which should only match correct variable names -variable-rgx=[a-z_][a-z0-9_]{2,30}$ - -# Regular expression which should only match correct list comprehension / -# generator expression variable names -inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ - -# Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_ - -# Bad variable names which should always be refused, separated by a comma -bad-names=foo,bar,baz,toto,tutu,tata - -# Regular expression which should only match functions or classes name which do -# not require a docstring -no-docstring-rgx=__.*__ +# Short names allowed in loops and comprehensions +good-names=i,j,k,v,ex,Run,_,f,n,x,y,z +no-docstring-rgx=^_(?!_) [DESIGN] - -# Maximum number of arguments for function / method -max-args=5 - -# Argument names that match this expression will be ignored. Default to name -# with leading underscore -ignored-argument-names=_.* - -# Maximum number of locals for function / method body -max-locals=15 - -# Maximum number of return / yield for function / method body -max-returns=6 - -# Maximum number of branch for function / method body -max-branchs=12 - -# Maximum number of statements in function / method body -max-statements=50 - -# Maximum number of parents for a class (see R0901). +max-args=8 +max-locals=20 +max-returns=8 +max-branches=15 +max-statements=60 max-parents=7 +max-attributes=15 +min-public-methods=1 +max-public-methods=30 -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - - -[CLASSES] - -# List of interface methods to ignore, separated by a comma. This is used for -# instance to not check methods defines in Zope's Interface base class. -ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by +[VARIABLES] +init-import=no +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+$)|dummy|^ignored_|^unused_ -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__,__new__,setUp +[TYPECHECK] +ignore-mixin-members=yes +ignored-classes= -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls +[SIMILARITIES] +min-similarity-lines=6 +ignore-comments=yes +ignore-docstrings=yes +ignore-imports=yes +[MISCELLANEOUS] +notes=FIXME,XXX,TODO [IMPORTS] - -# Deprecated modules which should not be used, separated by a comma -deprecated-modules=regsub,string,TERMIOS,Bastion,rexec - -# Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled) -import-graph= - -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled) -ext-import-graph= - -# Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled) -int-import-graph= - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when being caught. Defaults to -# "Exception" -overgeneral-exceptions=Exception +allow-reexport-from-package=yes diff --git a/CHANGELOG.md b/CHANGELOG.md index a6c1e2f..ed6d128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,89 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). + +## [0.3.0] - 2026-05-07 + +### Breaking changes +- All asimmodules: `calc_id: str` parameter replaced by `calculator: Dict` with + keys `calc_id` (reference into `calc_input.yaml`) or `calc_params` (inline + calculator specification). Mirrors the existing `image`/`get_atoms` pattern. +- `ase_md.ase_md`: `calc_spec` parameter renamed to `calculator` +- All YAML example files updated to use the new `calculator:` interface + +### Added +- `dry_run` option in `sim_input.yaml`: writes input files to the work directory + without submitting the job. Unlike `submit: false`, the flag is stripped from + the written `sim_input.yaml` so subsequent runs execute normally without + manual cleanup. +- `calc_array`: new `calculators` and `template_calculator` parameters accepting + dicts instead of plain strings. Old `calc_ids` / `template_calc_id` string + parameters are retained for backward compatibility and auto-converted. + +### Changed +- `job.py`: type annotations modernized to use built-in `dict/list/tuple` and + `X | Y` union syntax (Python 3.10+) throughout, removing most `typing` imports + +## [0.2.0] - 2026-04-06 + +### Breaking changes +- `change_dict_value`, `change_dict_values`, `expand_wildcards`: parameter `d` renamed to `dct` +- `get_str_btn`, `get_nth_label`: parameter `s` renamed to `string` +- `repeat_to_N` renamed to `repeat_to_n` +- All call sites in `asimmodules/workflows/` updated to use the new parameter names + +### Added +- Pre-commit hooks: pylint (10.00/10 on `src/asimtools/` excluding asimmodules) and pytest run on every commit +- Tests for previously untested utilities: `strip_symbols`, `get_axis_lims`, `improve_plot`, `write_csv_from_dict`, `new_db`, `check_if_slurm_job_is_running` +- Tests for previously untested job functionality: `Job` getters/updaters, `DistributedJob` init and inline submit, `ChainedJob` init and last output, `load_job_from_directory`, `get_subjobs`, `load_job_tree` +- Sphinx docs now include all previously missing asimmodule packages: `active_learning`, `ase_md`, `data`, `mace`, `phonopy`, `vasp` +- Workflows documentation page with parameter tables and YAML examples for all 7 workflow modules: `sim_array`, `image_array`, `calc_array`, `distributed`, `chained`, `iterative`, `update_dependencies` +- `autodoc_mock_imports` in Sphinx config for optional heavy dependencies (`maml`, `mace`, `matgl`, `chgnet`, `phonopy`, `seekpath`) + +### Changed +- Package moved to `src/` layout; `pyproject.toml` and pytest config updated accordingly +- Sphinx docs home page is now the project README +- `get_sim_input`, `get_calc_input`, `get_env_input` now return internal references instead of deepcopies +- `Job.__init__` still deepcopies inputs at construction time to preserve isolation from callers + +### Fixed +- `asim_run.py`: precommands were executed twice (once discarding output, once capturing); now runs once +- `asim_execute.py`: `calc_input` was assigned twice before the conditional read +- `job.py` `start()` and `complete()`: two sequential `update_output` calls (2 reads + 2 writes) collapsed into one +- `asim_check.py`: each job tree node read `output.yaml` twice per print call; now reads once per node + +### Performance +- `get_status()`: running-job status update no longer triggers a redundant `output.yaml` re-read +- `DistributedJob.__init__`: `use_slurm`/`use_sh` classification reduced from 3 O(n) passes to a single pass with early exit +- `load_job_from_directory`: replaced `exists()` + `read_yaml` (2 filesystem calls) with try/except around `read_yaml` (1 call) + +## [develop] - 2025-2-14 +### Added +- Can now specify an integer N to labels keyword add will use the N-th label for array workflows +- Can now use placehodlers in sim_array where the array_values replace part of +the string at the given key_sequence +- Can now specify whether to write velocities in lammps +- Calculator: Now added DFTD3 calculator using the ASE interface, works with any calculator. Two things of note: 1. You need to install DFTD3 from +https://www.chemie.uni-bonn.de/grimme/de/software/dft-d3/get_dft-d3. 2. Some +calculators which return a 3x3 matrix for stress will break. One can modify +ASE source for this as ASIMTools can't go into the calculator code. +- asim_check now also reports the job_ids +- get_atoms now allows addition of FixAtoms constraint +- output.yaml now includes hostname + +### Changed +- VASP interface changed to align more with pymatgen +- asimtools.utils.write_yaml now stops sorting keys to help with readability of +written yamls +- write_atoms now more universally used and recommended in asimmodules +- VASP calculation now fails if the max number of iterations is reached for ibrion=1,2,3 + +### Fixed +- Minor bugs in geometry optimizations +- Updated EspressoProfile calculator to match ASE 3.25.0b1 +- FixSymmetry now imported from ase.constraints + ## [0.1.0] - 2024-12-27 ### Added diff --git a/asimtools/__init__.py b/asimtools/__init__.py deleted file mode 100644 index 8dee4bf..0000000 --- a/asimtools/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from ._version import __version__ diff --git a/asimtools/_version.py b/asimtools/_version.py deleted file mode 100644 index 9167125..0000000 --- a/asimtools/_version.py +++ /dev/null @@ -1,4 +0,0 @@ -# See Python packaging guide -# https://packaging.python.org/guides/single-sourcing-package-version/ - -__version__ = "0.0.2" diff --git a/asimtools/asimmodules/benchmarking/distribution.py b/asimtools/asimmodules/benchmarking/distribution.py deleted file mode 100644 index c02ea97..0000000 --- a/asimtools/asimmodules/benchmarking/distribution.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python -''' -Plots the distribution of various properties in a dataset of structures - -Author: mkphuthi@github.com - -''' -from typing import Dict, Optional -import numpy as np -import matplotlib.pyplot as plt -from ase.units import kg, m as meters -from asimtools.utils import ( - get_images, -) - -cm3 = (meters * 100)**3 - -def distribution( - images: Dict, - unit: str = 'eV', - bins: int = 50, - log: bool = True, - remap_keys: Optional[Dict] = None, - skip_failed: bool = False, -) -> Dict: - if remap_keys is None: - remap_keys = {} - unit_factors = {'meV': 1000, 'eV': 1, 'kcal/mol': 23.0621} - unit_factor = unit_factors[unit] - - unit_dict = { - 'energy': f'{unit}/atom', - 'forces': f'{unit}'+r'/$\AA$', - 'stress': f'{unit}'+r'/$\AA^3$', - 'volume': r'$\AA^3$/atom', - 'pressure': f'{unit}'+r'/$\AA^3$', - 'enthalpy': f'{unit}/atom', - 'density': f'g/cm^3', - 'mass': 'amu', - 'natoms': 'atoms', - } - images = get_images(**images) - results = {prop: [] for prop in unit_dict} - for i, atoms in enumerate(images): - include = True - results['natoms'].append(len(atoms)) - if remap_keys.get('energy', False): - energy = atoms.info[remap_keys['energy']] - else: - energy = atoms.get_potential_energy() - results['energy'].append(energy) - if remap_keys.get('forces', False): - forces = atoms.arrays[remap_keys['forces']] - else: - forces = atoms.get_forces() - results['forces'].extend( - list(np.array(forces).flatten()) - ) - results['volume'].append(atoms.get_volume()) - if remap_keys.get('stress', False): - stress = atoms.arrays[remap_keys['stress']] - elif remap_keys.get('virial', False): - try: - stress = atoms.info[remap_keys['virial']] / atoms.get_volume() - except KeyError: - print('idx:', i, atoms.info, atoms.arrays) - else: - stress = atoms.get_stress(voigt=True) - results['stress'].extend( - list(np.array(stress)) * unit_factor - ) - results['pressure'].append(-np.sum(stress[:3])/3) - mass = np.sum(atoms.get_masses()) - results['mass'].append(mass) - - for prop in unit_dict: - results[prop] = np.array(results[prop]) - - results['density'] = ( - (results['mass'] * kg * 1000) / (results['volume'] / cm3) - ) - results['enthalpy'] = ( - results['energy'] + results['pressure'] * results['volume'] - ) - for prop in ['energy', 'volume', 'enthalpy']: - results[prop] = results[prop] / results['natoms'] - - for prop in ['forces', 'stress', 'pressure', 'energy', 'enthalpy']: - results[prop] = results[prop] * unit_factor - - for prop in unit_dict: - with open(f'summary.txt', 'a+') as f: - f.write(f'{prop} distribution\n') - f.write(f'Num. values: {len(results[prop])}\n') - f.write(f'Mean: {np.mean(results[prop])} {unit_dict[prop]}\n') - f.write(f'Std: {np.std(results[prop])} {unit_dict[prop]}\n') - f.write(f'Min: {np.min(results[prop])} {unit_dict[prop]}\n') - f.write(f'Max: {np.max(results[prop])} {unit_dict[prop]}\n') - f.write('+++++++++++++++++++++++++++++++++++++++++++++++\n') - fig = plt.figure() - plt.hist(results[prop], bins=bins, density=True) - plt.xlabel(f'{prop} [{unit_dict[prop]}]') - plt.ylabel('Frequency') - if log: - plt.yscale('log') - plt.title(f'{prop} distribution') - plt.savefig(f'{prop}_distribution.png') - plt.close(fig) - return {} diff --git a/asimtools/asimmodules/vasp/vasp.py b/asimtools/asimmodules/vasp/vasp.py deleted file mode 100755 index 3bf1346..0000000 --- a/asimtools/asimmodules/vasp/vasp.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python -''' -Runs VASP based on input files and optionally MP settings. -Heavily uses pymatgen for IO and MP settings. -VASP must be installed - -Author: mkphuthi@github.com -''' -from typing import Dict, Optional, Sequence -import os -import sys -from pathlib import Path -from numpy.random import randint -import subprocess -import logging -from ase.io import read -from pymatgen.io.ase import AseAtomsAdaptor -from pymatgen.io.vasp import Poscar -from pymatgen.io.vasp.sets import ( - MPRelaxSet, MPStaticSet, MPNonSCFSet, MPScanRelaxSet -) -from asimtools.utils import ( - get_atoms, -) - -def vasp( - image: Optional[Dict], - vaspinput_args: Optional[Dict] = None, - command: str = 'vasp_std', - mpset: Optional[str] = None, - write_image_output: bool = True, -) -> Dict: - """Run VASP with given input files and specified image - - :param image: Initial image for VASP calculation. Image specification, - see :func:`asimtools.utils.get_atoms` - :type image: Dict - :param vaspinput_args: Dictionary of pymatgen's VASPInput arguments. - See :class:`pymatgen.io.vasp.inputs.VaspInput` - :type vaspinput_args: Dict - :param command: Command with which to run VASP, defaults to 'vasp_std' - :type command: str, optional - :param mpset: Materials Project VASP set to use, defaults to None - :type mpset: str, optional - :param write_image_output: Whether to write output image in standard - asimtools format to file, defaults to False - :type write_image_output: bool, optional - """ - - if vaspinput_args: - if image is not None: - atoms = get_atoms(**image) - struct = AseAtomsAdapter.get_structure(atoms) - vaspinput = VaspInput( - poscar=Poscar(struct), - **vaspinput_args - ) - else: - atoms = get_atoms(**image) - struct = AseAtomsAdaptor.get_structure(atoms) - if mpset == 'MPRelaxSet': - vasp_input = MPRelaxSet(struct) - elif mpset == 'MPStaticSet': - vasp_input = MPStaticSet(struct) - elif mpset == 'MPNonSCFSet': - vasp_input = MPNonSCFSet(struct) - elif mpset == 'MPScanRelaxSet': - vasp_input = MPScanRelaxSet(struct) - else: - raise ValueError(f'Unknown MPSet: {mpset}') - - vasp_input.write_input("./") - - command = command.split(' ') - completed_process = subprocess.run( - command, check=False, capture_output=True, text=True, - ) - - with open('vasp_stdout.txt', 'a+', encoding='utf-8') as f: - f.write(completed_process.stdout) - - if completed_process.returncode != 0: - err_txt = f'Failed to run VASP\n' - err_txt += 'See vasp_stderr.txt for details.' - logging.error(err_txt) - with open('vasp_stderr.txt', 'a+', encoding='utf-8') as f: - f.write(completed_process.stderr) - completed_process.check_returncode() - return {} - - if write_image_output: - atoms_output = read('OUTCAR') - atoms_output.write( - 'image_output.xyz', - format='extxyz', - ) - - return {} diff --git a/docs/asimtools.asimmodules.active_learning.rst b/docs/asimtools.asimmodules.active_learning.rst new file mode 100644 index 0000000..3cca83c --- /dev/null +++ b/docs/asimtools.asimmodules.active_learning.rst @@ -0,0 +1,42 @@ +asimtools.asimmodules.active\_learning package +============================================== + +asimtools.asimmodules.active\_learning.ase\_md module +----------------------------------------------------- + +.. automodule:: asimtools.asimmodules.active_learning.ase_md + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.active\_learning.compute\_deviation module +---------------------------------------------------------------- + +.. automodule:: asimtools.asimmodules.active_learning.compute_deviation + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.active\_learning.direct\_sample module +------------------------------------------------------------ + +.. automodule:: asimtools.asimmodules.active_learning.direct_sample + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.active\_learning.select\_images module +------------------------------------------------------------ + +.. automodule:: asimtools.asimmodules.active_learning.select_images + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: asimtools.asimmodules.active_learning + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/asimtools.asimmodules.ase_md.rst b/docs/asimtools.asimmodules.ase_md.rst new file mode 100644 index 0000000..a743975 --- /dev/null +++ b/docs/asimtools.asimmodules.ase_md.rst @@ -0,0 +1,18 @@ +asimtools.asimmodules.ase\_md package +===================================== + +asimtools.asimmodules.ase\_md.ase\_md module +-------------------------------------------- + +.. automodule:: asimtools.asimmodules.ase_md.ase_md + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: asimtools.asimmodules.ase_md + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/asimtools.asimmodules.data.rst b/docs/asimtools.asimmodules.data.rst new file mode 100644 index 0000000..b825555 --- /dev/null +++ b/docs/asimtools.asimmodules.data.rst @@ -0,0 +1,18 @@ +asimtools.asimmodules.data package +================================== + +asimtools.asimmodules.data.collect\_images module +------------------------------------------------- + +.. automodule:: asimtools.asimmodules.data.collect_images + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: asimtools.asimmodules.data + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/asimtools.asimmodules.mace.rst b/docs/asimtools.asimmodules.mace.rst new file mode 100644 index 0000000..9fda97b --- /dev/null +++ b/docs/asimtools.asimmodules.mace.rst @@ -0,0 +1,18 @@ +asimtools.asimmodules.mace package +================================== + +asimtools.asimmodules.mace.train\_mace module +--------------------------------------------- + +.. automodule:: asimtools.asimmodules.mace.train_mace + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: asimtools.asimmodules.mace + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/asimtools.asimmodules.phonopy.rst b/docs/asimtools.asimmodules.phonopy.rst new file mode 100644 index 0000000..4007ac3 --- /dev/null +++ b/docs/asimtools.asimmodules.phonopy.rst @@ -0,0 +1,74 @@ +asimtools.asimmodules.phonopy package +===================================== + +asimtools.asimmodules.phonopy.forces module +------------------------------------------- + +.. automodule:: asimtools.asimmodules.phonopy.forces + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.phonopy.full\_qha module +---------------------------------------------- + +.. automodule:: asimtools.asimmodules.phonopy.full_qha + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.phonopy.generate\_phonopy\_displacements module +--------------------------------------------------------------------- + +.. automodule:: asimtools.asimmodules.phonopy.generate_phonopy_displacements + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.phonopy.phonon\_bands\_and\_dos module +------------------------------------------------------------ + +.. automodule:: asimtools.asimmodules.phonopy.phonon_bands_and_dos + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.phonopy.phonon\_bands\_and\_dos\_from\_forces module +-------------------------------------------------------------------------- + +.. automodule:: asimtools.asimmodules.phonopy.phonon_bands_and_dos_from_forces + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.phonopy.qha\_properties module +---------------------------------------------------- + +.. automodule:: asimtools.asimmodules.phonopy.qha_properties + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.phonopy.read\_force\_constants module +----------------------------------------------------------- + +.. automodule:: asimtools.asimmodules.phonopy.read_force_constants + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.phonopy.thermal\_properties module +-------------------------------------------------------- + +.. automodule:: asimtools.asimmodules.phonopy.thermal_properties + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: asimtools.asimmodules.phonopy + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/asimtools.asimmodules.rst b/docs/asimtools.asimmodules.rst index 74e4281..4d6b823 100644 --- a/docs/asimtools.asimmodules.rst +++ b/docs/asimtools.asimmodules.rst @@ -6,15 +6,21 @@ asimtools.asimmodules package .. toctree:: :maxdepth: 4 + asimtools.asimmodules.active_learning + asimtools.asimmodules.ase_md asimtools.asimmodules.benchmarking + asimtools.asimmodules.data asimtools.asimmodules.elastic_constants asimtools.asimmodules.eos asimtools.asimmodules.geometry_optimization asimtools.asimmodules.lammps + asimtools.asimmodules.mace asimtools.asimmodules.phonons + asimtools.asimmodules.phonopy asimtools.asimmodules.surface_energies asimtools.asimmodules.transformations asimtools.asimmodules.vacancy_formation_energy + asimtools.asimmodules.vasp asimtools.asimmodules.workflows @@ -35,13 +41,6 @@ asimtools.asimmodules.singlepoint module :undoc-members: :show-inheritance: -asimtools.asimmodules.template module -------------------------------------- - -.. automodule:: asimtools.asimmodules.template - :members: - :undoc-members: - :show-inheritance: Module contents --------------- diff --git a/docs/asimtools.asimmodules.vasp.rst b/docs/asimtools.asimmodules.vasp.rst new file mode 100644 index 0000000..b3c2b69 --- /dev/null +++ b/docs/asimtools.asimmodules.vasp.rst @@ -0,0 +1,18 @@ +asimtools.asimmodules.vasp package +================================== + +asimtools.asimmodules.vasp.vasp module +-------------------------------------- + +.. automodule:: asimtools.asimmodules.vasp.vasp + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: asimtools.asimmodules.vasp + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/asimtools.asimmodules.workflows.rst b/docs/asimtools.asimmodules.workflows.rst index 137c318..a200d80 100644 --- a/docs/asimtools.asimmodules.workflows.rst +++ b/docs/asimtools.asimmodules.workflows.rst @@ -1,8 +1,6 @@ asimtools.asimmodules.workflows package ======================================= - - asimtools.asimmodules.workflows.calc\_array module -------------------------------------------------- @@ -35,6 +33,14 @@ asimtools.asimmodules.workflows.image\_array module :undoc-members: :show-inheritance: +asimtools.asimmodules.workflows.iterative module +------------------------------------------------ + +.. automodule:: asimtools.asimmodules.workflows.iterative + :members: + :undoc-members: + :show-inheritance: + asimtools.asimmodules.workflows.sim\_array module ------------------------------------------------- @@ -43,6 +49,22 @@ asimtools.asimmodules.workflows.sim\_array module :undoc-members: :show-inheritance: +asimtools.asimmodules.workflows.update\_dependencies module +----------------------------------------------------------- + +.. automodule:: asimtools.asimmodules.workflows.update_dependencies + :members: + :undoc-members: + :show-inheritance: + +asimtools.asimmodules.workflows.utils module +-------------------------------------------- + +.. automodule:: asimtools.asimmodules.workflows.utils + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/docs/conf.py b/docs/conf.py index 553e067..15a6cb0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,7 +12,7 @@ import os import sys -sys.path.insert(0, os.path.abspath('../asimtools')) +sys.path.insert(0, os.path.abspath('../src')) sys.path.insert(0, os.path.abspath('../')) @@ -23,7 +23,7 @@ author = 'Mgcini Keith Phuthi' # The full version, including alpha/beta/rc tags -release = '1.0.0' +release = '0.2.0' # -- General configuration --------------------------------------------------- @@ -48,6 +48,16 @@ # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +# Mock optional heavy dependencies so autodoc can import all modules +autodoc_mock_imports = [ + 'maml', + 'mace', + 'matgl', + 'chgnet', + 'phonopy', + 'seekpath', +] + # -- Options for HTML output ------------------------------------------------- @@ -59,7 +69,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = [] # Intersphinx Mappings intersphinx_mapping = { diff --git a/docs/include_changelog.rst b/docs/include_changelog.rst new file mode 100644 index 0000000..08efb85 --- /dev/null +++ b/docs/include_changelog.rst @@ -0,0 +1,5 @@ +Changelog +========= + +.. include:: ../CHANGELOG.md + :parser: myst_parser.sphinx_ diff --git a/docs/include_contributing.rst b/docs/include_contributing.rst index 78dbb13..ee11058 100644 --- a/docs/include_contributing.rst +++ b/docs/include_contributing.rst @@ -1,5 +1,5 @@ Contributing to ASIMTools ========================= -.. include:: CONTRIBUTING.md +.. include:: ../CONTRIBUTING.md :parser: myst_parser.sphinx_ diff --git a/docs/include_readme.rst b/docs/include_readme.rst index f2253cd..8f9ef40 100644 --- a/docs/include_readme.rst +++ b/docs/include_readme.rst @@ -1,5 +1,5 @@ README ====== -.. include:: README.md +.. include:: ../README.md :parser: myst_parser.sphinx_ diff --git a/docs/index.rst b/docs/index.rst index 4e56338..3cbd526 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,23 +1,17 @@ -.. asimtools documentation master file, created by - sphinx-quickstart on Sat Jul 1 18:22:31 2023. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. +.. asimtools documentation master file -Welcome to asimtools's documentation! -===================================== +.. include:: include_readme.rst .. toctree:: :maxdepth: 12 :caption: Contents: - README Running asimmodules Custom asimmodules Workflows API Docs + Changelog Contributing - - Indices and tables diff --git a/docs/usage.rst b/docs/usage.rst index 6b7a632..f67c1e3 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -250,6 +250,39 @@ and M3GNet force fields are implemented. the arguments are passed as ``calc = LennardJones(**{'sigma':3.2, 'epsilon':3})`` +.. _specifyingcalculators: + +Specifying Calculators +---------------------- + +Asimmodules that require a calculator accept a ``calculator`` dictionary as part +of their ``args`` section. There are two ways to specify a calculator: + +**By reference** — look up a named entry from ``calc_input.yaml``: + +.. code-block:: yaml + + args: + calculator: + calc_id: lj_argon + +**Inline** — provide the full calculator specification directly: + +.. code-block:: yaml + + args: + calculator: + calc_params: + name: MACE + args: + model: medium + use_device: cpu + +The ``calc_id`` form is preferred for production workflows because the same +calculator can be shared across many sim_inputs and updated in one place. The +``calc_params`` form is useful for one-off jobs or when the calculator should +travel with the sim_input (e.g. in active-learning workflows). + .. _siminput: sim_input.yaml @@ -288,7 +321,10 @@ The parameters are: - **submit**: (bool, optional) whether to run the asimmodule. If set to false it will just write the input files which is very useful for testing before submitting large workflows. You can go in and test one example before - resubmitting with ``submit=True``, defaults to true + resubmitting with ``submit=True``, defaults to true +- **dry_run**: (bool, optional) like ``submit: false`` but the flag is stripped + from the written ``sim_input.yaml``, so the next ``asim-execute`` call runs + normally without any manual cleanup, defaults to false - **workdir**: (str, optional) The directory in which the asimmodule will be run, `asim-execute` will create the directory whereas `asim-run` ignores this parameter entirely, defaults to './results' @@ -511,17 +547,25 @@ import them and use them in any other code for example, you can import :func:`asimtools.asimmodules.singlepoint` and use it as below. .. code-block:: python - + from asimtools.asimmodules.singlepoint import singlepoint - results = singlepoint(image={'name': 'Ar'}, calc_id='lj') + results = singlepoint( + image={'name': 'Ar'}, + calculator={'calc_id': 'lj'}, + ) print(results) -You can also use the utils and tools e.g. to load a calculator using just a -``calc_id`` +You can also use the utils and tools e.g. to load a calculator: .. code-block:: python from asimtools.calculators import load_calc - calc = load_calc('lj') + # By reference to calc_input.yaml + calc = load_calc(calculator={'calc_id': 'lj'}) + + # Inline specification + calc = load_calc(calculator={'calc_params': {'name': 'LennardJones', + 'module': 'ase.calculators.lj', + 'args': {'sigma': 3.54}}}) diff --git a/docs/workflows.rst b/docs/workflows.rst index 4867ded..8896d02 100644 --- a/docs/workflows.rst +++ b/docs/workflows.rst @@ -1,33 +1,559 @@ Using built-in workflow tools ============================= -ASIMTools offers some standard tools for performing common workflows. These -are: +ASIMTools provides six built-in workflow asimmodules for the most common +simulation patterns. They are all wrappers around the +:class:`~asimtools.job.DistributedJob` and :class:`~asimtools.job.ChainedJob` +classes and can be nested arbitrarily. -#. :func:`asimtools.asimmodules.sim_array.sim_array` - Run the same asimmodule - with one or more specified arguments of the asimmodule iterated over. This - is the most useful asimmodule and can substitute most others except - ``chained`` +.. contents:: Workflows + :local: + :depth: 1 -#. :func:`asimtools.asimmodules.image_array.image_array` - Run the same - asimmodule on multiple images, e.g. a repeat calculation on a database +---- -#. :func:`asimtools.asimmodules.calc_array.calc_array` - Run the same - asimmodule using different calc_ids or calculator parameters based on a - template. e.g. to converge cutoffs in DFT or benchmark many force fields +sim\_array +---------- -#. :func:`asimtools.asimmodules.distributed.distributed` - Run multiple - sim_inputs in parallel +**Module:** :func:`asimtools.asimmodules.workflows.sim_array.sim_array` -#. :func:`asimtools.asimmodules.chained.chained` - Run asimmodules one after - the other, e.g. if step 2 results depend on step 1 etc. This allows building - multi-step workflows. +Run the **same asimmodule in parallel** over a sweep of values for a single +argument (or a paired set of arguments). This is the most generally useful +workflow and covers parameter sweeps, convergence tests, and ensemble runs. -#. :func:`asimtools.asimmodules.iterative.iterative` - Run the same asimmodule - over and over until some condition is reached. This asimmodule is still - under active development +When to use +~~~~~~~~~~~ -Examples for each type of workflow are given in the examples directory and -documentation can be found in :mod:`asimtools.asimmodules`. They also serve as -templates for you to build your own workflows directly using -:func:`asimtools.job.Job` objects as an advanced user. +- Sweep a lattice constant, cutoff energy, temperature, etc. +- Vary an integer index (e.g. run the same relaxation on structures 0-99). +- Sweep multiple parameters simultaneously (``secondary_key_sequences``). + +Key parameters +~~~~~~~~~~~~~~ + ++-----------------------------+--------------------------------------------------+ +| Parameter | Description | ++=============================+==================================================+ +| ``template_sim_input`` | Base sim_input copied for every job. | ++-----------------------------+--------------------------------------------------+ +| ``key_sequence`` | Dot-path into the nested dict being swept, | +| | e.g. ``[args, a]`` to change ``args.a``. | ++-----------------------------+--------------------------------------------------+ +| ``array_values`` | Explicit list of values. | ++-----------------------------+--------------------------------------------------+ +| ``linspace_args`` | ``[start, stop, n]`` — passed to | +| | :func:`numpy.linspace`. | ++-----------------------------+--------------------------------------------------+ +| ``arange_args`` | ``[start, stop, step]`` — passed to | +| | :func:`numpy.arange`. | ++-----------------------------+--------------------------------------------------+ +| ``file_pattern`` | Glob pattern; files matching it become values. | ++-----------------------------+--------------------------------------------------+ +| ``labels`` | ``"values"`` (default) uses the value itself; | +| | ``"files"`` extracts a label from the file path; | +| | a list provides explicit directory names. | ++-----------------------------+--------------------------------------------------+ +| ``placeholder`` | Replace this sentinel string inside the value | +| | rather than the whole value. | ++-----------------------------+--------------------------------------------------+ +| ``secondary_key_sequences`` | Additional keys to sweep in lock-step. | ++-----------------------------+--------------------------------------------------+ +| ``group_size`` | Pack N jobs into each Slurm array task. | ++-----------------------------+--------------------------------------------------+ + +Example — sweep lattice constants +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + # sim_input.yaml + asimmodule: workflows.sim_array + env_id: batch + workdir: results/lc_sweep + args: + key_sequence: [args, image, a] + linspace_args: [3.4, 4.2, 9] # 9 values from 3.4 to 4.2 Å + template_sim_input: + asimmodule: singlepoint + env_id: batch + args: + calculator: + calc_id: my_mlip + image: + name: Cu + crystalstructure: fcc + a: PLACEHOLDER + properties: [energy, forces, stress] + +Example — sweep over a set of structure files +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + asimmodule: workflows.sim_array + env_id: batch + workdir: results/structure_sweep + args: + key_sequence: [args, image, image_file] + file_pattern: data/structures/*.xyz + labels: files # label taken from the filename + template_sim_input: + asimmodule: geometry_optimization.cell_relax + env_id: batch + args: + calculator: + calc_id: my_mlip + image: + image_file: PLACEHOLDER + +Example — sweep two parameters simultaneously +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + asimmodule: workflows.sim_array + env_id: batch + workdir: results/dual_sweep + args: + key_sequence: [args, temperature] + array_values: [300, 600, 900, 1200] + secondary_key_sequences: + - [args, pressure] + secondary_array_values: + - [1.0, 2.0, 3.0, 4.0] + template_sim_input: + asimmodule: ase_md.ase_md + env_id: batch + args: + calculator: + calc_id: my_mlip + image: {name: Fe} + temperature: 300 + pressure: 1.0 + +---- + +image\_array +------------ + +**Module:** :func:`asimtools.asimmodules.workflows.image_array.image_array` + +Run the **same asimmodule in parallel on multiple structures**. The image +input specification for each job is taken from +:func:`asimtools.utils.get_images`, so any combination of file patterns, +explicit lists, or ASE database queries is supported. + +When to use +~~~~~~~~~~~ + +- Relaxation or single-point calculation across a database of structures. +- Benchmark a force field against a set of reference structures. +- Compute phonons for every structure in a dataset. + +Key parameters +~~~~~~~~~~~~~~ + ++-----------------------------+---------------------------------------------------+ +| Parameter | Description | ++=============================+===================================================+ +| ``images`` | Dict for :func:`~asimtools.utils.get_images`; | +| | can use ``image_file``, ``pattern``, | +| | ``patterns``, or ``images``. | ++-----------------------------+---------------------------------------------------+ +| ``template_sim_input`` | Base sim_input; image key is injected per job. | ++-----------------------------+---------------------------------------------------+ +| ``key_sequence`` | Where in ``template_sim_input`` to inject the | +| | image path (default ``[args, image, | +| | image_file]``). | ++-----------------------------+---------------------------------------------------+ +| ``labels`` | Source of directory name for each job. | ++-----------------------------+---------------------------------------------------+ +| ``env_ids`` | Per-image environments (or a single string). | ++-----------------------------+---------------------------------------------------+ + +Example — single-point energy on a dataset +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + asimmodule: workflows.image_array + env_id: batch + workdir: results/dataset_sp + args: + images: + pattern: data/structures/*.xyz + template_sim_input: + asimmodule: singlepoint + env_id: batch + args: + calculator: + calc_id: my_mlip + properties: [energy, forces, stress] + labels: files + +Example — relax all structures in an xyz file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + asimmodule: workflows.image_array + env_id: batch + workdir: results/relax_all + args: + images: + image_file: data/structures.xyz + template_sim_input: + asimmodule: geometry_optimization.atom_relax + env_id: batch + args: + calculator: + calc_id: my_mlip + +---- + +calc\_array +----------- + +**Module:** :func:`asimtools.asimmodules.workflows.calc_array.calc_array` + +Run the **same asimmodule in parallel with different calculators** (or +different calculator hyperparameters). Useful for benchmarking, convergence +studies, or comparing multiple potentials. + +When to use +~~~~~~~~~~~ + +- Compare DFT, ML potentials, and empirical force fields on the same system. +- Converge a DFT parameter (cutoff energy, k-point density) across many values. +- Benchmark a new potential against a reference. + +Key parameters +~~~~~~~~~~~~~~ + ++-----------------------------+--------------------------------------------------+ +| Parameter | Description | ++=============================+==================================================+ +| ``subsim_input`` | The sim_input for the asimmodule to run. | ++-----------------------------+--------------------------------------------------+ +| ``calculators`` | Explicit list of calculator dicts, each with | +| | a ``calc_id`` or ``calc_params`` key. | ++-----------------------------+--------------------------------------------------+ +| ``template_calculator`` | Base calculator dict; ``key_sequence`` is swept | +| | inside its parameters. | ++-----------------------------+--------------------------------------------------+ +| ``key_sequence`` | Path into the calculator dict to sweep. | ++-----------------------------+--------------------------------------------------+ +| ``array_values`` / | Values to sweep (same semantics as | +| ``linspace_args`` / | ``sim_array``). | +| ``arange_args`` | | ++-----------------------------+--------------------------------------------------+ +| ``calc_ids`` | *Deprecated* — list of calc_id strings. | +| | Auto-converted to ``calculators``. | ++-----------------------------+--------------------------------------------------+ +| ``template_calc_id`` | *Deprecated* — base calc_id string. | +| | Auto-converted to ``template_calculator``. | ++-----------------------------+--------------------------------------------------+ + +Example — benchmark multiple calculators +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + asimmodule: workflows.calc_array + env_id: batch + workdir: results/calc_benchmark + args: + calculators: + - calc_id: emt + - calc_id: lj_argon + - calc_id: mace_mp + subsim_input: + asimmodule: singlepoint + env_id: batch + args: + image: {name: Ar} + properties: [energy, forces] + +Example — converge DFT cutoff energy +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + asimmodule: workflows.calc_array + env_id: batch + workdir: results/ecut_convergence + args: + template_calculator: + calc_id: vasp_base + key_sequence: [args, encut] + linspace_args: [200, 600, 9] # 200, 250, …, 600 eV + subsim_input: + asimmodule: singlepoint + env_id: batch + args: + image: {name: Fe} + properties: [energy] + +---- + +distributed +----------- + +**Module:** :func:`asimtools.asimmodules.workflows.distributed.distributed` + +Submit an **arbitrary collection of heterogeneous sim_inputs in parallel**. +Unlike ``sim_array`` / ``image_array`` / ``calc_array``, each sub-job can be +a completely different asimmodule with different parameters. + +When to use +~~~~~~~~~~~ + +- Run a heterogeneous set of jobs that do not share a common template. +- Fan out to many independent calculations and collect results later. +- Use as the parallel layer in a custom workflow script. + +Key parameters +~~~~~~~~~~~~~~ + ++-----------------------------+--------------------------------------------------+ +| Parameter | Description | ++=============================+==================================================+ +| ``subsim_inputs`` | Dict mapping job IDs to individual sim_inputs. | ++-----------------------------+--------------------------------------------------+ +| ``array_max`` | Max concurrent jobs in Slurm array. | ++-----------------------------+--------------------------------------------------+ +| ``group_size`` | Pack N jobs per Slurm array task. | ++-----------------------------+--------------------------------------------------+ +| ``skip_failed`` | Continue even if some sub-jobs fail. | ++-----------------------------+--------------------------------------------------+ + +Example +~~~~~~~ + +.. code-block:: yaml + + asimmodule: workflows.distributed + env_id: batch + workdir: results/mixed_jobs + args: + subsim_inputs: + eos_fe: + asimmodule: eos.postprocess + env_id: batch + args: + image: {name: Fe} + calculator: + calc_id: my_mlip + relax_cu: + asimmodule: geometry_optimization.cell_relax + env_id: batch + args: + image: {name: Cu} + calculator: + calc_id: my_mlip + sp_ar: + asimmodule: singlepoint + env_id: batch + args: + image: {name: Ar} + calculator: + calc_id: emt + properties: [energy] + +---- + +chained +------- + +**Module:** :func:`asimtools.asimmodules.workflows.chained.chained` + +Run asimmodules **one after the other**, where each step can depend on files +produced by the previous step. When Slurm is used, job dependencies are set +automatically via ``sbatch --dependency``. + +When to use +~~~~~~~~~~~ + +- Multi-step workflows: relax → single-point → post-process. +- Any workflow where step N reads output files from step N-1. +- Chain a ``sim_array`` with a post-processing script. + +Key parameters +~~~~~~~~~~~~~~ + ++-----------------------------+--------------------------------------------------+ +| Parameter | Description | ++=============================+==================================================+ +| ``steps`` | Ordered dict (``step-0``, ``step-1``, ...) of | +| | sim_inputs to execute in sequence. | ++-----------------------------+--------------------------------------------------+ + +.. note:: + + Keys must follow the pattern ``step-N`` (zero-indexed integers). If a step + internally launches additional Slurm jobs (e.g. via ``sim_array``), use + ``update_dependencies`` as an intermediate step to wire up the Slurm + dependency chain correctly. + +Example — relax then compute phonons +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + asimmodule: workflows.chained + env_id: batch + workdir: results/relax_then_phonons + args: + steps: + step-0: + asimmodule: geometry_optimization.cell_relax + env_id: batch + args: + calculator: + calc_id: my_mlip + image: {name: Cu} + step-1: + asimmodule: phonons.ase_phonons + env_id: batch + args: + calculator: + calc_id: my_mlip + image: + image_file: ../step-0/final.xyz + +Example — three-step pipeline with a parallel middle step +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + asimmodule: workflows.chained + env_id: batch + workdir: results/pipeline + args: + steps: + step-0: + asimmodule: geometry_optimization.cell_relax + env_id: batch + args: + calculator: + calc_id: my_mlip + image: {name: Al} + step-1: + asimmodule: workflows.sim_array + env_id: batch + args: + key_sequence: [args, image, a] + linspace_args: [3.9, 4.1, 5] + template_sim_input: + asimmodule: singlepoint + env_id: batch + args: + calculator: + calc_id: my_mlip + image: + name: Al + a: PLACEHOLDER + step-2: + asimmodule: eos.postprocess + env_id: batch + args: + workdir: ../step-1 + +---- + +iterative +--------- + +**Module:** :func:`asimtools.asimmodules.workflows.iterative.iterative` + +Run the **same asimmodule sequentially** over a sweep of values where each +job must complete before the next begins. Unlike ``sim_array``, jobs run +one at a time. Optionally, a file produced by each step can be fed as input +to the next (``dependent_file``). + +When to use +~~~~~~~~~~~ + +- Each step's output is input to the next (e.g. active-learning loops). +- A sequential sweep where order matters. +- Iterative fitting or refinement workflows. + +Key parameters +~~~~~~~~~~~~~~ + ++-----------------------------------+----------------------------------------------+ +| Parameter | Description | ++===================================+==============================================+ +| ``template_sim_input`` | Base sim_input for each step. | ++-----------------------------------+----------------------------------------------+ +| ``key_sequence`` | Key path swept across steps. | ++-----------------------------------+----------------------------------------------+ +| ``array_values`` / ``linspace_args`` | Values to iterate over. | +| / ``arange_args`` | | ++-----------------------------------+----------------------------------------------+ +| ``dependent_file`` | Filename (relative to each step's workdir) | +| | to pass as input to the next step. | ++-----------------------------------+----------------------------------------------+ +| ``dependent_file_key_sequence`` | Key path in the next step's sim_input where | +| | the file path is injected. | ++-----------------------------------+----------------------------------------------+ + +Example — sequential geometry relaxations at increasing pressures +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: yaml + + asimmodule: workflows.iterative + env_id: batch + workdir: results/pressure_series + args: + key_sequence: [args, pressure] + array_values: [0, 5, 10, 20, 50] + dependent_file: final.xyz + dependent_file_key_sequence: [args, image, image_file] + template_sim_input: + asimmodule: geometry_optimization.cell_relax + env_id: batch + args: + calculator: + calc_id: my_mlip + image: {name: Fe} + pressure: 0 + +---- + +update\_dependencies +-------------------- + +**Module:** +:func:`asimtools.asimmodules.workflows.update_dependencies.update_dependencies` + +An **internal helper** used by ``chained`` to wire up Slurm job dependencies +when one step internally launches additional Slurm jobs (e.g. via +``sim_array``). It reads the job IDs written by the previous step and calls +``scontrol update`` to add the correct ``afterok`` dependency on the next +step's jobs. + +Most users will not need to call this directly; it is inserted automatically +by ``chained`` when needed. It has no effect outside of a Slurm environment. + +.. code-block:: yaml + + # Example use inside a chained workflow (advanced) + step-1: + asimmodule: workflows.update_dependencies + env_id: batch + args: + prev_step_dir: ../step-0 + next_step_dir: ../step-2 + skip_failed: false + +---- + +API reference +------------- + +.. toctree:: + :maxdepth: 1 + + asimtools.asimmodules.workflows diff --git a/examples/external/CHGNet/atom_relax_sim_input.yaml b/examples/external/CHGNet/atom_relax_sim_input.yaml index 54b0601..e85cf6f 100644 --- a/examples/external/CHGNet/atom_relax_sim_input.yaml +++ b/examples/external/CHGNet/atom_relax_sim_input.yaml @@ -2,7 +2,8 @@ asimmodule: geometry_optimization.atom_relax env_id: workdir: relax_results args: - calc_id: CHGNet + calculator: + calc_id: CHGNet image: name: NaCl builder: bulk diff --git a/examples/external/CHGNet/relax_results/sim_input.yaml b/examples/external/CHGNet/relax_results/sim_input.yaml index 0a7a0e6..ecd04cf 100644 --- a/examples/external/CHGNet/relax_results/sim_input.yaml +++ b/examples/external/CHGNet/relax_results/sim_input.yaml @@ -1,5 +1,6 @@ args: - calc_id: CHGNet + calculator: + calc_id: CHGNet fmax: 0.01 image: image_file: image_input.xyz diff --git a/examples/external/GPAW/singlepoint_sim_input.yaml b/examples/external/GPAW/singlepoint_sim_input.yaml index 5526cc9..27df8f2 100644 --- a/examples/external/GPAW/singlepoint_sim_input.yaml +++ b/examples/external/GPAW/singlepoint_sim_input.yaml @@ -3,7 +3,8 @@ env_id: batch workdir: gpaw_results overwrite: true args: - calc_id: gpaw + calculator: + calc_id: gpaw image: name: Ar builder: bulk diff --git a/examples/external/M3GNEt/atom_relax_sim_input.yaml b/examples/external/M3GNEt/atom_relax_sim_input.yaml index d8b2959..1e17f35 100644 --- a/examples/external/M3GNEt/atom_relax_sim_input.yaml +++ b/examples/external/M3GNEt/atom_relax_sim_input.yaml @@ -2,7 +2,8 @@ asimmodule: geometry_optimization.atom_relax env_id: workdir: relax_results args: - calc_id: M3GNet + calculator: + calc_id: M3GNet image: name: NaCl builder: bulk diff --git a/examples/external/M3GNEt/relax_results/sim_input.yaml b/examples/external/M3GNEt/relax_results/sim_input.yaml index e05508a..5c467cf 100644 --- a/examples/external/M3GNEt/relax_results/sim_input.yaml +++ b/examples/external/M3GNEt/relax_results/sim_input.yaml @@ -1,5 +1,6 @@ args: - calc_id: M3GNet + calculator: + calc_id: M3GNet fmax: 0.01 image: image_file: image_input.xyz diff --git a/examples/external/MACE/atom_relax_sim_input.yaml b/examples/external/MACE/atom_relax_sim_input.yaml index e5125a4..2bee1bd 100644 --- a/examples/external/MACE/atom_relax_sim_input.yaml +++ b/examples/external/MACE/atom_relax_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: geometry_optimization.atom_relax workdir: nacl_results args: - calc_id: MACE_cpu + calculator: + calc_id: MACE_cpu image: name: NaCl builder: bulk diff --git a/examples/external/MACE/full_qha_sim_input.yaml b/examples/external/MACE/full_qha_sim_input.yaml index 532ddbe..428c085 100644 --- a/examples/external/MACE/full_qha_sim_input.yaml +++ b/examples/external/MACE/full_qha_sim_input.yaml @@ -6,7 +6,8 @@ args: name: NaCl crystalstructure: rocksalt a: 5.641 - calc_id: MACE64_cpu + calculator: + calc_id: MACE64_cpu calc_env_id: inline process_env_id: inline phonopy_save_path: phonopy_save.yaml # Specify path relative to this asimmodule's workdir diff --git a/examples/external/MACE/mace-off_atom_relax_sim_input.yaml b/examples/external/MACE/mace-off_atom_relax_sim_input.yaml index f14cbda..314e29c 100644 --- a/examples/external/MACE/mace-off_atom_relax_sim_input.yaml +++ b/examples/external/MACE/mace-off_atom_relax_sim_input.yaml @@ -2,7 +2,8 @@ asimmodule: geometry_optimization.atom_relax env_id: workdir: cyclobutane_relax_results args: - calc_id: MACE-OFF + calculator: + calc_id: MACE-OFF image: name: cyclobutane builder: molecule diff --git a/examples/external/OMAT24/atom_relax_sim_input.yaml b/examples/external/OMAT24/atom_relax_sim_input.yaml index 5590d93..67f7873 100644 --- a/examples/external/OMAT24/atom_relax_sim_input.yaml +++ b/examples/external/OMAT24/atom_relax_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: geometry_optimization.atom_relax workdir: nacl_results args: - calc_id: OMAT24_eqV2_153M + calculator: + calc_id: OMAT24_eqV2_153M image: name: NaCl builder: bulk diff --git a/examples/external/QuantumEspresso/scf_singlepoint_sim_input.yaml b/examples/external/QuantumEspresso/scf_singlepoint_sim_input.yaml index 6cb3f34..b38cd44 100644 --- a/examples/external/QuantumEspresso/scf_singlepoint_sim_input.yaml +++ b/examples/external/QuantumEspresso/scf_singlepoint_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: singlepoint workdir: scf_results args: - calc_id: QE_Li + calculator: + calc_id: QE_Li image: name: Li builder: bulk diff --git a/examples/external/QuantumEspresso/vc-relax_singlepoint_sim_input.yaml b/examples/external/QuantumEspresso/vc-relax_singlepoint_sim_input.yaml index 5f0e4af..6a1bad0 100644 --- a/examples/external/QuantumEspresso/vc-relax_singlepoint_sim_input.yaml +++ b/examples/external/QuantumEspresso/vc-relax_singlepoint_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: singlepoint workdir: vc-relax_results args: - calc_id: QE_Li_vc-relax + calculator: + calc_id: QE_Li_vc-relax image: name: Li builder: bulk diff --git a/examples/external/VASP/calc_input.yaml b/examples/external/VASP/calc_input.yaml index 9939693..25c757a 100644 --- a/examples/external/VASP/calc_input.yaml +++ b/examples/external/VASP/calc_input.yaml @@ -7,5 +7,9 @@ vasp_PBE: prec: Normal xc: PBE lreal: False - command: srun vasp_std + command: srun --mpi=pmi2 vasp_std + # See ASE vasp kpoints specification + kpts: [1,1,1] + # other parameters not specified through ASE can be set directly + NCORE: 4 diff --git a/examples/external/VASP/vasp_ase_sim_input.yaml b/examples/external/VASP/vasp_ase_sim_input.yaml new file mode 100644 index 0000000..c3a3272 --- /dev/null +++ b/examples/external/VASP/vasp_ase_sim_input.yaml @@ -0,0 +1,10 @@ +asimmodule: singlepoint +workdir: vasp_ase_results +env_id: n24 # Specify your own env +args: + calculator: + calc_id: vasp_PBE + image: + name: Na + builder: bulk + properties: ['energy', 'forces'] diff --git a/examples/external/VASP/vasp_mixed_sim_input.yaml b/examples/external/VASP/vasp_mixed_sim_input.yaml new file mode 100644 index 0000000..90206a1 --- /dev/null +++ b/examples/external/VASP/vasp_mixed_sim_input.yaml @@ -0,0 +1,18 @@ +# This example shows how to use a mpset as a template then specify your own INCAR +asimmodule: vasp.vasp +workdir: vasp_mixed_results +env_id: n24 # Specify your own env +args: + image: + name: Na + builder: bulk + incar: + ENCUT: 480 + KSPACING: 0.7 + IBRION: -1 + kpoints: + kpts: [[1,1,1]] + potcar: + Na: PBE + + command: srun --mpi=pmi2 vasp_std diff --git a/examples/external/VASP/vasp_mpset_sim_input.yaml b/examples/external/VASP/vasp_mpset_sim_input.yaml index 2dd3880..b3ae4a4 100644 --- a/examples/external/VASP/vasp_mpset_sim_input.yaml +++ b/examples/external/VASP/vasp_mpset_sim_input.yaml @@ -1,9 +1,9 @@ asimmodule: vasp.vasp workdir: vasp_mpset_results -env_id: inline +env_id: n24 # Specify your own env args: image: name: Na builder: bulk - mpset: MPStaticSet - command: vasp_std + mpset: MatPESStaticSet + command: srun --mpi=pmi2 vasp_std diff --git a/examples/external/VASP/singlepoint_sim_input.yaml b/examples/external/VASP/vasp_sim_input.yaml similarity index 64% rename from examples/external/VASP/singlepoint_sim_input.yaml rename to examples/external/VASP/vasp_sim_input.yaml index acd4c33..2134f74 100644 --- a/examples/external/VASP/singlepoint_sim_input.yaml +++ b/examples/external/VASP/vasp_sim_input.yaml @@ -1,8 +1,9 @@ asimmodule: singlepoint workdir: vasp_results -env_id: inline +env_id: n24 # Specify your own env args: - calc_id: vasp + calculator: + calc_id: vasp_PBE image: name: Na builder: bulk diff --git a/examples/external/active_learning/active_learning_sim_input.yaml b/examples/external/active_learning/active_learning_sim_input.yaml index 7befdae..bbb3134 100644 --- a/examples/external/active_learning/active_learning_sim_input.yaml +++ b/examples/external/active_learning/active_learning_sim_input.yaml @@ -37,7 +37,7 @@ args: step-1: asimmodule: active_learning.ase_md args: - calc_spec: + calculator: calc_params: name: MACE args: @@ -80,6 +80,7 @@ args: subsim_input: asimmodule: singlepoint args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: image_file: {} \ No newline at end of file diff --git a/examples/external/active_learning/ase_md_sim_input.yaml b/examples/external/active_learning/ase_md_sim_input.yaml index 17fe6e0..9e517e6 100644 --- a/examples/external/active_learning/ase_md_sim_input.yaml +++ b/examples/external/active_learning/ase_md_sim_input.yaml @@ -1,12 +1,13 @@ asimmodule: ase_md.ase_md.py workdir: ase_md_results args: - calc_spec: + calculator: calc_id: lj_Ar - # name: MACE - # args: - # model: /Users/keith/dev/asimtools/examples/external/active_learning/train_mace_results/MACE_models/mace_test_compiled.model - # use_device: cpu + # calc_params: + # name: MACE + # args: + # model: /Users/keith/dev/asimtools/examples/external/active_learning/train_mace_results/MACE_models/mace_test_compiled.model + # use_device: cpu image: name: Ar cubic: True diff --git a/examples/internal/ase_cubic_eos/ase_cubic_eos_sim_input.yaml b/examples/internal/ase_cubic_eos/ase_cubic_eos_sim_input.yaml index e175500..da20da2 100644 --- a/examples/internal/ase_cubic_eos/ase_cubic_eos_sim_input.yaml +++ b/examples/internal/ase_cubic_eos/ase_cubic_eos_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: geometry_optimization.ase_cubic_eos_optimization workdir: eos_results args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu a: 3.59 diff --git a/examples/internal/ase_cubic_eos_custom/array_ase_eos_sim_input.yaml b/examples/internal/ase_cubic_eos_custom/array_ase_eos_sim_input.yaml index 4dbc5b9..84892ee 100644 --- a/examples/internal/ase_cubic_eos_custom/array_ase_eos_sim_input.yaml +++ b/examples/internal/ase_cubic_eos_custom/array_ase_eos_sim_input.yaml @@ -6,7 +6,8 @@ args: template_sim_input: asimmodule: ../../ase_eos.py args: - calc_id: emt + calculator: + calc_id: emt image: builder: bulk crystalstructure: fcc diff --git a/examples/internal/ase_cubic_eos_custom/ase_eos.py b/examples/internal/ase_cubic_eos_custom/ase_eos.py index f99cbd0..542402e 100644 --- a/examples/internal/ase_cubic_eos_custom/ase_eos.py +++ b/examples/internal/ase_cubic_eos_custom/ase_eos.py @@ -15,22 +15,22 @@ def ase_eos( image: Dict, - calc_id: str, + calculator: dict, db_file: 'bulk.db' ) -> Dict: """Calculate the equation of state, fit it and extract the equilibrium volume, energy and bulk modulus :param image: image to use - :type image: Dict - :param calc_id: calculator id - :type calc_id: str + :type image: dict + :param calculator: calculator spec + :type calculator: dict :param db_file: ASE database in which to store results :type db_file: bulk.db :return: results including equilibrium volume, energy and bulk modulus - :rtype: Dict + :rtype: dict """ - calc = load_calc(calc_id) + calc = load_calc(calculator) db = connect(db_file) atoms = get_atoms(**image) atoms.calc = calc diff --git a/examples/internal/ase_cubic_eos_custom/ase_eos_sim_input.yaml b/examples/internal/ase_cubic_eos_custom/ase_eos_sim_input.yaml index ee1f78c..5ddd238 100644 --- a/examples/internal/ase_cubic_eos_custom/ase_eos_sim_input.yaml +++ b/examples/internal/ase_cubic_eos_custom/ase_eos_sim_input.yaml @@ -2,7 +2,8 @@ asimmodule: ../ase_eos.py env_id: inline workdir: Cu_results args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu cubic: true diff --git a/examples/internal/atom_relax/atom_relax_sim_input.yaml b/examples/internal/atom_relax/atom_relax_sim_input.yaml index 36b32c6..f5ca60d 100644 --- a/examples/internal/atom_relax/atom_relax_sim_input.yaml +++ b/examples/internal/atom_relax/atom_relax_sim_input.yaml @@ -2,7 +2,8 @@ asimmodule: geometry_optimization.atom_relax env_id: workdir: relax_results args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar builder: bulk diff --git a/examples/internal/calc_array/calc_array_calc_id_sim_input.yaml b/examples/internal/calc_array/calc_array_calc_id_sim_input.yaml index 986271f..0566b5e 100644 --- a/examples/internal/calc_array/calc_array_calc_id_sim_input.yaml +++ b/examples/internal/calc_array/calc_array_calc_id_sim_input.yaml @@ -2,7 +2,9 @@ asimmodule: workflows.calc_array overwrite: true workdir: calc_id_results args: - calc_ids: [emt, lj_Cu] + calculators: + - calc_id: emt + - calc_id: lj_Cu env_ids: [inline, inline] subsim_input: asimmodule: geometry_optimization.atom_relax diff --git a/examples/internal/calc_array/calc_array_elastic_constant_sim_input.yaml b/examples/internal/calc_array/calc_array_elastic_constant_sim_input.yaml index e6ac647..568f7f1 100644 --- a/examples/internal/calc_array/calc_array_elastic_constant_sim_input.yaml +++ b/examples/internal/calc_array/calc_array_elastic_constant_sim_input.yaml @@ -1,12 +1,15 @@ asimmodule: workflows.calc_array workdir: env_results args: - calc_ids: [emt, lj_Cu] - env_ids: [inline, inline] # Must correspond to calc_id if list is given + calculators: + - calc_id: emt + - calc_id: lj_Cu + env_ids: [inline, inline] # Must correspond to calculator if list is given subsim_input: asimmodule: elastic_constants.cubic_energy_expansion args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu a: 3.6 diff --git a/examples/internal/calc_array/calc_array_secondary_sim_input.yaml b/examples/internal/calc_array/calc_array_secondary_sim_input.yaml index 794b19e..8ae72e8 100644 --- a/examples/internal/calc_array/calc_array_secondary_sim_input.yaml +++ b/examples/internal/calc_array/calc_array_secondary_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: workflows.calc_array workdir: secondary_results args: - template_calc_id: lj_Cu + template_calculator: + calc_id: lj_Cu key_sequence: ['args', 'sigma'] linspace_args: [2.3, 2.36, 3] secondary_key_sequences: [[args, 'epsilon']] diff --git a/examples/internal/calc_array/calc_array_sigma_sim_input.yaml b/examples/internal/calc_array/calc_array_sigma_sim_input.yaml index ffce902..f0f82a0 100644 --- a/examples/internal/calc_array/calc_array_sigma_sim_input.yaml +++ b/examples/internal/calc_array/calc_array_sigma_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: workflows.calc_array workdir: sigma_results args: - template_calc_id: lj_Cu + template_calculator: + calc_id: lj_Cu key_sequence: ['args', 'sigma'] array_values: [2.30, 2.33, 2.36] subsim_input: diff --git a/examples/internal/cell_relax/cell_relax_sim_input.yaml b/examples/internal/cell_relax/cell_relax_sim_input.yaml index fefaabe..4836641 100644 --- a/examples/internal/cell_relax/cell_relax_sim_input.yaml +++ b/examples/internal/cell_relax/cell_relax_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: geometry_optimization.cell_relax # The symmetry might change! workdir: cell_relax_results args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar builder: bulk diff --git a/examples/internal/chained/chained_batch_nested_sim_input.yaml b/examples/internal/chained/chained_batch_nested_sim_input.yaml index f87a24f..b0423a9 100644 --- a/examples/internal/chained/chained_batch_nested_sim_input.yaml +++ b/examples/internal/chained/chained_batch_nested_sim_input.yaml @@ -12,7 +12,8 @@ args: env_id: batch job_name: relax args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar crystalstructure: fcc @@ -37,7 +38,8 @@ args: asimmodule: geometry_optimization.cell_relax env_id: batch args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: image_file: ../step-0/id-0001__relax__/image_output.xyz fmax: 0.01 @@ -50,6 +52,7 @@ args: asimmodule: geometry_optimization.ase_cubic_eos_optimization env_id: batch args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: image_file: ../step-1/image_output.xyz diff --git a/examples/internal/chained/chained_batch_sim_input.yaml b/examples/internal/chained/chained_batch_sim_input.yaml index 20c9f58..94e3311 100644 --- a/examples/internal/chained/chained_batch_sim_input.yaml +++ b/examples/internal/chained/chained_batch_sim_input.yaml @@ -6,7 +6,8 @@ args: asimmodule: geometry_optimization.atom_relax env_id: batch args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar crystalstructure: fcc @@ -19,7 +20,8 @@ args: asimmodule: geometry_optimization.cell_relax env_id: batch args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: image_file: ../step-0/image_output.xyz fmax: 0.01 @@ -32,6 +34,7 @@ args: asimmodule: geometry_optimization.ase_cubic_eos_optimization env_id: batch args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: image_file: ../step-1/image_output.xyz diff --git a/examples/internal/chained/chained_sim_input.yaml b/examples/internal/chained/chained_sim_input.yaml index 374b4ac..815bf1e 100644 --- a/examples/internal/chained/chained_sim_input.yaml +++ b/examples/internal/chained/chained_sim_input.yaml @@ -5,7 +5,8 @@ args: step-0: asimmodule: geometry_optimization.atom_relax args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar crystalstructure: fcc @@ -17,13 +18,15 @@ args: step-1: asimmodule: geometry_optimization.cell_relax args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: - image_file: step-0/image_output.xyz + image_file: ../step-0/image_output.xyz fmax: 0.006 step-2: asimmodule: geometry_optimization.ase_cubic_eos_optimization args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: - image_file: step-1/image_output.xyz + image_file: ../step-1/image_output.xyz diff --git a/examples/internal/chained/chained_srun_sim_input.yaml b/examples/internal/chained/chained_srun_sim_input.yaml index ae63f5c..a8a5b96 100644 --- a/examples/internal/chained/chained_srun_sim_input.yaml +++ b/examples/internal/chained/chained_srun_sim_input.yaml @@ -6,7 +6,8 @@ args: asimmodule: geometry_optimization.atom_relax env_id: srun args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar crystalstructure: fcc @@ -19,7 +20,8 @@ args: asimmodule: geometry_optimization.cell_relax env_id: srun args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: image_file: step-0/image_output.xyz # for srun, files must be relative to chain root fmax: 0.006 @@ -32,6 +34,7 @@ args: asimmodule: geometry_optimization.ase_cubic_eos_optimization env_id: srun args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: image_file: step-1/image_output.xyz diff --git a/examples/internal/chained/eos_chained_array_sim_input.yaml b/examples/internal/chained/eos_chained_array_sim_input.yaml index aaac5cb..8834d41 100644 --- a/examples/internal/chained/eos_chained_array_sim_input.yaml +++ b/examples/internal/chained/eos_chained_array_sim_input.yaml @@ -20,7 +20,8 @@ args: asimmodule: singlepoint env_id: batch args: - calc_id: emt + calculator: + calc_id: emt # This step is introduced to connect step 1 and 3 since step # one submits jobs internally step-2: diff --git a/examples/internal/distributed/distributed_bash_sim_input.yaml b/examples/internal/distributed/distributed_bash_sim_input.yaml index be81ed5..62d3bb7 100644 --- a/examples/internal/distributed/distributed_bash_sim_input.yaml +++ b/examples/internal/distributed/distributed_bash_sim_input.yaml @@ -6,7 +6,8 @@ args: asimmodule: singlepoint env_id: bash #If all env_ids are slurm batch jobs using the same configuration, we automatically use arrays args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar id-0001: @@ -14,7 +15,8 @@ args: asimmodule: geometry_optimization.atom_relax env_id: bash args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar id-0002: @@ -22,12 +24,15 @@ args: asimmodule: workflows.calc_array env_id: bash args: - calc_ids: [emt, lj_Cu] - env_ids: [inline, inline] # Must correspond to calc_id if list is given + calculators: + - calc_id: emt + - calc_id: lj_Cu + env_ids: [inline, inline] # Must correspond to calculator if list is given subsim_input: asimmodule: elastic_constants.cubic_energy_expansion args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu a: 3.6 diff --git a/examples/internal/distributed/distributed_batch_sim_input.yaml b/examples/internal/distributed/distributed_batch_sim_input.yaml index ce7fdf6..7d7b86a 100644 --- a/examples/internal/distributed/distributed_batch_sim_input.yaml +++ b/examples/internal/distributed/distributed_batch_sim_input.yaml @@ -7,7 +7,8 @@ args: asimmodule: singlepoint env_id: batch #If all env_ids are slurm batch jobs using the same configuration, we automatically use arrays args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar id-0001: @@ -15,7 +16,8 @@ args: asimmodule: geometry_optimization.atom_relax env_id: batch args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar id-0002: @@ -23,12 +25,15 @@ args: asimmodule: workflows.calc_array env_id: batch args: - calc_ids: [emt, lj_Cu] - env_ids: [inline, inline] # Must correspond to calc_id if list is given + calculators: + - calc_id: emt + - calc_id: lj_Cu + env_ids: [inline, inline] # Must correspond to calculator if list is given subsim_input: asimmodule: elastic_constants.cubic_energy_expansion args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu a: 3.6 diff --git a/examples/internal/distributed/distributed_fail_sim_input.yaml b/examples/internal/distributed/distributed_fail_sim_input.yaml index aad93ec..3824f32 100644 --- a/examples/internal/distributed/distributed_fail_sim_input.yaml +++ b/examples/internal/distributed/distributed_fail_sim_input.yaml @@ -6,7 +6,8 @@ args: id-0000: asimmodule: singlepoint args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar properties: [bad_property] @@ -14,7 +15,8 @@ args: # Doesn't have to be same asimmodule asimmodule: geometry_optimization.atom_relax args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar optimizer: BFGS @@ -22,12 +24,15 @@ args: # This is just the calc_array example copied and pasted! asimmodule: workflows.calc_array args: - calc_ids: [emt, lj_Cu] - env_ids: [inline, inline] # Must correspond to calc_id if list is given + calculators: + - calc_id: emt + - calc_id: lj_Cu + env_ids: [inline, inline] # Must correspond to calculator if list is given subsim_input: asimmodule: elastic_constants.cubic_energy_expansion args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu a: 3.6 diff --git a/examples/internal/distributed/distributed_group_bash_sim_input.yaml b/examples/internal/distributed/distributed_group_bash_sim_input.yaml index 1fc42ca..0e0c7f4 100644 --- a/examples/internal/distributed/distributed_group_bash_sim_input.yaml +++ b/examples/internal/distributed/distributed_group_bash_sim_input.yaml @@ -8,7 +8,8 @@ args: asimmodule: singlepoint env_id: batch #If all env_ids are slurm batch jobs using the same configuration, we automatically use arrays args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar id-0001: @@ -16,7 +17,8 @@ args: asimmodule: geometry_optimization.atom_relax env_id: batch args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar id-0002: @@ -24,12 +26,15 @@ args: asimmodule: workflows.calc_array env_id: batch args: - calc_ids: [emt, lj_Cu] - env_ids: [inline, inline] # Must correspond to calc_id if list is given + calculators: + - calc_id: emt + - calc_id: lj_Cu + env_ids: [inline, inline] # Must correspond to calculator if list is given subsim_input: asimmodule: elastic_constants.cubic_energy_expansion args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu a: 3.6 diff --git a/examples/internal/distributed/distributed_mixed_sim_input.yaml b/examples/internal/distributed/distributed_mixed_sim_input.yaml index a9319cb..52276c6 100644 --- a/examples/internal/distributed/distributed_mixed_sim_input.yaml +++ b/examples/internal/distributed/distributed_mixed_sim_input.yaml @@ -6,7 +6,8 @@ args: asimmodule: singlepoint env_id: inline # No array since one of them is not a slurm batch job args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar id-0001: @@ -14,7 +15,8 @@ args: asimmodule: geometry_optimization.atom_relax env_id: batch args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar id-0002: @@ -22,12 +24,15 @@ args: asimmodule: workflows.calc_array env_id: batch args: - calc_ids: [emt, lj_Cu] - env_ids: [inline, inline] # Must correspond to calc_id if list is given + calculators: + - calc_id: emt + - calc_id: lj_Cu + env_ids: [inline, inline] # Must correspond to calculator if list is given subsim_input: asimmodule: elastic_constants.cubic_energy_expansion args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu a: 3.6 diff --git a/examples/internal/distributed/distributed_sim_input.yaml b/examples/internal/distributed/distributed_sim_input.yaml index 60e1c39..f6a93df 100644 --- a/examples/internal/distributed/distributed_sim_input.yaml +++ b/examples/internal/distributed/distributed_sim_input.yaml @@ -5,14 +5,16 @@ args: id-0000: asimmodule: singlepoint args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar id-0001: # Doesn't have to be same asimmodule asimmodule: geometry_optimization.atom_relax args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar optimizer: BFGS @@ -20,12 +22,15 @@ args: # This is just the calc_array example copied and pasted! asimmodule: workflows.calc_array args: - calc_ids: [emt, lj_Cu] - env_ids: [inline, inline] # Must correspond to calc_id if list is given + calculators: + - calc_id: emt + - calc_id: lj_Cu + env_ids: [inline, inline] # Must correspond to calculator if list is given subsim_input: asimmodule: elastic_constants.cubic_energy_expansion args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu a: 3.6 diff --git a/examples/internal/elastic_constants/elastic_constants_sim_input.yaml b/examples/internal/elastic_constants/elastic_constants_sim_input.yaml index 3b935fb..463053a 100644 --- a/examples/internal/elastic_constants/elastic_constants_sim_input.yaml +++ b/examples/internal/elastic_constants/elastic_constants_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: elastic_constants.cubic_energy_expansion workdir: elastic_constant_results args: - calc_id: emt + calculator: + calc_id: emt image: # Does not need to be relaxed, will use energy minimum volume from eos name: Cu a: 3.59 diff --git a/examples/internal/image_array/image_array_batch_sim_input.yaml b/examples/internal/image_array/image_array_batch_sim_input.yaml index fb638c8..0f2e444 100644 --- a/examples/internal/image_array/image_array_batch_sim_input.yaml +++ b/examples/internal/image_array/image_array_batch_sim_input.yaml @@ -9,6 +9,7 @@ args: env_id: batch args: image: {} - calc_id: lj_Ar + calculator: + calc_id: lj_Ar properties: ['energy', 'forces'] diff --git a/examples/internal/image_array/image_array_key_sequence_sim_input.yaml b/examples/internal/image_array/image_array_key_sequence_sim_input.yaml index 9680553..1e9023b 100644 --- a/examples/internal/image_array/image_array_key_sequence_sim_input.yaml +++ b/examples/internal/image_array/image_array_key_sequence_sim_input.yaml @@ -12,6 +12,7 @@ args: asimmodule: singlepoint args: image: {} - calc_id: lj_Ar + calculator: + calc_id: lj_Ar properties: ['energy', 'forces'] diff --git a/examples/internal/image_array/image_array_sim_input.yaml b/examples/internal/image_array/image_array_sim_input.yaml index 4960c3a..451ddb8 100644 --- a/examples/internal/image_array/image_array_sim_input.yaml +++ b/examples/internal/image_array/image_array_sim_input.yaml @@ -7,6 +7,7 @@ args: subsim_input: asimmodule: singlepoint args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar properties: ['energy', 'forces'] diff --git a/examples/internal/iterative/array_cell_relax_sim_input.yaml b/examples/internal/iterative/array_cell_relax_sim_input.yaml index 8742953..6b48ae6 100644 --- a/examples/internal/iterative/array_cell_relax_sim_input.yaml +++ b/examples/internal/iterative/array_cell_relax_sim_input.yaml @@ -7,7 +7,8 @@ args: template_sim_input: asimmodule: geometry_optimization.symmetric_cell_relax args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: image_file: ../Ar.cif optimizer: BFGS diff --git a/examples/internal/iterative/iterative_lattice_parameters_sim_input.yaml b/examples/internal/iterative/iterative_lattice_parameters_sim_input.yaml index 7371fd3..ecfe0d8 100644 --- a/examples/internal/iterative/iterative_lattice_parameters_sim_input.yaml +++ b/examples/internal/iterative/iterative_lattice_parameters_sim_input.yaml @@ -7,7 +7,8 @@ args: template_sim_input: asimmodule: singlepoint args: - calc_id: lj_Cu + calculator: + calc_id: lj_Cu image: builder: bulk name: Cu diff --git a/examples/internal/iterative/sequential_cell_relax_sim_input.yaml b/examples/internal/iterative/sequential_cell_relax_sim_input.yaml index e84e8eb..2af4697 100644 --- a/examples/internal/iterative/sequential_cell_relax_sim_input.yaml +++ b/examples/internal/iterative/sequential_cell_relax_sim_input.yaml @@ -9,7 +9,8 @@ args: template_sim_input: asimmodule: geometry_optimization.symmetric_cell_relax args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: image_file: ../Ar.cif optimizer: BFGS diff --git a/examples/internal/optimize_geometry/optimize_sim_input.yaml b/examples/internal/optimize_geometry/optimize_sim_input.yaml index c32268f..e8ed169 100644 --- a/examples/internal/optimize_geometry/optimize_sim_input.yaml +++ b/examples/internal/optimize_geometry/optimize_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: geometry_optimization.optimize workdir: optimize_results args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar crystalstructure: fcc diff --git a/examples/internal/phonons/phonons_sim_input.yaml b/examples/internal/phonons/phonons_sim_input.yaml index e53e9c4..a76a349 100644 --- a/examples/internal/phonons/phonons_sim_input.yaml +++ b/examples/internal/phonons/phonons_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: phonons.ase_phonons workdir: phonons_results args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu cubic: true diff --git a/examples/internal/phonopy/full_qha_sim_input.yaml b/examples/internal/phonopy/full_qha_sim_input.yaml index 4210476..4c9e788 100644 --- a/examples/internal/phonopy/full_qha_sim_input.yaml +++ b/examples/internal/phonopy/full_qha_sim_input.yaml @@ -3,7 +3,8 @@ workdir: full_qha_results args: image: name: Cu - calc_id: emt + calculator: + calc_id: emt calc_env_id: inline process_env_id: inline phonopy_save_path: phonopy_save.yaml # Specify path relative to this asimmodule's workdir diff --git a/examples/internal/phonopy/phonon_bands_and_dos_sim_input.yaml b/examples/internal/phonopy/phonon_bands_and_dos_sim_input.yaml index 9471a86..521402a 100644 --- a/examples/internal/phonopy/phonon_bands_and_dos_sim_input.yaml +++ b/examples/internal/phonopy/phonon_bands_and_dos_sim_input.yaml @@ -7,7 +7,8 @@ args: distance: 0.02 phonopy_save_path: phonopy.save calc_env_id: inline - calc_id: emt + calculator: + calc_id: emt process_env_id: inline # paths: ['GAMMA', 'X', 'GAMMA', 'L'] # labels: Optional[Sequence] = None, diff --git a/examples/internal/sim_array/run.sh b/examples/internal/sim_array/run.sh index 96e0ed1..e4d8690 100644 --- a/examples/internal/sim_array/run.sh +++ b/examples/internal/sim_array/run.sh @@ -29,3 +29,8 @@ asim-execute sim_array_calc_id_sim_input.yaml -c ../calc_input.yaml -e ../env_in # It also autmatically names the result directories (ids) corresponding to the # name of the file. You can do this with any input file, not just structures asim-execute sim_array_image_file_sim_input.yaml -c ../calc_input.yaml -e ../env_input.yaml + +# Example 5: +# This example runs the same calculator on Cu with different +# crystal structures but uses a placeholder instead that is replaced by the array_values +asim-execute sim_array_crystalstructure_sim_input.yaml -c ../calc_input.yaml -e ../env_input.yaml \ No newline at end of file diff --git a/examples/internal/sim_array/sim_array_batch_calc_id_sim_input.yaml b/examples/internal/sim_array/sim_array_batch_calc_id_sim_input.yaml index 3f533d4..8a11a00 100644 --- a/examples/internal/sim_array/sim_array_batch_calc_id_sim_input.yaml +++ b/examples/internal/sim_array/sim_array_batch_calc_id_sim_input.yaml @@ -7,7 +7,8 @@ args: template_sim_input: asimmodule: singlepoint args: - calc_id: lj_Cu + calculator: + calc_id: lj_Cu image: builder: bulk name: Cu diff --git a/examples/internal/sim_array/sim_array_batch_crystalstructure_sim_input.yaml b/examples/internal/sim_array/sim_array_batch_crystalstructure_sim_input.yaml index 71baba5..7044d71 100644 --- a/examples/internal/sim_array/sim_array_batch_crystalstructure_sim_input.yaml +++ b/examples/internal/sim_array/sim_array_batch_crystalstructure_sim_input.yaml @@ -16,7 +16,8 @@ args: template_sim_input: asimmodule: singlepoint args: - calc_id: lj_Cu + calculator: + calc_id: lj_Cu image: {} # You can set a placeholder here or not specify. # I usually put something that would cause an error to make sure # it is substituted correctly diff --git a/examples/internal/sim_array/sim_array_batch_image_file_sim_input.yaml b/examples/internal/sim_array/sim_array_batch_image_file_sim_input.yaml index f9cecfc..517e0bc 100644 --- a/examples/internal/sim_array/sim_array_batch_image_file_sim_input.yaml +++ b/examples/internal/sim_array/sim_array_batch_image_file_sim_input.yaml @@ -8,7 +8,8 @@ args: template_sim_input: asimmodule: singlepoint args: - calc_id: lj_Cu + calculator: + calc_id: lj_Cu image: image_file: {} \ No newline at end of file diff --git a/examples/internal/sim_array/sim_array_batch_lattice_parameters_sim_input.yaml b/examples/internal/sim_array/sim_array_batch_lattice_parameters_sim_input.yaml index b253ffa..30434dd 100644 --- a/examples/internal/sim_array/sim_array_batch_lattice_parameters_sim_input.yaml +++ b/examples/internal/sim_array/sim_array_batch_lattice_parameters_sim_input.yaml @@ -7,7 +7,8 @@ args: template_sim_input: asimmodule: singlepoint args: - calc_id: lj_Cu + calculator: + calc_id: lj_Cu image: builder: bulk name: Cu diff --git a/examples/internal/sim_array/sim_array_calc_id_sim_input.yaml b/examples/internal/sim_array/sim_array_calc_id_sim_input.yaml index 02357bf..ca03f79 100644 --- a/examples/internal/sim_array/sim_array_calc_id_sim_input.yaml +++ b/examples/internal/sim_array/sim_array_calc_id_sim_input.yaml @@ -1,13 +1,14 @@ asimmodule: workflows.sim_array workdir: calc_id_results args: - key_sequence: [args, calc_id] + key_sequence: [args, calculator, calc_id] array_values: [emt, lj_Cu] env_ids: [inline, inline] template_sim_input: asimmodule: singlepoint args: - calc_id: {} + calculator: + calc_id: {} image: builder: bulk name: Cu diff --git a/examples/internal/sim_array/sim_array_crystalstructure_placeholder_sim_input.yaml b/examples/internal/sim_array/sim_array_crystalstructure_placeholder_sim_input.yaml new file mode 100644 index 0000000..a01b7db --- /dev/null +++ b/examples/internal/sim_array/sim_array_crystalstructure_placeholder_sim_input.yaml @@ -0,0 +1,19 @@ +asimmodule: workflows.sim_array +workdir: crystalstructure_placeholder_results +args: + key_sequence: ['args', 'image', 'crystalstructure'] + labels: [fcc, bcc] # Check what happens in the results dir if you don't specify labels + placeholder: PLACEHOLDER + array_values: [f,b] + env_ids: inline + template_sim_input: + asimmodule: singlepoint + args: + calculator: + calc_id: lj_Cu + image: + builder: bulk + name: Cu + crystalstructure: PLACEHOLDERcc + a: 3.655 + \ No newline at end of file diff --git a/examples/internal/sim_array/sim_array_crystalstructure_secondary_sim_input.yaml b/examples/internal/sim_array/sim_array_crystalstructure_secondary_sim_input.yaml index 3dcfdcf..a95b784 100644 --- a/examples/internal/sim_array/sim_array_crystalstructure_secondary_sim_input.yaml +++ b/examples/internal/sim_array/sim_array_crystalstructure_secondary_sim_input.yaml @@ -12,7 +12,8 @@ args: template_sim_input: asimmodule: singlepoint args: - calc_id: lj_Cu + calculator: + calc_id: lj_Cu image: name: Cu builder: bulk diff --git a/examples/internal/sim_array/sim_array_crystalstructure_sim_input.yaml b/examples/internal/sim_array/sim_array_crystalstructure_sim_input.yaml index 93634a0..86b6ff0 100644 --- a/examples/internal/sim_array/sim_array_crystalstructure_sim_input.yaml +++ b/examples/internal/sim_array/sim_array_crystalstructure_sim_input.yaml @@ -2,7 +2,7 @@ asimmodule: workflows.sim_array workdir: crystalstructure_results args: key_sequence: ['args', 'image'] - labels: [fcc, bcc] # Check what happens in the results dir if you don't specify ids + labels: [fcc, bcc] # Check what happens in the results dir if you don't specify labels array_values: - builder: bulk name: Cu @@ -16,7 +16,8 @@ args: template_sim_input: asimmodule: singlepoint args: - calc_id: lj_Cu + calculator: + calc_id: lj_Cu image: {} # You can set a placeholder here or not specify. # I usually put something that would cause an error to make sure # it is substituted correctly diff --git a/examples/internal/sim_array/sim_array_image_file_sim_input.yaml b/examples/internal/sim_array/sim_array_image_file_sim_input.yaml index f9cecfc..517e0bc 100644 --- a/examples/internal/sim_array/sim_array_image_file_sim_input.yaml +++ b/examples/internal/sim_array/sim_array_image_file_sim_input.yaml @@ -8,7 +8,8 @@ args: template_sim_input: asimmodule: singlepoint args: - calc_id: lj_Cu + calculator: + calc_id: lj_Cu image: image_file: {} \ No newline at end of file diff --git a/examples/internal/sim_array/sim_array_lattice_parameters_sim_input.yaml b/examples/internal/sim_array/sim_array_lattice_parameters_sim_input.yaml index bc10f1e..2f77fec 100644 --- a/examples/internal/sim_array/sim_array_lattice_parameters_sim_input.yaml +++ b/examples/internal/sim_array/sim_array_lattice_parameters_sim_input.yaml @@ -7,7 +7,8 @@ args: template_sim_input: asimmodule: singlepoint args: - calc_id: lj_Cu + calculator: + calc_id: lj_Cu image: builder: bulk name: Cu diff --git a/examples/internal/singlepoint/singlepoint_batch_sim_input.yaml b/examples/internal/singlepoint/singlepoint_batch_sim_input.yaml index dc34214..0766f3a 100644 --- a/examples/internal/singlepoint/singlepoint_batch_sim_input.yaml +++ b/examples/internal/singlepoint/singlepoint_batch_sim_input.yaml @@ -2,7 +2,8 @@ asimmodule: singlepoint workdir: batch_results env_id: batch args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar builder: bulk diff --git a/examples/internal/singlepoint/singlepoint_sim_input.yaml b/examples/internal/singlepoint/singlepoint_sim_input.yaml index df4329e..ca3f423 100644 --- a/examples/internal/singlepoint/singlepoint_sim_input.yaml +++ b/examples/internal/singlepoint/singlepoint_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: singlepoint workdir: inline_results args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar builder: bulk diff --git a/examples/internal/singlepoint/singlepoint_srun_sim_input.yaml b/examples/internal/singlepoint/singlepoint_srun_sim_input.yaml index f9973f1..c48e291 100644 --- a/examples/internal/singlepoint/singlepoint_srun_sim_input.yaml +++ b/examples/internal/singlepoint/singlepoint_srun_sim_input.yaml @@ -2,7 +2,8 @@ asimmodule: singlepoint workdir: srun_results env_id: srun args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar builder: bulk diff --git a/examples/internal/surface_energies/surface_energies_sim_input.yaml b/examples/internal/surface_energies/surface_energies_sim_input.yaml index 55645a6..b057dc8 100644 --- a/examples/internal/surface_energies/surface_energies_sim_input.yaml +++ b/examples/internal/surface_energies/surface_energies_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: surface_energies.surface_energies workdir: surface_energies_results args: - calc_id: emt + calculator: + calc_id: emt image: name: Cu cubic: true # This is really important! You must use the conventional cell! diff --git a/examples/internal/symmetric_cell_relax/symmetric_cell_relax_sim_input.yaml b/examples/internal/symmetric_cell_relax/symmetric_cell_relax_sim_input.yaml index 6fe30e6..269da5b 100644 --- a/examples/internal/symmetric_cell_relax/symmetric_cell_relax_sim_input.yaml +++ b/examples/internal/symmetric_cell_relax/symmetric_cell_relax_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: geometry_optimization.symmetric_cell_relax workdir: symmetric_cell_relax_results args: - calc_id: lj_Ar + calculator: + calc_id: lj_Ar image: name: Ar crystalstructure: sc diff --git a/examples/internal/vacancy_formation_energy/vfe_sim_input.yaml b/examples/internal/vacancy_formation_energy/vfe_sim_input.yaml index efa55fb..3aa619f 100644 --- a/examples/internal/vacancy_formation_energy/vfe_sim_input.yaml +++ b/examples/internal/vacancy_formation_energy/vfe_sim_input.yaml @@ -1,7 +1,8 @@ asimmodule: vacancy_formation_energy.vacancy_formation_energy workdir: vfe_results args: - calc_id: emt + calculator: + calc_id: emt image: # Does not need to be relaxed, will use energy minimum volume from eos name: Cu a: 3.59 diff --git a/pyproject.toml b/pyproject.toml index 9344421..7fad6bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "asimtools" description = "A lightweight python package for managing and running atomic simulation workflows" -version = "0.1.0" +version = "0.3.0" readme = "README.md" license = { text = "MIT" } authors = [ @@ -38,15 +38,16 @@ dependencies = [ ] [tool.setuptools.packages.find] +where = ["src"] include = [ "asimtools", "asimtools.*", - "asimtools.scripts.*.*", ] [project.optional-dependencies] dev = [ "asimtools[tests, docs, phonons]", + "pylint", ] mlip = ["matgl>=1.1.2", "chgnet>=0.3.3", "mace-torch>=0.3.3"] phonons = ["phonopy>=2.20.0", "seekpath>=2.1.0"] @@ -69,5 +70,5 @@ documentation = "https://battmodels.github.io/asimtools/" [tool.pytest.ini_options] log_cli_level = "warn" -pythonpath = "asimtools" +pythonpath = ["src"] testpaths = ["tests"] diff --git a/src/asimtools/__init__.py b/src/asimtools/__init__.py new file mode 100644 index 0000000..a9612c5 --- /dev/null +++ b/src/asimtools/__init__.py @@ -0,0 +1,41 @@ +'''asimtools: lightweight atomic simulation workflow manager.''' +from importlib.metadata import version +__version__ = version("asimtools") +from asimtools.job import Job, UnitJob, DistributedJob, ChainedJob +from asimtools.calculators import load_calc +from asimtools.utils import ( + get_atoms, + get_images, + read_yaml, + write_yaml, + write_atoms, + repeat_to_n, + get_str_btn, + join_names, + change_dict_value, + change_dict_values, + expand_wildcards, +) + +__all__ = [ + "__version__", + # Job classes + "Job", + "UnitJob", + "DistributedJob", + "ChainedJob", + # Calculators + "load_calc", + # Utilities + "get_atoms", + "get_images", + "read_yaml", + "write_yaml", + "write_atoms", + "repeat_to_n", + "get_str_btn", + "join_names", + "change_dict_value", + "change_dict_values", + "expand_wildcards", +] diff --git a/asimtools/asimmodules/__init__.py b/src/asimtools/asimmodules/__init__.py similarity index 100% rename from asimtools/asimmodules/__init__.py rename to src/asimtools/asimmodules/__init__.py diff --git a/asimtools/asimmodules/active_learning/__init__.py b/src/asimtools/asimmodules/active_learning/__init__.py similarity index 100% rename from asimtools/asimmodules/active_learning/__init__.py rename to src/asimtools/asimmodules/active_learning/__init__.py diff --git a/asimtools/asimmodules/active_learning/ase_md.py b/src/asimtools/asimmodules/active_learning/ase_md.py similarity index 100% rename from asimtools/asimmodules/active_learning/ase_md.py rename to src/asimtools/asimmodules/active_learning/ase_md.py diff --git a/asimtools/asimmodules/active_learning/compute_deviation.py b/src/asimtools/asimmodules/active_learning/compute_deviation.py similarity index 89% rename from asimtools/asimmodules/active_learning/compute_deviation.py rename to src/asimtools/asimmodules/active_learning/compute_deviation.py index f57c61f..ef62cbf 100644 --- a/asimtools/asimmodules/active_learning/compute_deviation.py +++ b/src/asimtools/asimmodules/active_learning/compute_deviation.py @@ -17,7 +17,7 @@ def compute_deviation( template_calc_params: Optional[Dict] = None, model_weights_key_sequence: Optional[Sequence] = None, model_weights_pattern: Optional[os.PathLike] = None, - calc_ids: Optional[Sequence] = None, + calculators: Optional[Sequence] = None, ) -> Dict: """Computes variance of properties from a trajectory file @@ -31,13 +31,14 @@ def compute_deviation( :param model_weights_pattern: Pattern of model weights files, defaults to None :type model_weights_pattern: Optional[os.PathLike] - :param calc_ids: List of calc_ids to use, if provided, all other arguments - are ignored, defaults to None - :type calc_ids: Optional[Sequence] + :param calculators: List of calculator dicts, each with 'calc_id' or + 'calc_params' key. If provided, all other arguments are ignored, + defaults to None + :type calculators: Optional[Sequence] """ properties = ['energy', 'forces', 'stress', 'energy_per_atom'] - if calc_ids is None: + if calculators is None: model_weights_files = natsorted(glob(model_weights_pattern)) calc_dict = {} @@ -49,9 +50,12 @@ def compute_deviation( return_copy=True ) - calc_dict[f'calc-{i}'] = new_calc_params + calc_dict[f'calc-{i}'] = {'calc_params': new_calc_params} else: - calc_dict = {calc_id: calc_id for calc_id in calc_ids} + calc_dict = {} + for i, calculator in enumerate(calculators): + label = calculator.get('calc_id', f'calc-{i}') + calc_dict[label] = calculator variances = {prop: {} for prop in properties} @@ -74,10 +78,7 @@ def compute_deviation( atom_results = {prop: [] for prop in properties} for calc_id in calc_dict: # Some calculators behave badly if not reloaded unfortunately - if isinstance(calc_dict[calc_id], str): - calc = load_calc(calc_id=calc_dict[calc_id]) - else: - calc = load_calc(calc_params=calc_dict[calc_id].copy()) + calc = load_calc(calculator=calc_dict[calc_id]) atoms.set_calculator(calc) energy = atoms.get_potential_energy(atoms) diff --git a/asimtools/asimmodules/active_learning/direct_sample.py b/src/asimtools/asimmodules/active_learning/direct_sample.py similarity index 100% rename from asimtools/asimmodules/active_learning/direct_sample.py rename to src/asimtools/asimmodules/active_learning/direct_sample.py diff --git a/asimtools/asimmodules/active_learning/select_images.py b/src/asimtools/asimmodules/active_learning/select_images.py similarity index 100% rename from asimtools/asimmodules/active_learning/select_images.py rename to src/asimtools/asimmodules/active_learning/select_images.py diff --git a/asimtools/asimmodules/ase_md/ase_md.py b/src/asimtools/asimmodules/ase_md/ase_md.py similarity index 78% rename from asimtools/asimmodules/ase_md/ase_md.py rename to src/asimtools/asimmodules/ase_md/ase_md.py index 69a8a34..beaae5e 100644 --- a/asimtools/asimmodules/ase_md/ase_md.py +++ b/src/asimtools/asimmodules/ase_md/ase_md.py @@ -18,6 +18,7 @@ from ase.md.velocitydistribution import MaxwellBoltzmannDistribution from ase.md.langevin import Langevin from ase.md.npt import NPT +from ase.md import MDLogger from asimtools.calculators import load_calc from asimtools.utils import ( get_atoms, @@ -31,6 +32,9 @@ def langevin_nvt( traj_file: str = None, friction: float = 1e-2, timestep: float = 1*fs, + properties: Optional[Sequence] = ('energy', 'forces', 'stress'), + log_interval: int = 1, + traj_interval: Optional[int] = 1, ): """Does Langevin dynamics @@ -62,9 +66,21 @@ def langevin_nvt( traj_file, atoms=atoms, mode='w', - properties=['energy', 'forces', 'stress'] + properties=['energy', 'forces'] ) - dyn.attach(traj.write) + dyn.attach(traj.write, interval=traj_interval) + stress = False + if 'stress' in properties: + stress = True + dyn.attach(MDLogger( + dyn, + atoms, + 'md.log', + header=True, + stress=stress, + peratom=True, + mode="a" + ), interval=log_interval) dyn.run(nsteps) return atoms, traj @@ -77,6 +93,9 @@ def npt( traj_file: str = None, ttime: float = 25*fs, pfactor: Optional[float] = None, # (75*fs)**2 * 14*GPa, #Replace 14 with bulk modulus of material + properties: Optional[Sequence] = ('energy', 'forces', 'stress'), + log_interval: int = 1, + traj_interval: Optional[int] = 1, ): """Does NPT dynamics @@ -107,14 +126,28 @@ def npt( pfactor=pfactor, # mask=np.diag([1, 1, 1]), ) + if traj_file is not None: traj = Trajectory( traj_file, atoms=atoms, mode='w', - properties=['energy', 'forces', 'stress'], + properties=properties, ) - dyn.attach(traj.write) + dyn.attach(traj.write, interval=traj_interval) + + stress = False + if 'stress' in properties: + stress = True + dyn.attach(MDLogger( + dyn, + atoms, + 'md.log', + header=True, + stress=stress, + peratom=True, + mode="a" + ), interval=log_interval) dyn.run(nsteps) traj = Trajectory(traj_file, 'r') return atoms, traj @@ -161,7 +194,7 @@ def plot_thermo( plt.close(fig='all') def ase_md( - calc_spec: Dict, + calculator: Dict, image: Dict, timestep: float, nsteps: int = 100, @@ -172,12 +205,17 @@ def ase_md( pfactor: Optional[float] = None, externalstress: Optional[float] = 0, plot: Optional[bool] = True, + time_unit: Optional[str] = 'ase', + plot_args: Optional[dict] = None, + properties: Optional[Sequence] = ('energy', 'forces', 'stress'), + log_interval: Optional[int] = 1, + traj_interval: Optional[int] = 1, ) -> Dict: """Runs ASE MD simulations. This is only recommended for small systems and for testing. For larger systems, use LAMMPS or more purpose-built code - :param calc_spec: calc specification - :type calc_spec: Dict + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param timestep: Timestep in ASE time units @@ -203,9 +241,13 @@ def ase_md( :rtype: Dict """ - calc = load_calc(**calc_spec) + calc = load_calc(calculator=calculator) atoms = get_atoms(**image) - atoms.set_calculator(calc) + atoms.calc = calc + + if time_unit == 'fs': + timestep *= fs + ttime *= fs assert dynamics in ['nvt', 'langevin', 'npt'], 'Invalid dynamics' if dynamics == 'langevin': @@ -216,6 +258,8 @@ def ase_md( traj_file='output.traj', timestep=timestep, friction=friction, + log_interval=log_interval, + traj_interval=traj_interval, ) elif dynamics == 'npt': atoms, _ = npt( @@ -227,6 +271,9 @@ def ase_md( pfactor=pfactor, externalstress=externalstress, ttime=ttime, + properties=properties, + log_interval=log_interval, + traj_interval=traj_interval, ) elif dynamics == 'nvt': atoms, _ = npt( @@ -237,11 +284,19 @@ def ase_md( timestep=timestep, pfactor=None, ttime=ttime, + properties=properties, + log_interval=log_interval, + traj_interval=traj_interval, ) + if plot_args is None: + plot_args = {} if plot: - plot_thermo(images={'image_file': 'output.traj'}) + plot_thermo( + images={'image_file': 'output.traj'}, + **plot_args + ) results = {} return results diff --git a/asimtools/asimmodules/benchmarking/__init__.py b/src/asimtools/asimmodules/benchmarking/__init__.py similarity index 100% rename from asimtools/asimmodules/benchmarking/__init__.py rename to src/asimtools/asimmodules/benchmarking/__init__.py diff --git a/src/asimtools/asimmodules/benchmarking/distribution.py b/src/asimtools/asimmodules/benchmarking/distribution.py new file mode 100644 index 0000000..23f0607 --- /dev/null +++ b/src/asimtools/asimmodules/benchmarking/distribution.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python +''' +Plots the distribution of various properties in a dataset of structures + +Author: mkphuthi@github.com + +''' +from typing import Dict, Optional, Sequence +import numpy as np +import matplotlib.pyplot as plt +from ase.units import kg, m as meters +from asimtools.utils import ( + get_images, +) + +cm = (meters / 100) + +def distribution( + images: Dict, + unit: str = 'eV', + bins: int = 50, + log: bool = True, + properties: Sequence[str] = ('energy', 'forces', 'stress', 'pressure', 'enthalpy'), + remap_keys: Optional[Dict] = None, +) -> Dict: + noncalc_properties = ['density', 'mass', 'natoms', 'volume'] + properties = list(properties) + properties += noncalc_properties + if remap_keys is None: + remap_keys = {} + unit_factors = {'meV': 1000, 'eV': 1, 'kcal/mol': 23.0621} + unit_factor = unit_factors[unit] + + unit_dict = { + 'energy': f'{unit}/atom', + 'forces': f'{unit}'+r'/$\AA$', + 'stress': f'{unit}'+r'/$\AA^3$', + 'volume': r'$\AA^3$/atom', + 'pressure': f'{unit}'+r'/$\AA^3$', + 'enthalpy': f'{unit}/atom', + 'density': f'g/cm^3', + 'mass': 'amu', + 'natoms': 'atoms', + } + images = get_images(**images) + results = {prop: [] for prop in properties} + for i, atoms in enumerate(images): + results['natoms'].append(len(atoms)) + if 'energy' in properties: + if remap_keys.get('energy', False): + energy = atoms.info[remap_keys['energy']] + else: + energy = atoms.get_potential_energy() + + results['energy'].append(energy) + if 'forces' in properties: + if remap_keys.get('forces', False): + forces = atoms.arrays[remap_keys['forces']] + else: + forces = atoms.get_forces() + + results['forces'].extend( + list(np.array(forces).flatten()) + ) + if 'volume' in properties: + results['volume'].append(atoms.get_volume()) + if 'stress' in properties: + if remap_keys.get('stress', False): + stress = atoms.arrays[remap_keys['stress']] + elif remap_keys.get('virial', False): + try: + stress = atoms.info[remap_keys['virial']] / atoms.get_volume() + except KeyError: + print('idx:', i, atoms.info, atoms.arrays) + else: + stress = atoms.get_stress(voigt=True) + + results['stress'].extend( + list(np.array(stress)) + ) + if 'pressure' in properties: + results['pressure'].append(-np.sum(stress[:3])/3) + if 'mass' in properties: + mass = np.sum(atoms.get_masses()) + results['mass'].append(mass) + + for prop in properties: + results[prop] = np.array(results[prop]) + + if 'density' in properties: + assert 'mass' in properties and 'volume' in properties, \ + 'Mass and volume must be included to calculate density' + results['density'] = ( + (results['mass'] / results['volume']) / (kg * 0.001) * (cm**3) + ) + if 'enthalpy' in properties: + assert 'energy' in properties and 'pressure' in properties and 'volume' in properties, \ + 'Energy, pressure and volume must be included to calculate enthalpy' + results['enthalpy'] = ( + results['energy'] + results['pressure'] * results['volume'] + ) + for prop in ['energy', 'volume', 'enthalpy']: + if prop in properties: + results[prop] = results[prop] / results['natoms'] + + for prop in ['forces', 'stress', 'pressure', 'energy', 'enthalpy']: + if prop in properties: + results[prop] = results[prop] * unit_factor + + for prop in properties: + with open(f'summary.txt', 'a+') as f: + f.write(f'{prop} distribution\n') + f.write(f'Num. values: {len(results[prop])}\n') + f.write(f'Mean: {np.mean(results[prop])} {unit_dict[prop]}\n') + f.write(f'Std: {np.std(results[prop])} {unit_dict[prop]}\n') + f.write(f'Min: {np.min(results[prop])} {unit_dict[prop]}\n') + f.write(f'Max: {np.max(results[prop])} {unit_dict[prop]}\n') + f.write('+++++++++++++++++++++++++++++++++++++++++++++++\n') + fig = plt.figure() + plt.hist(results[prop], bins=bins, density=True) + plt.xlabel(f'{prop} [{unit_dict[prop]}]') + plt.ylabel('Frequency') + if log: + plt.yscale('log') + plt.title(f'{prop} distribution') + plt.savefig(f'{prop}_distribution.png') + plt.close(fig) + return {} diff --git a/asimtools/asimmodules/benchmarking/parity.py b/src/asimtools/asimmodules/benchmarking/parity.py similarity index 89% rename from asimtools/asimmodules/benchmarking/parity.py rename to src/asimtools/asimmodules/benchmarking/parity.py index 1730cc9..780fd63 100644 --- a/asimtools/asimmodules/benchmarking/parity.py +++ b/src/asimtools/asimmodules/benchmarking/parity.py @@ -11,6 +11,7 @@ from functools import partial from multiprocessing import Pool import numpy as np +from tqdm import tqdm import matplotlib.pyplot as plt from asimtools.calculators import load_calc from asimtools.utils import ( @@ -23,7 +24,7 @@ Calculator = TypeVar('Calculator') def calc_parity_data( subset: List, - calc_id: str, + calculator: Dict, properties: Sequence = ('energy', 'forces', 'stress'), force_prob: float = 1.0, ) -> Dict: @@ -31,8 +32,8 @@ def calc_parity_data( :param subset: List of atoms instances :type subset: List - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param properties: Properties to evaluate, choose from "energy", \ "forces" and "stress", defaults to ('energy', 'forces', 'stress') :type properties: List, optional @@ -51,8 +52,10 @@ def calc_parity_data( fpvals = [] srvals = [] spvals = [] - for i, atoms in enumerate(subset): - calc = load_calc(calc_id) + for i, atoms in enumerate(tqdm(subset)): + calc = load_calc(calculator=calculator) + patoms = atoms.copy() + patoms.calc = calc n_atoms = len(atoms) if 'energy' in properties: prop = 'energy' @@ -60,7 +63,7 @@ def calc_parity_data( [ervals, atoms.get_potential_energy()/n_atoms] ) epvals = np.hstack( - [epvals, float(calc.get_potential_energy(atoms)/n_atoms)] + [epvals, float(patoms.get_potential_energy()/n_atoms)] ) if 'forces' in properties: @@ -71,17 +74,16 @@ def calc_parity_data( ) fpvals = np.hstack( - [fpvals, np.array(calc.get_forces(atoms)).flatten()] + [fpvals, np.array(patoms.get_forces()).flatten()] ) if 'stress' in properties: prop = 'stress' srvals = np.hstack( - [srvals, np.array(atoms.get_stress(voigt=False)).flatten()] + [srvals, np.array(atoms.get_stress(voigt=True)).flatten()] ) - spvals = np.hstack( - [spvals, np.array(calc.get_stress(atoms)).flatten()] + [spvals, np.array(patoms.get_stress(voigt=True)).flatten()] ) res[prop] = {'ref': srvals, 'pred': spvals} @@ -126,7 +128,7 @@ def rmse(yhat: Sequence, y: Sequence) -> float: def parity( images: Dict, - calc_id: str, + calculator: Dict, force_prob: float = 1.0, nprocs: int = 1, unit: str = 'meV', @@ -138,8 +140,8 @@ def parity( :param images: Images specification, see :func:`asimtools.utils.get_images` :type images: Dict - :param calc_id: ID of calculator provided in calc_input or global file - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param force_prob: Fraction of forces to consider in force parity, \ can be used for speeding up large structures by only subsampling\ randomly, defaults to 1.0 @@ -178,7 +180,7 @@ def parity( with Pool(nprocs) as pool: reses = pool.map(partial( calc_parity_data, - calc_id=calc_id, + calculator=calculator, properties=properties, force_prob=force_prob, ), @@ -187,7 +189,7 @@ def parity( else: reses = [calc_parity_data( subset, - calc_id=calc_id, + calculator=calculator, properties=properties, force_prob=force_prob, ) for subset in subsets diff --git a/asimtools/asimmodules/data/__init__.py b/src/asimtools/asimmodules/data/__init__.py similarity index 100% rename from asimtools/asimmodules/data/__init__.py rename to src/asimtools/asimmodules/data/__init__.py diff --git a/asimtools/asimmodules/data/collect_images.py b/src/asimtools/asimmodules/data/collect_images.py similarity index 51% rename from asimtools/asimmodules/data/collect_images.py rename to src/asimtools/asimmodules/data/collect_images.py index a2a1e70..e6255c9 100644 --- a/asimtools/asimmodules/data/collect_images.py +++ b/src/asimtools/asimmodules/data/collect_images.py @@ -6,9 +6,11 @@ from typing import Dict, Optional, Sequence import numpy as np +from pymatgen.io.ase import AseAtomsAdaptor as AAA +from pymatgen.analysis.structure_matcher import StructureMatcher from ase.io import write from asimtools.utils import ( - get_atoms, get_images, new_db + get_images, new_db ) @@ -18,10 +20,13 @@ def collect_images( fnames: Sequence[str] = ['output_images.xyz'], splits: Optional[Sequence[float]] = (1,), shuffle: bool = True, + sort_by_energy_per_atom: bool = False, + remove_duplicates: Optional[bool] = False, rename_keys: Optional[Dict] = None, energy_per_atom_limits: Optional[Sequence[float]] = None, force_max: Optional[float] = None, stress_limits: Optional[Sequence[float]] = None, + properties: Optional[tuple] = ('energy', 'forces', 'stress'), ) -> Dict: """Collects images into one file/database and can split them into multiple files/databases for ML tasks @@ -36,18 +41,29 @@ def collect_images( :type splits: Optional[Sequence[float]], optional :param shuffle: shuffle images before splitting, defaults to True :type shuffle: bool, optional - :param rename_keys: keys to rename on writing to the output file, defaults to None + :param sort_by_energy_per_atom: sort images before splitting, defaults to + False + :type sort_by_energy_per_atom: bool, optional + :param remove_duplicates: Whether to search for and remove duplicates with + :class:`pymatgen.analysis.structure_matcher.StructureMatcher`. This is + quite slow, defaults to False + :param rename_keys: keys to rename on writing to the output file, defaults + to None :type rename_keys: Optional[Dict], optional - :param energy_per_atom_limits: energy limits for filtering images, defaults to None + :param energy_per_atom_limits: energy limits for filtering images, + defaults to None :type energy_per_atom_limits: Optional[Sequence[float]], optional :param force_max: forces maximimum for filtering images, defaults to None :type force_max: Optional[float], optional :param stress_limits: stress limits for filtering images, defaults to None :type stress_limits: Optional[Sequence[float]], optional + :param properties: which of energy, force, stress to consider + :type Sequence, optional :return: results :rtype: Dict """ + write_kwargs = {} if fnames == (1): fnames = [f'{fnames}-{i:03d}' for i in range(len(splits))] @@ -58,44 +74,75 @@ def collect_images( selected_atoms = [] nonselected_atoms = [] for atoms in images: - energy = atoms.get_potential_energy() - forces = atoms.get_forces() - stress = atoms.get_stress() - select = True - if energy_per_atom_limits is not None: - if (energy < energy_per_atom_limits[0]): - if (energy > energy_per_atom_limits[1]): - select = False - if force_max is not None: - max_force = np.max(np.linalg.norm(forces, axis=1)) - if (max_force > force_max): - select = False - if stress_limits is not None: - max_stress = np.max(stress) - min_stress = np.min(stress) - if (min_stress < stress_limits[0]): - if (max_stress > stress_limits[1]): + if 'energy' in properties: + energy = atoms.get_potential_energy() + if energy_per_atom_limits is not None: + if (energy < energy_per_atom_limits[0]): + if (energy > energy_per_atom_limits[1]): + select = False + if 'forces' in properties: + forces = atoms.get_forces() + if force_max is not None: + max_force = np.max(np.linalg.norm(forces, axis=1)) + if (max_force > force_max): select = False - print(select) + if 'stress' in properties: + stress = atoms.get_stress() + if stress_limits is not None: + max_stress = np.max(stress) + min_stress = np.min(stress) + if (min_stress < stress_limits[0]): + if (max_stress > stress_limits[1]): + select = False if select: if rename_keys is not None and out_format == 'extxyz': write_kwargs['write_info'] = True - if 'energy' in rename_keys: + if 'energy' in rename_keys and 'energy' in properties: atoms.info[rename_keys['energy']] = energy - if 'forces' in rename_keys: + if 'forces' in rename_keys and 'forces' in properties: atoms.arrays[rename_keys['forces']] = forces - if 'stress' in rename_keys: + if 'stress' in rename_keys and 'stress' in properties: atoms.info[rename_keys['stress']] = stress selected_atoms.append(atoms) else: nonselected_atoms.append(atoms) write('nonselected_images.xyz', nonselected_atoms, format='extxyz') + + if remove_duplicates: + all_structs = [AAA.get_structure(atoms) for atoms in selected_atoms] + selected_inds = [0] + selected_structs = [all_structs[0]] + sm = StructureMatcher() + num_duplicates = 0 + for i, struct in enumerate(all_structs): + is_duplicate = False + for j, selected_struct in enumerate(selected_structs): + is_duplicate = sm.fit(struct, selected_struct) + if is_duplicate: + num_duplicates += 1 + break + if not is_duplicate: + selected_structs.append(struct) + selected_inds.append(i) + selected_atoms = [selected_atoms[i] for i in selected_inds] + if shuffle: + assert not sort_by_energy_per_atom, 'Either sort or shuffle, not both' np.random.shuffle(selected_atoms) - datasets = [] + elif sort_by_energy_per_atom: + assert not shuffle, 'Either sort or shuffle, not both' + e_per_atoms = [ + atoms.get_potential_energy() / len(atoms) for atoms \ + in selected_atoms + ] + sort_result = sorted( + zip(e_per_atoms, selected_atoms), key=lambda x: x[0] + ) + selected_atoms = [x[1] for x in sort_result] + start_index = 0 index_ranges = [] for i, split in enumerate(splits): diff --git a/asimtools/asimmodules/do_nothing.py b/src/asimtools/asimmodules/do_nothing.py similarity index 100% rename from asimtools/asimmodules/do_nothing.py rename to src/asimtools/asimmodules/do_nothing.py diff --git a/asimtools/asimmodules/elastic_constants/__init__.py b/src/asimtools/asimmodules/elastic_constants/__init__.py similarity index 100% rename from asimtools/asimmodules/elastic_constants/__init__.py rename to src/asimtools/asimmodules/elastic_constants/__init__.py diff --git a/asimtools/asimmodules/elastic_constants/cubic_energy_expansion.py b/src/asimtools/asimmodules/elastic_constants/cubic_energy_expansion.py similarity index 93% rename from asimtools/asimmodules/elastic_constants/cubic_energy_expansion.py rename to src/asimtools/asimmodules/elastic_constants/cubic_energy_expansion.py index d186cf3..d97d52f 100755 --- a/asimtools/asimmodules/elastic_constants/cubic_energy_expansion.py +++ b/src/asimtools/asimmodules/elastic_constants/cubic_energy_expansion.py @@ -80,16 +80,16 @@ def get_strained_atoms(atoms, strain: str, delta: float): return strained_atoms def cubic_energy_expansion( - calc_id: str, + calculator: Dict, image: Dict, deltas: Sequence[float] = (-0.01,-0.0075,-0.005,0.00,0.005,0.0075,0.01), ase_cubic_eos_args: Optional[Dict] = None, ) -> Dict: - """Calculates B (Bulk modulus), C11, C12 and C44 elastic constants of + """Calculates B (Bulk modulus), C11, C12 and C44 elastic constants of a structure with cubic symmetry - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param deltas: strains to apply in each direction, defaults to (-0.01,-0.0075,-0.005,0.00,0.005,0.0075,0.01) @@ -99,13 +99,13 @@ def cubic_energy_expansion( :return: Elastic constant results :rtype: Dict """ - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) atoms = get_atoms(**image) - atoms.set_calculator(calc) + atoms.calc = calc # Start by getting the Bulk modulus and optimized cell from the EOS logging.info('Calculating EOS') - eos_kwargs = {'image': image, 'calc_id': calc_id} + eos_kwargs = {'image': image, 'calculator': calculator} if ase_cubic_eos_args is not None: eos_kwargs.update(ase_cubic_eos_args) eos_results = eos(**eos_kwargs) @@ -122,7 +122,7 @@ def cubic_energy_expansion( c44_atoms = get_strained_atoms( atoms.copy(), 'mono_vol_cons', delta ) - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) c44_atoms.calc = calc c44_en = c44_atoms.get_potential_energy() c44_ens.append(c44_en) @@ -134,7 +134,7 @@ def cubic_energy_expansion( c11min12_atoms = get_strained_atoms( atoms.copy(), 'orth_vol_cons', delta ) - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) c11min12_atoms.calc = calc c11min12_en = c11min12_atoms.get_potential_energy() c11min12_ens.append(c11min12_en) diff --git a/asimtools/asimmodules/eos/__init__.py b/src/asimtools/asimmodules/eos/__init__.py similarity index 100% rename from asimtools/asimmodules/eos/__init__.py rename to src/asimtools/asimmodules/eos/__init__.py diff --git a/asimtools/asimmodules/eos/postprocess.py b/src/asimtools/asimmodules/eos/postprocess.py similarity index 100% rename from asimtools/asimmodules/eos/postprocess.py rename to src/asimtools/asimmodules/eos/postprocess.py diff --git a/asimtools/asimmodules/geometry_optimization/__init__.py b/src/asimtools/asimmodules/geometry_optimization/__init__.py similarity index 100% rename from asimtools/asimmodules/geometry_optimization/__init__.py rename to src/asimtools/asimmodules/geometry_optimization/__init__.py diff --git a/asimtools/asimmodules/geometry_optimization/ase_cubic_eos_optimization.py b/src/asimtools/asimmodules/geometry_optimization/ase_cubic_eos_optimization.py similarity index 95% rename from asimtools/asimmodules/geometry_optimization/ase_cubic_eos_optimization.py rename to src/asimtools/asimmodules/geometry_optimization/ase_cubic_eos_optimization.py index ef85f67..b0cd268 100644 --- a/asimtools/asimmodules/geometry_optimization/ase_cubic_eos_optimization.py +++ b/src/asimtools/asimmodules/geometry_optimization/ase_cubic_eos_optimization.py @@ -17,7 +17,7 @@ from asimtools.utils import get_atoms def ase_cubic_eos_optimization( - calc_id: str, + calculator: Dict, image: Dict, npoints: Optional[int] = 5, eos_string: Optional[str] = 'sj', @@ -27,8 +27,8 @@ def ase_cubic_eos_optimization( ) -> Dict: """Generate the energy-volume equation of state (energy calculations not parallelized) - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param npoints: Number of energy points to calculate, must be >5, defaults to 5 @@ -44,7 +44,7 @@ def ase_cubic_eos_optimization( :return: Equilibrium energy, volume, bulk modulus and factor by which to scale lattice parameter to get equilibrium structure :rtype: Dict """ - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) atoms = get_atoms(**image) atoms.calc = calc v_init = atoms.get_volume() diff --git a/asimtools/asimmodules/geometry_optimization/atom_relax.py b/src/asimtools/asimmodules/geometry_optimization/atom_relax.py similarity index 87% rename from asimtools/asimmodules/geometry_optimization/atom_relax.py rename to src/asimtools/asimmodules/geometry_optimization/atom_relax.py index 684ee55..f39732a 100755 --- a/asimtools/asimmodules/geometry_optimization/atom_relax.py +++ b/src/asimtools/asimmodules/geometry_optimization/atom_relax.py @@ -9,10 +9,10 @@ import ase.optimize from ase.io.trajectory import Trajectory from asimtools.calculators import load_calc -from asimtools.utils import get_atoms, get_logger +from asimtools.utils import get_atoms, get_logger, write_atoms def atom_relax( - calc_id: str, + calculator: Dict, image: Dict, optimizer: str = 'GPMin', #GPMin is fast in many cases according to ASE docs properties: Tuple[str] = ('energy', 'forces'), @@ -23,8 +23,8 @@ def atom_relax( """Relaxes the given tomic structure using ASE's built-in structure optimizers - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param prefix: Prefix of output files, defaults to '' @@ -38,9 +38,9 @@ def atom_relax( :return: Dictionary of results :rtype: Dict """ - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) atoms = get_atoms(**image) - atoms.set_calculator(calc) + atoms.calc = calc logger = get_logger() if prefix is not None: @@ -64,11 +64,10 @@ def atom_relax( raise image_file = prefix + 'image_output.xyz' - atoms.write( + write_atoms( image_file, + atoms, format='extxyz', - write_info=False, - write_results=True, ) energy = float(atoms.get_potential_energy()) diff --git a/asimtools/asimmodules/geometry_optimization/cell_relax.py b/src/asimtools/asimmodules/geometry_optimization/cell_relax.py similarity index 87% rename from asimtools/asimmodules/geometry_optimization/cell_relax.py rename to src/asimtools/asimmodules/geometry_optimization/cell_relax.py index 7c0d1b7..a0946a1 100755 --- a/asimtools/asimmodules/geometry_optimization/cell_relax.py +++ b/src/asimtools/asimmodules/geometry_optimization/cell_relax.py @@ -11,13 +11,13 @@ from typing import Dict, Optional, Sequence import ase.optimize -from ase.constraints import StrainFilter +from ase.filters import StrainFilter from ase.io.trajectory import Trajectory from asimtools.calculators import load_calc -from asimtools.utils import get_atoms, join_names +from asimtools.utils import get_atoms, join_names, write_atoms def cell_relax( - calc_id: str, + calculator: Dict, image: Dict, optimizer: str = 'BFGS', fmax: float = 0.002, @@ -27,8 +27,8 @@ def cell_relax( ) -> Dict: """Relax cell using ASE Optimizer - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param prefix: Prefix to output files, defaults to '' @@ -44,9 +44,9 @@ def cell_relax( :return: Dictionary of results including, final energy, stress and output files :rtype: Dict """ - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) atoms = get_atoms(**image) - atoms.set_calculator(calc) + atoms.calc = calc if prefix is not None: prefix = prefix + '_' @@ -70,11 +70,10 @@ def cell_relax( raise image_file = join_names([prefix, 'image_output.xyz'])[:-2] - atoms.write( + write_atoms( image_file, + atoms, format='extxyz', - write_info=False, - write_results=True, ) energy = float(atoms.get_potential_energy()) diff --git a/asimtools/asimmodules/geometry_optimization/optimize.py b/src/asimtools/asimmodules/geometry_optimization/optimize.py similarity index 87% rename from asimtools/asimmodules/geometry_optimization/optimize.py rename to src/asimtools/asimmodules/geometry_optimization/optimize.py index 56850cf..804e0f5 100755 --- a/asimtools/asimmodules/geometry_optimization/optimize.py +++ b/src/asimtools/asimmodules/geometry_optimization/optimize.py @@ -6,13 +6,13 @@ ''' from typing import Dict, Optional import ase.optimize -from ase.constraints import ExpCellFilter +from ase.filters import ExpCellFilter from ase.io.trajectory import Trajectory from asimtools.calculators import load_calc -from asimtools.utils import get_atoms +from asimtools.utils import get_atoms, write_atoms def optimize( - calc_id: str, + calculator: Dict, image: Dict, optimizer: str = 'BFGS', fmax: float = 0.003, #Roughly 0.48GPa @@ -22,8 +22,8 @@ def optimize( ) -> Dict: """Relaxes cell (and atoms) using ase.constraints.ExpCellFilter while retaining symmetry - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param optimizer: Any optimizer from ase.optimize, defaults to 'BFGS' @@ -44,9 +44,9 @@ def optimize( if optimizer_args is None: optimizer_args = {} - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) atoms = get_atoms(**image) - atoms.set_calculator(calc) + atoms.calc = calc ecf = ExpCellFilter(atoms, **expcellfilter_args) @@ -66,11 +66,10 @@ def optimize( raise image_file = 'image_output.xyz' - atoms.write( + write_atoms( image_file, + atoms, format='extxyz', - write_info=False, - write_results=True, ) energy = float(atoms.get_potential_energy()) diff --git a/asimtools/asimmodules/geometry_optimization/symmetric_cell_relax.py b/src/asimtools/asimmodules/geometry_optimization/symmetric_cell_relax.py similarity index 84% rename from asimtools/asimmodules/geometry_optimization/symmetric_cell_relax.py rename to src/asimtools/asimmodules/geometry_optimization/symmetric_cell_relax.py index d134808..221c0b3 100755 --- a/asimtools/asimmodules/geometry_optimization/symmetric_cell_relax.py +++ b/src/asimtools/asimmodules/geometry_optimization/symmetric_cell_relax.py @@ -6,14 +6,14 @@ ''' from typing import Dict, Optional import ase.optimize -from ase.constraints import ExpCellFilter -from ase.spacegroup.symmetrize import FixSymmetry +from ase.filters import ExpCellFilter +from ase.constraints import FixSymmetry from ase.io.trajectory import Trajectory from asimtools.calculators import load_calc -from asimtools.utils import get_atoms +from asimtools.utils import get_atoms, write_atoms def symmetric_cell_relax( - calc_id: str, + calculator: Dict, image: Dict, optimizer: str = 'BFGS', fmax: float = 0.003, #Roughly 0.48GPa @@ -23,8 +23,8 @@ def symmetric_cell_relax( ) -> Dict: """Relaxes cell (and atoms) using ase.constraints.ExpCellFilter while retaining symmetry - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param optimizer: Any optimizer from ase.optimize, defaults to 'BFGS' @@ -45,9 +45,9 @@ def symmetric_cell_relax( if optimizer_args is None: optimizer_args = {} - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) atoms = get_atoms(**image) - atoms.set_calculator(calc) + atoms.calc = calc atoms.set_constraint(FixSymmetry(atoms, **fixsymmetry_args)) ecf = ExpCellFilter(atoms, **expcellfilter_args) @@ -68,11 +68,11 @@ def symmetric_cell_relax( raise image_file = 'image_output.xyz' - atoms.write( + atoms.constraints = [] # write doesn't work with FixSymmetry constraint + write_atoms( image_file, + atoms, format='extxyz', - write_info=False, - write_results=True, ) energy = float(atoms.get_potential_energy()) diff --git a/asimtools/asimmodules/lammps/__init__.py b/src/asimtools/asimmodules/lammps/__init__.py similarity index 100% rename from asimtools/asimmodules/lammps/__init__.py rename to src/asimtools/asimmodules/lammps/__init__.py diff --git a/asimtools/asimmodules/lammps/lammps.py b/src/asimtools/asimmodules/lammps/lammps.py similarity index 74% rename from asimtools/asimmodules/lammps/lammps.py rename to src/asimtools/asimmodules/lammps/lammps.py index 50d7f20..9888e6c 100755 --- a/asimtools/asimmodules/lammps/lammps.py +++ b/src/asimtools/asimmodules/lammps/lammps.py @@ -4,7 +4,7 @@ Author: mkphuthi@github.com ''' -from typing import Dict, Optional +from typing import Dict, Optional, Sequence import sys from pathlib import Path from numpy.random import randint @@ -22,7 +22,10 @@ def lammps( placeholders: Optional[Dict] = None, lmp_cmd: str = 'lmp', masses: bool = True, + velocities: bool = False, seed: Optional[int] = None, + restart_template: Optional[str] = None, + specorder: Sequence[str] = None, ) -> Dict: """Runs a lammps script based on a specified template, variables can be specified as arguments to be defined in the final LAMMPS input file if @@ -46,12 +49,20 @@ def lammps( :type lmp_cmd: str, optional :param masses: Whether to specify atomic masses in LAMMPS data input, requires ASE>3.23.0, defaults to True - :type masses: bool, optional + :param velocities: Whether to specify atomic velocities in LAMMPS data input, + requires ASE>3.23.0, defaults to False + :type velocities: bool, optional :param seed: Random seed for anywhere necessary in the template. You will need to put the 'SEED' placeholder anywhere you want a random seed to be placed, if seed=None, a random one is generated, defaults to None :type seed: int, optional + :param restart_template: Optional lammps input template to be used to + generate a restart.lammps file, defaults to None + :type restart_template: str, optional + :param specorder: Optional list of atomic species in the order they + should appear in the LAMMPS data input file, defaults to None + :type specorder: Sequence[str], optional :return: LAMMPS out file names :rtype: Dict """ @@ -62,15 +73,17 @@ def lammps( # in arbitrary image provided by asimtools if image is not None: atoms = get_atoms(**image) - if masses: + if masses or velocities: try: atoms.write( 'image_input.lmpdat', format='lammps-data', atom_style=atom_style, masses=masses, + velocities=velocities, + specorder=specorder, ) - except TypeError as te: + except ValueError as te: err_txt = 'Need ASE version >=3.23 to support writing ' err_txt += 'masses to lammps input file. Add mass keyword to ' err_txt += 'lammps template instead' @@ -88,6 +101,7 @@ def lammps( variables['IMAGE_FILE'] = 'image_input.lmpdat' lmp_txt = '' + for variable, value in variables.items(): lmp_txt += f'variable {variable} equal {value}\n' @@ -99,14 +113,27 @@ def lammps( if placeholders is None: placeholders = {} + if restart_template is not None: + restart_txt = lmp_txt + with open(restart_template, 'r', encoding='utf-8') as f: + restart_lines = f.readlines() + for rline in restart_lines: + if placeholders is not None: + for placeholder in placeholders: + rline = rline.replace(placeholder, str(placeholders[placeholder])) + restart_txt += rline + with open('restart.lammps', 'w', encoding='utf-8') as f: + f.write(restart_txt) + for line in lines: if 'SEED' in line and 'SEED' not in placeholders: if seed is None: seed = str(randint(0, 100000)) - line = line.replace('SEED', seed) + line = line.replace('SEED', str(seed)) - for placeholder in placeholders: - line = line.replace(placeholder, placeholders[placeholder]) + if placeholders is not None: + for placeholder in placeholders: + line = line.replace(placeholder, str(placeholders[placeholder])) lmp_txt += line if image is not None: @@ -134,6 +161,7 @@ def lammps( logging.error(err_txt) with open('lmp_stderr.txt', 'a+', encoding='utf-8') as f: f.write(completed_process.stderr) + completed_process.check_returncode() return {} diff --git a/src/asimtools/asimmodules/lammps/utils.py b/src/asimtools/asimmodules/lammps/utils.py new file mode 100644 index 0000000..3386767 --- /dev/null +++ b/src/asimtools/asimmodules/lammps/utils.py @@ -0,0 +1,56 @@ +'''Utility functions for reading and processing LAMMPS output files.''' +from pathlib import Path +import numpy as np + + +def read_lammps_log(logfile, skip_failed=False): + '''Read a LAMMPS log file and return thermodynamic data and metadata.''' + with open(logfile, 'r', encoding='utf-8') as f: + logtxt = f.readlines() + + starts = [] + stops = [] + natoms = None + for i, line in enumerate(logtxt): + if 'Step' in line: + starts.append(i + 1) + if len(starts) > len(stops): + if 'Loop time' in line or 'WARNING: Pair style restartinfo' in line: + stops.append(i) + if 'atoms' in line: + atoms_line = line.split() + if len(atoms_line) == 2: + try: + natoms = int(atoms_line[0]) + except ValueError: + pass + + if natoms is None: + if 'Loop time' in line: + try: + natoms = int(line.split()[-2]) + except ValueError: + pass + + if skip_failed and (len(starts) != len(stops) or len(starts) == 0): + print(f"Incomplete run for {Path(logfile).resolve()}") + return False + + assert len(starts) != 0, f"No data in {logfile}" + assert natoms is not None, f"Could not find natoms in {logfile}" + + if len(starts) > len(stops): + stops.append(-5) + + headings = logtxt[starts[0]-1].split() + data = np.empty(len(headings)) + for start, stop in zip(starts, stops): + for data_line in logtxt[start:stop]: + data = np.vstack([data, np.fromstring(data_line, dtype=float, sep=' ')]) + + metadata = { + 'natoms': natoms, + 'columns': headings, + } + + return data[1:,:], metadata diff --git a/asimtools/asimmodules/mace/train_mace.py b/src/asimtools/asimmodules/mace/train_mace.py similarity index 100% rename from asimtools/asimmodules/mace/train_mace.py rename to src/asimtools/asimmodules/mace/train_mace.py diff --git a/asimtools/asimmodules/phonons/__init__.py b/src/asimtools/asimmodules/phonons/__init__.py similarity index 100% rename from asimtools/asimmodules/phonons/__init__.py rename to src/asimtools/asimmodules/phonons/__init__.py diff --git a/asimtools/asimmodules/phonons/ase_phonons.py b/src/asimtools/asimmodules/phonons/ase_phonons.py similarity index 93% rename from asimtools/asimmodules/phonons/ase_phonons.py rename to src/asimtools/asimmodules/phonons/ase_phonons.py index 9a4638f..1f29c40 100755 --- a/asimtools/asimmodules/phonons/ase_phonons.py +++ b/src/asimtools/asimmodules/phonons/ase_phonons.py @@ -14,7 +14,7 @@ from asimtools.utils import get_atoms def ase_phonons( - calc_id: str, + calculator: Dict, image: Dict, path: str, delta: float = 0.01, @@ -23,8 +23,8 @@ def ase_phonons( ) -> Dict: """Calculates phonon spectrum and DOS using ASE - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param path: Path in BZ for plot @@ -40,7 +40,7 @@ def ase_phonons( """ atoms = get_atoms(**image) - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) ph = Phonons(atoms, calc, supercell=supercell, delta=delta) try: diff --git a/asimtools/asimmodules/phonopy/__init__.py b/src/asimtools/asimmodules/phonopy/__init__.py similarity index 100% rename from asimtools/asimmodules/phonopy/__init__.py rename to src/asimtools/asimmodules/phonopy/__init__.py diff --git a/asimtools/asimmodules/phonopy/forces.py b/src/asimtools/asimmodules/phonopy/forces.py similarity index 87% rename from asimtools/asimmodules/phonopy/forces.py rename to src/asimtools/asimmodules/phonopy/forces.py index 41e47aa..fdc51cc 100644 --- a/asimtools/asimmodules/phonopy/forces.py +++ b/src/asimtools/asimmodules/phonopy/forces.py @@ -8,7 +8,7 @@ def forces( images: Dict, - calc_id: str, + calculator: Dict, calc_env_id: Optional[str] = None, **kwargs, ) -> Dict: @@ -16,8 +16,8 @@ def forces( :param images: Images specification, see :func:`asimtools.utils.get_images` :type images: Dict - :param calc_id: calc_id specification, see :func:`asimtools.utils.get_calc` - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param calc_env_id: env_id to use for the calculator, defaults to None :type calc_env_id: Optional[str], optional :param kwargs: Additional keyword arguments to pass to image_array @@ -29,7 +29,7 @@ def forces( singlepoint_input={ 'asimmodule': 'singlepoint', 'args': { - 'calc_id': calc_id, + 'calculator': calculator, 'properties': ['energy', 'forces'] }, } diff --git a/asimtools/asimmodules/phonopy/full_qha.py b/src/asimtools/asimmodules/phonopy/full_qha.py similarity index 93% rename from asimtools/asimmodules/phonopy/full_qha.py rename to src/asimtools/asimmodules/phonopy/full_qha.py index d6850c5..8fedf53 100644 --- a/asimtools/asimmodules/phonopy/full_qha.py +++ b/src/asimtools/asimmodules/phonopy/full_qha.py @@ -4,7 +4,7 @@ def full_qha( image: Dict, - calc_id: str, + calculator: Dict, phonopy_save_path: Optional[str] = None, calc_env_id: Optional[str] = None, process_env_id: Optional[str] = None, @@ -12,6 +12,7 @@ def full_qha( supercell: Sequence = [5,5,5], t_max: float = 1000, pressure: Optional[float] = None, + distance: Optional[float] = 0.02, ) -> Dict: """Perform a full Quasiharmonic Approximation and predict thermal properties of a given structure. Calculated properties include @@ -19,8 +20,8 @@ def full_qha( :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param phonopy_save_path: Path where phonopy save yaml is saved, this file is important to keep for easier postprocessing/analsyis, we recommend keeping the default, defaults to None @@ -41,6 +42,8 @@ def full_qha( :type t_max: float, optional :param pressure: Pressure to optimize to, defaults to None :type pressure: Optional[float], optional + :param distance: Dispacement distance for phonons, defaults to 0.02 + :type distance: Optional[float], optional :return: Nothing :rtype: Dict """ @@ -50,7 +53,7 @@ def full_qha( else: phonopy_save_path = str(Path(phonopy_save_path).resolve()) ase_cubic_eos_args['image'] = image - ase_cubic_eos_args['calc_id'] = calc_id + ase_cubic_eos_args['calculator'] = calculator scales = ase_cubic_eos_args.get('scales', False) if scales: npoints = len(scales) @@ -84,7 +87,7 @@ def full_qha( 'index': {} }, 'supercell': supercell, - 'distance': 0.02, + 'distance': distance, 'phonopy_save_path': phonopy_save_path, }, }, @@ -96,7 +99,7 @@ def full_qha( 'pattern': '../step-0/supercell-*', 'format': 'vasp', }, - 'calc_id': calc_id, + 'calculator': calculator, 'calc_env_id': calc_env_id, }, }, diff --git a/asimtools/asimmodules/phonopy/generate_phonopy_displacements.py b/src/asimtools/asimmodules/phonopy/generate_phonopy_displacements.py similarity index 66% rename from asimtools/asimmodules/phonopy/generate_phonopy_displacements.py rename to src/asimtools/asimmodules/phonopy/generate_phonopy_displacements.py index ce187ec..f12a051 100755 --- a/asimtools/asimmodules/phonopy/generate_phonopy_displacements.py +++ b/src/asimtools/asimmodules/phonopy/generate_phonopy_displacements.py @@ -7,14 +7,19 @@ from phonopy import Phonopy from phonopy.interface.calculator import read_crystal_structure, write_crystal_structure from phonopy.structure.atoms import PhonopyAtoms +from ase import Atoms +from ase.spacegroup.symmetrize import check_symmetry from asimtools.calculators import load_calc from asimtools.utils import get_atoms, get_logger +import spglib def generate_phonopy_displacements( image: Dict, supercell: Sequence[int], distance: float = 0.01, - phonopy_save_path: str = 'phonopy_params.yaml' + phonopy_save_path: str = 'phonopy_params.yaml', + refine_cell: bool = False, + symprec: float = 1e-4, ) -> Dict: """Generates displacements for phonopy calculations @@ -31,6 +36,26 @@ def generate_phonopy_displacements( """ atoms = get_atoms(**image) + unrefined_sg = check_symmetry(atoms, symprec=symprec).international + if refine_cell: + cell = ( + atoms.get_cell().array, + atoms.get_scaled_positions(), + atoms.get_atomic_numbers() + ) + + refined_cell = spglib.standardize_cell(cell, symprec=symprec) + atoms = Atoms( + cell=refined_cell[0], + scaled_positions=refined_cell[1], + numbers=refined_cell[2], + pbc=True, + ) + atoms.write('refined_atoms.cif') + sg = check_symmetry(atoms, symprec=1e-6).international + else: + sg = None + atoms.write('POSCAR-unitcell', format='vasp') unitcell, _ = read_crystal_structure( @@ -61,4 +86,8 @@ def generate_phonopy_displacements( phonon.save(phonopy_save_path) - return {} + results = { + 'unrefined_spacegroup': str(unrefined_sg), + 'refined_spacegroup': str(sg), + } + return results diff --git a/asimtools/asimmodules/phonopy/phonon_bands_and_dos.py b/src/asimtools/asimmodules/phonopy/phonon_bands_and_dos.py similarity index 89% rename from asimtools/asimmodules/phonopy/phonon_bands_and_dos.py rename to src/asimtools/asimmodules/phonopy/phonon_bands_and_dos.py index 3bd9588..f8b9b99 100644 --- a/asimtools/asimmodules/phonopy/phonon_bands_and_dos.py +++ b/src/asimtools/asimmodules/phonopy/phonon_bands_and_dos.py @@ -4,18 +4,11 @@ import os import numpy as np from numpy.typing import ArrayLike -# import matplotlib.pyplot as plt -# from ase.io import read -# import phonopy -# from phonopy.phonon.band_structure import get_band_qpoints_and_path_connections -# from asimtools.calculators import load_calc -# from asimtools.asimmodules.workflows.image_array import image_array -# from asimtools.utils import get_str_btn, get_images from asimtools.job import UnitJob def phonon_bands_and_dos( image: Dict, - calc_id: str, + calculator: Dict, calc_env_id: str, process_env_id: str, supercell: ArrayLike = [10,10,10], @@ -26,13 +19,15 @@ def phonon_bands_and_dos( use_seekpath: Optional[bool] = True, npoints: Optional[int] = 51, mesh: Optional[Union[ArrayLike,float]] = [20, 20, 20], + refine_cell: bool = False, + symprec: float = 1e-4, ) -> Dict: """Workflow to calculate phonon bands and density of states using phonopy. :param image: Image specification. See :ref:`asimtools.utils.get_image`. :type image: Dict - :param calc_id: calc_id of the calculator to use. - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param calc_env_id: env_id of the calculator to use. :type calc_env_id: str :param process_env_id: env_id for pre- and post-processing. @@ -77,6 +72,8 @@ def phonon_bands_and_dos( 'supercell': supercell, 'distance': distance, 'phonopy_save_path': phonopy_save_path, + 'refine_cell': refine_cell, + 'symprec': symprec, }, }, 'step-1': { @@ -87,7 +84,7 @@ def phonon_bands_and_dos( 'pattern': '../step-0/supercell-*', 'format': 'vasp', }, - 'calc_id': calc_id, + 'calculator': calculator, }, }, 'step-2': { diff --git a/asimtools/asimmodules/phonopy/phonon_bands_and_dos_from_forces.py b/src/asimtools/asimmodules/phonopy/phonon_bands_and_dos_from_forces.py similarity index 100% rename from asimtools/asimmodules/phonopy/phonon_bands_and_dos_from_forces.py rename to src/asimtools/asimmodules/phonopy/phonon_bands_and_dos_from_forces.py diff --git a/asimtools/asimmodules/phonopy/qha_properties.py b/src/asimtools/asimmodules/phonopy/qha_properties.py similarity index 100% rename from asimtools/asimmodules/phonopy/qha_properties.py rename to src/asimtools/asimmodules/phonopy/qha_properties.py diff --git a/asimtools/asimmodules/phonopy/read_force_constants.py b/src/asimtools/asimmodules/phonopy/read_force_constants.py similarity index 100% rename from asimtools/asimmodules/phonopy/read_force_constants.py rename to src/asimtools/asimmodules/phonopy/read_force_constants.py diff --git a/asimtools/asimmodules/phonopy/thermal_properties.py b/src/asimtools/asimmodules/phonopy/thermal_properties.py similarity index 91% rename from asimtools/asimmodules/phonopy/thermal_properties.py rename to src/asimtools/asimmodules/phonopy/thermal_properties.py index 4cd3993..3824fbb 100644 --- a/asimtools/asimmodules/phonopy/thermal_properties.py +++ b/src/asimtools/asimmodules/phonopy/thermal_properties.py @@ -22,8 +22,11 @@ def thermal_properties( if run_thermal_properties_kwargs is None: run_thermal_properties_kwargs = {} + print('Loading phonopy save...') phonon = phonopy.load(phonopy_save_path) + print('Running mesh...') phonon.run_mesh(mesh, **run_mesh_kwargs) + print('Running thermal properties...') phonon.run_thermal_properties( t_step=t_step, t_max=t_max, @@ -33,6 +36,7 @@ def thermal_properties( phonon.save(phonopy_save_path) # Write and plot thermal properties + print('Write and plot thermal properties...') if suffix != '': suffix = '-' + suffix tp_label = 'thermal_properties' + suffix diff --git a/asimtools/asimmodules/singlepoint.py b/src/asimtools/asimmodules/singlepoint.py similarity index 80% rename from asimtools/asimmodules/singlepoint.py rename to src/asimtools/asimmodules/singlepoint.py index 9347a6e..55151e4 100755 --- a/asimtools/asimmodules/singlepoint.py +++ b/src/asimtools/asimmodules/singlepoint.py @@ -10,10 +10,11 @@ from asimtools.calculators import load_calc from asimtools.utils import ( get_atoms, + write_atoms, ) def singlepoint( - calc_id: str, + calculator: Dict, image: Dict, properties: Tuple[str] = ('energy', 'forces'), prefix: Optional[str] = None, @@ -21,8 +22,10 @@ def singlepoint( """Evaluates the properties of a single image, currently implemented properties are energy, forces and stress - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification with either a ``calc_id`` key + to look up from the global calc_input or a ``calc_params`` key to use + directly, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param properties: properties to evaluate, defaults to ('energy', 'forces') @@ -30,15 +33,15 @@ def singlepoint( :return: Dictionary of results :rtype: Dict """ - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) atoms = get_atoms(**image) - atoms.set_calculator(calc) + atoms.calc = calc if prefix is not None: prefix = prefix + '_' else: prefix = '' - import time; time.sleep(20) + columns_append = [] if 'energy' in properties: try: energy = atoms.get_potential_energy() @@ -54,6 +57,7 @@ def singlepoint( except Exception: logging.error('Failed to calculate forces') raise + columns_append += ['forces'] if 'stress' in properties: try: @@ -64,11 +68,10 @@ def singlepoint( raise image_file = prefix + 'image_output.xyz' - atoms.write( + write_atoms( image_file, + atoms, format='extxyz', - write_info=False, - write_results=True, ) results = { diff --git a/asimtools/asimmodules/surface_energies/__init__.py b/src/asimtools/asimmodules/surface_energies/__init__.py similarity index 100% rename from asimtools/asimmodules/surface_energies/__init__.py rename to src/asimtools/asimmodules/surface_energies/__init__.py diff --git a/asimtools/asimmodules/surface_energies/surface_energies.py b/src/asimtools/asimmodules/surface_energies/surface_energies.py similarity index 58% rename from asimtools/asimmodules/surface_energies/surface_energies.py rename to src/asimtools/asimmodules/surface_energies/surface_energies.py index 2d389eb..9585c33 100755 --- a/asimtools/asimmodules/surface_energies/surface_energies.py +++ b/src/asimtools/asimmodules/surface_energies/surface_energies.py @@ -14,6 +14,7 @@ from pymatgen.io.ase import AseAtomsAdaptor as AAA from asimtools.calculators import load_calc from asimtools.asimmodules.geometry_optimization.atom_relax import atom_relax +from asimtools.asimmodules.singlepoint import singlepoint from asimtools.utils import ( get_atoms, ) @@ -36,8 +37,8 @@ def get_surface_energy(slab, calc, bulk_e_per_atom): return converged, surf_en, slab_en, area def surface_energies( - calc_id: str, image: Dict, + calculator: Dict = None, millers: Union[str,Sequence] = 'all', atom_relax_args: Optional[Dict] = None, generate_all_slabs_args: Optional[Dict] = None, @@ -45,8 +46,8 @@ def surface_energies( """Calculates surface energies of slabs defined by args specified for pymatgen.core.surface.generate_all_slabs() - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param millers: List of miller indices to consider in the form 'xyz', @@ -63,12 +64,9 @@ def surface_energies( :rtype: Dict """ - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) bulk = get_atoms(**image) - bulk.set_calculator(calc) - - if atom_relax_args is None: - atom_relax_args = {} + bulk.calc = calc default_pymatgen_kwargs = { 'max_index': 3, @@ -86,55 +84,61 @@ def surface_energies( bulk_struct, **pymargs ) - + print('Generated %s slabs', len(slabs)) logging.info('Generated %s distinct slabs', len(slabs)) n_bulk = len(bulk) bulk_e_per_atom = bulk.get_potential_energy() / n_bulk bulk.write('bulk.xyz') slab_dict = {} - for s, slab in enumerate(slabs): + for _, slab in enumerate(slabs): big_slab = slab.copy() mindex = slab.miller_index miller = f'{mindex[0]}{mindex[1]}{mindex[2]}' if millers == 'all' or miller in millers: logging.info('Calculating for %s', miller) atoms = AAA.get_atoms(big_slab) - atoms.write(f'{miller}.xyz') - - relax_results = atom_relax( - calc_id=calc_id, - image={'atoms': atoms}, - optimizer=atom_relax_args.get('optimizer', 'BFGS'), - properties=('energy','forces'), - fmax=atom_relax_args.get('fmax', 0.01), - prefix=f'{miller}_relaxed' - ) - atoms = get_atoms( - image_file = relax_results.get('files', {}).get('image') - ) - - assert np.allclose(atoms.pbc, (True, True, True)), \ - f'Check pbcs for {miller}: {atoms.pbc}' - assert atoms.cell[2][2] > pymargs['min_vacuum_size'] + \ - pymargs['min_slab_size'], \ - f'Check layer number and vacuum for {miller}' - assert miller not in slab_dict, \ - f'Multiple terminations for {miller}' - + atoms.write(f'miller-{miller}.xyz') slab_dict[miller] = {} - converged, surf_en, slab_en, area = get_surface_energy( - atoms, load_calc(calc_id), bulk_e_per_atom - ) - - if converged: - slab_dict[miller]['surf_energy'] = float(surf_en) - slab_dict[miller]['natoms'] = len(atoms) - slab_dict[miller]['slab_energy'] = float(slab_en) - slab_dict['bulk_energy_per_atom'] = float(bulk_e_per_atom) - slab_dict[miller]['area'] = float(area) - atoms.write(f'{miller}.xyz') + if atom_relax_args is not None: + relax_results = atom_relax( + calculator=calculator, + image={'atoms': atoms}, + optimizer=atom_relax_args.get('optimizer', 'BFGS'), + properties=('energy','forces'), + fmax=atom_relax_args.get('fmax', 0.01), + prefix=f'{miller}_relaxed' + ) + + atoms = get_atoms( + image_file = relax_results.get('files', {}).get('image') + ) + + assert np.allclose(atoms.pbc, (True, True, True)), \ + f'Check pbcs for {miller}: {atoms.pbc}' + assert atoms.cell[2][2] > pymargs['min_vacuum_size'] + \ + pymargs['min_slab_size'], \ + f'Check layer number and vacuum for {miller}' + assert miller not in slab_dict, \ + f'Multiple terminations for {miller}' + + converged, surf_en, slab_en, area = get_surface_energy( + atoms, load_calc(calculator=calculator), bulk_e_per_atom + ) + + if converged: + slab_dict[miller]['surf_energy'] = float(surf_en) + slab_dict[miller]['natoms'] = len(atoms) + slab_dict[miller]['slab_energy'] = float(slab_en) + slab_dict['bulk_energy_per_atom'] = float(bulk_e_per_atom) + slab_dict[miller]['area'] = float(area) + atoms.write(f'{miller}.xyz') + else: + logging.warning('Slab %s not converged', miller) + + assert len(slab_dict) > 0, \ + 'No slabs generated. Check your args for miller indices' results = { 'surface_energies': slab_dict, } diff --git a/asimtools/asimmodules/transformations/__init__.py b/src/asimtools/asimmodules/transformations/__init__.py similarity index 100% rename from asimtools/asimmodules/transformations/__init__.py rename to src/asimtools/asimmodules/transformations/__init__.py diff --git a/asimtools/asimmodules/transformations/scale_unit_cells.py b/src/asimtools/asimmodules/transformations/delete_atoms.py similarity index 100% rename from asimtools/asimmodules/transformations/scale_unit_cells.py rename to src/asimtools/asimmodules/transformations/delete_atoms.py diff --git a/src/asimtools/asimmodules/transformations/scale_unit_cells.py b/src/asimtools/asimmodules/transformations/scale_unit_cells.py new file mode 100644 index 0000000..9c49976 --- /dev/null +++ b/src/asimtools/asimmodules/transformations/scale_unit_cells.py @@ -0,0 +1,79 @@ +''' +Produce a set of images with unit cells scaled compared to the input + +author: mkphuthi@github.com +''' + +from typing import Dict, Optional, Sequence +import numpy as np +from ase.io import write +from asimtools.utils import ( + get_atoms, +) + +def apply_scale(old_atoms, scale): + ''' Applies a scaling factor to a unit cell ''' + atoms = old_atoms.copy() + new_cell = atoms.get_cell() * scale + atoms.set_cell(new_cell, scale_atoms=True) + atoms.info['scale'] = f'{scale:.3f}' + return atoms + +def scale_unit_cells( + image: Dict, + scales: Optional[Sequence] = None, + logspace: Optional[Sequence] = None, + linspace: Optional[Sequence] = None, + scale_by: str = 'a', +) -> Dict: + """Produce a set of images with unit cells scaled compared to the input + + :param image: Image specification, see :func:`asimtools.utils.get_atoms` + :type image: Dict + :param scales: Scaling values by which to scale cell, defaults to None + :type scales: Optional[Sequence], optional + :param logspace: Parameters to pass to np.logspace for scaling values, + defaults to None + :type logspace: Optional[Sequence], optional + :param linspace: Parameters to pass to np.linspace for scaling values, + defaults to None + :type linspace: Optional[Sequence], optional + :param scale_by: Scale either "volume" or "a" which is lattice parameter, + defaults to 'a' + :type scale_by: str, optional + :raises ValueError: If more than one of scales, linspace, logspace are + provided + :return: Path to xyz file + :rtype: Dict + """ + + assert scale_by in ['volume', 'a'], \ + 'Only scaling by "a" and "volume" allowed' + + if (scales is None and linspace is None and logspace is not None): + scales = np.logspace(*logspace) + elif (scales is None and linspace is not None and logspace is None): + scales = np.linspace(*linspace) + elif (scales is not None and linspace is None and logspace is None): + pass + else: + raise ValueError( + 'Provide only one of factors, factor_logspacem factor_linspace' + ) + + atoms = get_atoms(**image) + + scales = np.array(scales) + if scale_by == 'volume': + scales = scales**(1/3) + + # Make a database of structures with the volumes scaled appropriately + scaled_images = [] + for scale in scales: + new_atoms = apply_scale(atoms, scale) + scaled_images.append(new_atoms) + + scaled_images_file = 'scaled_unitcells_output.xyz' + write(scaled_images_file, scaled_images, format='extxyz') + + return {'files': {'images': scaled_images_file}} diff --git a/asimtools/asimmodules/vacancy_formation_energy/__init__.py b/src/asimtools/asimmodules/vacancy_formation_energy/__init__.py similarity index 100% rename from asimtools/asimmodules/vacancy_formation_energy/__init__.py rename to src/asimtools/asimmodules/vacancy_formation_energy/__init__.py diff --git a/asimtools/asimmodules/vacancy_formation_energy/vacancy_formation_energy.py b/src/asimtools/asimmodules/vacancy_formation_energy/vacancy_formation_energy.py similarity index 92% rename from asimtools/asimmodules/vacancy_formation_energy/vacancy_formation_energy.py rename to src/asimtools/asimmodules/vacancy_formation_energy/vacancy_formation_energy.py index 9cf1aab..e72b332 100755 --- a/asimtools/asimmodules/vacancy_formation_energy/vacancy_formation_energy.py +++ b/src/asimtools/asimmodules/vacancy_formation_energy/vacancy_formation_energy.py @@ -15,7 +15,7 @@ ) def vacancy_formation_energy( - calc_id: str, + calculator: Dict, image: Dict, vacancy_index: int = 0, atom_relax_args: Optional[Dict] = None, @@ -24,8 +24,8 @@ def vacancy_formation_energy( ) -> Dict: """Calculates the monovacancy formation energy from a bulk structure - :param calc_id: calc_id specification - :type calc_id: str + :param calculator: Calculator specification, see :func:`asimtools.calculators.load_calc` + :type calculator: Dict :param image: Image specification, see :func:`asimtools.utils.get_atoms` :type image: Dict :param vacancy_index: Index of atom to remove, defaults to 0 @@ -47,9 +47,9 @@ def vacancy_formation_energy( :rtype: Dict """ - calc = load_calc(calc_id) + calc = load_calc(calculator=calculator) bulk = get_atoms(**image).repeat(repeat) - bulk.set_calculator(calc) + bulk.calc = calc vacant = bulk.copy() del vacant[vacancy_index] @@ -67,7 +67,7 @@ def vacancy_formation_energy( if atom_relax_args is not None: try: relax_results = atom_relax( - calc_id=calc_id, + calculator=calculator, image={'atoms': vacant}, optimizer=atom_relax_args.get('optimizer', 'BFGS'), properties=('energy','forces'), @@ -82,7 +82,7 @@ def vacancy_formation_energy( else: try: relax_results = optimize( - calc_id=calc_id, + calculator=calculator, image={'atoms': vacant}, # optimizer=optimize_args.get('optimizer', 'BFGS'), # fmax=atom_relax_args.get('fmax', 0.003), diff --git a/asimtools/asimmodules/vasp/__init__.py b/src/asimtools/asimmodules/vasp/__init__.py similarity index 100% rename from asimtools/asimmodules/vasp/__init__.py rename to src/asimtools/asimmodules/vasp/__init__.py diff --git a/src/asimtools/asimmodules/vasp/vasp.py b/src/asimtools/asimmodules/vasp/vasp.py new file mode 100755 index 0000000..de08b50 --- /dev/null +++ b/src/asimtools/asimmodules/vasp/vasp.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python +''' +Runs VASP based on input files and optionally MP settings. +Heavily uses pymatgen for IO and MP settings. +VASP must be installed + +Author: mkphuthi@github.com +''' +from typing import Dict, Optional, Sequence +import os +import sys +from pathlib import Path +from numpy.random import randint +import subprocess +import logging +import shutil +from ase.io import read +from ase import Atoms +from pymatgen.io.ase import AseAtomsAdaptor +from pymatgen.io.vasp import Poscar, Incar, Potcar, Kpoints, VaspInput +import pymatgen.io.vasp.sets +from asimtools.utils import ( + get_atoms, + get_str_btn, +) + +def execute_vasp_run_command( + command: str, +) -> None: + command = command.split(' ') + completed_process = subprocess.run( + command, check=False, capture_output=True, text=True, + ) + + with open('vasp_stdout.txt', 'a+', encoding='utf-8') as f: + f.write(completed_process.stdout) + + if completed_process.returncode != 0: + err_txt = f'VASP failed with error code: {completed_process.returncode}' + err_txt += '\nSee vasp_stderr.txt for details.' + logging.error(err_txt) + with open('vasp_stderr.txt', 'a+', encoding='utf-8') as f: + f.write(completed_process.stderr) + completed_process.check_returncode() + if "ZBRENT: fatal error in bracketing" in completed_process.stderr: + filenum = 0 + while os.path.exists(f'OUTCAR.{filenum}'): + filenum += 1 + shutil.move('OUTCAR', f'OUTCAR.{filenum}') + shutil.move('POSCAR', f'POSCAR.{filenum}') + shutil.move('CONTCAR', f'POSCAR') + execute_vasp_run_command(command) + else: + logging.info( + f'VASP run completed with code {completed_process.returncode}.' + ) + +def vasp( + image: Optional[Dict], + user_incar_settings: Optional[Dict] = None, + user_kpoints_settings: Optional[Dict] = None, + user_potcar_functional: str = 'PBE_64', + potcar: Optional[Dict] = None, + vaspinput_kwargs: Optional[Dict] = None, + command: str = 'srun vasp_std', + mpset: Optional[str] = None, + prev_calc: Optional[os.PathLike] = None, + write_image_output: bool = True, + run_vasp: bool = True, +) -> Dict: + """Run VASP with given input files and specified image + + :param image: Initial image for VASP calculation. Image specification, + see :func:`asimtools.utils.get_atoms` + :type image: Dict + :param user_incar_settings: Dictionary of INCAR settings to override + defaults or MP settings, defaults to None + :type user_incar_settings: Dict, optional + :param user_kpoints_settings: Dictionary of KPOINTS settings to override + defaults or MP settings, defaults to None + :type user_kpoints_settings: Dict, optional + :param user_potcar_functional: Potcar functional to use in case of MP + settings, defaults to 'PBE_64' + :type user_potcar_functional: str, optional + :param potcar: Dictionary specifying Potcar settings, see + :class:`pymatgen.io.vasp.inputs.Potcar`, defaults to None + :type potcar: Dict, optional + :param prev_calc: Path to previous VASP calculation to use as starting + point for MP settings, defaults to None + :type prev_calc: os.PathLike, optional + :param vaspinput_kwargs: Dictionary of pymatgen's VaspInput arguments. + See :class:`pymatgen.io.vasp.inputs.VaspInput` + :type vaspinput_kwargs: Dict + :param command: Command with which to run VASP, defaults to 'vasp_std' + :type command: str, optional + :param mpset: Materials Project VASP set to use see + :mod:`pymatgen.io.vasp.sets`, defaults to None + :type mpset: str, optional + :param write_image_output: Whether to write output image in standard + asimtools format to file, defaults to False + :type write_image_output: bool, optional + :param run_vasp: Whether to run VASP after writing input files, + defaults to True + :type run_vasp: bool, optional + """ + + if vaspinput_kwargs is None: + vaspinput_kwargs = {} + + struct = get_atoms(**image, return_type='pymatgen') + if mpset is not None: + try: + set_ = getattr(pymatgen.io.vasp.sets, mpset) + except: + raise ImportError( + f'Unknown mpset: {mpset}. See available sets in pymatgen.') + + if prev_calc is not None: + vasp_input = set_.from_prev_calc( + prev_calc, + user_incar_settings=user_incar_settings, + user_kpoints_settings=user_kpoints_settings, + user_potcar_functional=user_potcar_functional, + **vaspinput_kwargs + ) + else: + vasp_input = set_( + struct, + user_incar_settings=user_incar_settings, + user_kpoints_settings=user_kpoints_settings, + user_potcar_functional=user_potcar_functional, + **vaspinput_kwargs + ) + + else: + + incar = Incar(user_incar_settings) + incar.check_params() + if potcar is not None: + potcar = Potcar(potcar) + if user_kpoints_settings is not None: + kpoints = Kpoints(user_kpoints_settings) + else: + kpoints=None + + if vaspinput_args is None: + vaspinput_args = {} + + vasp_input = VaspInput( + incar=incar, + kpoints=kpoints, + poscar=Poscar(struct), + potcar=potcar, + **vaspinput_kwargs + ) + + vasp_input.write_input("./") + + if run_vasp: + optimization_failed = False + execute_vasp_run_command(command) + incar = vasp_input.incar + ibrion = incar.get('IBRION', -1) + nsw = incar.get('NSW', 0) + # Don't write result if running a relaxation that didn't finish + if ibrion in (1,2,3): + if nsw > 0: + with open('OUTCAR', 'r') as f: + lines = f.readlines() + lines = lines[::-1] + for line in lines: + if 'Ionic step' in line: + last_step = int(get_str_btn(line, 'Ionic step', '--')) + if last_step == nsw: + optimization_failed = True + raise RuntimeError( + 'VASP relaxation did not complete. ' + 'Check OUTCAR for details.' + ) + + if write_image_output and not optimization_failed: + atoms_output = read('OUTCAR') + atoms_output.write( + 'image_output.xyz', + format='extxyz', + ) + + return {} diff --git a/src/asimtools/asimmodules/vasp/vasp.py.bak b/src/asimtools/asimmodules/vasp/vasp.py.bak new file mode 100755 index 0000000..b66f55b --- /dev/null +++ b/src/asimtools/asimmodules/vasp/vasp.py.bak @@ -0,0 +1,142 @@ +#!/usr/bin/env python +''' +Runs VASP based on input files and optionally MP settings. +Heavily uses pymatgen for IO and MP settings. +VASP must be installed + +Author: mkphuthi@github.com +''' +from typing import Dict, Optional, Sequence +import os +import sys +from pathlib import Path +from numpy.random import randint +import subprocess +import logging +from ase.io import read +from ase import Atoms +from pymatgen.io.ase import AseAtomsAdaptor +from pymatgen.io.vasp import Poscar, Incar, Potcar, Kpoints, VaspInput +import pymatgen.io.vasp.sets +from asimtools.utils import ( + get_atoms, +) + +def vasp( + image: Optional[Dict], + user_incar_settings: Optional[Dict] = None, + user_kpoints_settings: Optional[Dict] = None, + user_potcar_functional: str = 'PBE_64', + potcar: Optional[Dict] = None, + vaspinput_kwargs: Optional[Dict] = None, + command: str = 'srun vasp_std', + mpset: Optional[str] = None, + prev_calc: Optional[os.PathLike] = None, + write_image_output: bool = True, + run_vasp: bool = True, +) -> Dict: + """Run VASP with given input files and specified image + + :param image: Initial image for VASP calculation. Image specification, + see :func:`asimtools.utils.get_atoms` + :type image: Dict + :param vaspinput_args: Dictionary of pymatgen's VaspInput arguments. + See :class:`pymatgen.io.vasp.inputs.VaspInput` + :type vaspinput_args: Dict + :param command: Command with which to run VASP, defaults to 'vasp_std' + :type command: str, optional + :param mpset: Materials Project VASP set to use see + :mod:`pymatgen.io.vasp.sets`, defaults to None + :type mpset: str, optional + :param write_image_output: Whether to write output image in standard + asimtools format to file, defaults to False + :type write_image_output: bool, optional + """ + + if vaspinput_kwargs is None: + vaspinput_kwargs = {} + + struct = get_atoms(**image, return_type='pymatgen') + if mpset is not None: + # if not ((incar is None) or (potcar is None) or (kpoints is None)): + # raise ValueError( + # 'Provide either mpset or all of incar and kpoints' + # ) + try: + set_ = getattr(pymatgen.io.vasp.sets, mpset) + except: + raise ImportError( + f'Unknown mpset: {mpset}. See available sets in pymatgen.') + + if prev_calc is not None: + vasp_input = set_.from_prev_calc( + prev_calc, + user_incar_settings=user_incar_settings, + user_kpoints_settings=user_kpoints_settings, + user_potcar_functional=user_potcar_functional, + **vaspinput_kwargs + ) + else: + vasp_input = set_( + struct, + user_incar_settings=user_incar_settings, + user_kpoints_settings=user_kpoints_settings, + user_potcar_functional=user_potcar_functional, + **vaspinput_kwargs + ) + + else: + + incar = Incar(user_incar_settings) + incar.check_params() + if potcar is not None: + potcar = Potcar(potcar) + if user_kpoints_settings is not None: + kpoints = Kpoints(user_kpoints_settings) + else: + kpoints=None + + if vaspinput_args is None: + vaspinput_args = {} + + vasp_input = VaspInput( + incar=incar, + kpoints=kpoints, + poscar=Poscar(struct), + potcar=potcar, + **vaspinput_kwargs + ) + + + vasp_input.write_input("./") + # if incar_kwargs is not None: + # with open('INCAR', 'a+') as fp: + # for k, v in incar_kwargs.items(): + # fp.write(f'\n{k} = {v}') + + if run_vasp: + command = command.split(' ') + completed_process = subprocess.run( + command, check=False, capture_output=True, text=True, + ) + + with open('vasp_stdout.txt', 'a+', encoding='utf-8') as f: + f.write(completed_process.stdout) + + if completed_process.returncode != 0: + err_txt = f'Failed to run VASP\n' + err_txt += 'See vasp_stderr.txt for details.' + logging.error(err_txt) + with open('vasp_stderr.txt', 'a+', encoding='utf-8') as f: + f.write(completed_process.stderr) + completed_process.check_returncode() + return {} + + if write_image_output: + atoms_output = read('OUTCAR') + atoms_output.write( + 'image_output.xyz', + format='extxyz', + ) + + return {} diff --git a/asimtools/asimmodules/workflows/__init__.py b/src/asimtools/asimmodules/workflows/__init__.py similarity index 100% rename from asimtools/asimmodules/workflows/__init__.py rename to src/asimtools/asimmodules/workflows/__init__.py diff --git a/asimtools/asimmodules/workflows/calc_array.py b/src/asimtools/asimmodules/workflows/calc_array.py similarity index 70% rename from asimtools/asimmodules/workflows/calc_array.py rename to src/asimtools/asimmodules/workflows/calc_array.py index 3bf15c2..b94c7f8 100755 --- a/asimtools/asimmodules/workflows/calc_array.py +++ b/src/asimtools/asimmodules/workflows/calc_array.py @@ -19,6 +19,8 @@ def calc_array( subsim_input: Dict, calc_ids: Sequence[str] = None, template_calc_id: Optional[str] = None, + calculators: Sequence[Dict] = None, + template_calculator: Optional[Dict] = None, key_sequence: Optional[Sequence[str]] = None, array_values: Optional[Sequence] = None, file_pattern: Optional[str] = None, @@ -39,8 +41,20 @@ def calc_array( """Apply the same asimmodule using different calculators and if necessary different environments - :param calc_ids: Iterable with calc_ids, defaults to None + :param calc_ids: Deprecated. Use calculators instead. Iterable with + calc_ids, defaults to None :type calc_ids: Sequence, optional + :param template_calc_id: Deprecated. Use template_calculator instead. + calc_id of the template calculator, defaults to None + :type template_calc_id: str, optional + :param calculators: Sequence of calculator dicts, each with a 'calc_id' + or 'calc_params' key, see :func:`asimtools.calculators.load_calc`, + defaults to None + :type calculators: Sequence[Dict], optional + :param template_calculator: Calculator dict with 'calc_id' or 'calc_params' + key used as template when iterating over key_sequence values, see + :func:`asimtools.calculators.load_calc`, defaults to None + :type template_calculator: Optional[Dict], optional :param calc_input: Dictionary of calculator inputs :type calc_input: Dictionary, optional :param labels: Iterable with custom labels for each calc, defaults to None @@ -82,28 +96,36 @@ def calc_array( :return: Dictionary of results :rtype: Dict """ + # Backward compatibility: convert old-style params to new interface + if calculators is None and calc_ids is not None: + calculators = [{'calc_id': cid} for cid in calc_ids] + if template_calculator is None and template_calc_id is not None: + template_calculator = {'calc_id': template_calc_id} + print([ array_values, linspace_args, arange_args, file_pattern ]) using_array_values = key_sequence is not None\ - and template_calc_id is not None\ + and template_calculator is not None\ and [ array_values, linspace_args, arange_args, file_pattern ].count(None) == 3 - err_txt = 'Specify either a sequence of "calc_ids" or all of ' - err_txt += '"key_sequence", "template_calc_id" and one of [' + err_txt = 'Specify either a sequence of "calculators" or all of ' + err_txt += '"key_sequence", "template_calculator" and one of [' err_txt += '"array_values", "linspace_args", "arange_args", "file_pattern"' err_txt += '] to iterate over' - assert calc_ids is not None or using_array_values, err_txt + assert calculators is not None or using_array_values, err_txt if using_array_values: - if calc_input is None: - calc_input = get_calc_input() - calc_params = calc_input[template_calc_id] - new_calc_input = {} + if template_calculator.get('calc_params') is not None: + calc_params = template_calculator['calc_params'] + else: + if calc_input is None: + calc_input = get_calc_input() + calc_params = calc_input[template_calculator['calc_id']] - if calc_ids is not None and labels is None: - labels = calc_ids + if calculators is not None and labels is None: + labels = [c.get('calc_id', f'calc-{i}') for i, c in enumerate(calculators)] results = prepare_array_vals( key_sequence=key_sequence, @@ -124,9 +146,10 @@ def calc_array( secondary_array_values = results['secondary_array_values'] secondary_key_sequences = results['secondary_key_sequences'] + calculators = [] for i, val in enumerate(array_values): new_calc_params = change_dict_value( - d=calc_params, + dct=calc_params, new_value=val, key_sequence=key_sequence, return_copy=True, @@ -134,36 +157,32 @@ def calc_array( if secondary_array_values is not None: for k, vs in zip(secondary_key_sequences, secondary_array_values): new_calc_params = change_dict_value( - d=new_calc_params, + dct=new_calc_params, new_value=vs[i], key_sequence=k, return_copy=False, ) + calculators.append({'calc_params': new_calc_params}) - new_calc_input[labels[i]] = new_calc_params - - calc_input = new_calc_input - calc_ids = labels - - elif calc_ids is not None: + elif calculators is not None: assert labels != 'get_str_btn', \ 'get_str_btn only works when using the key_sequence argument.' if labels is None or labels == 'values': - labels = calc_ids + labels = [c.get('calc_id', f'calc-{i}') for i, c in enumerate(calculators)] - assert len(labels) == len(calc_ids), \ - 'Num. of calc_ids or array_values must match num. of labels' + assert len(labels) == len(calculators), \ + 'Num. of calculators or array_values must match num. of labels' if env_ids is not None: - assert len(env_ids) == len(calc_ids) or isinstance(env_ids, str), \ - 'Provide one env_id or as many as there are calc_ids/array_values' + assert len(env_ids) == len(calculators) or isinstance(env_ids, str), \ + 'Provide one env_id or as many as there are calculators/array_values' array_sim_input = {} # Make individual sim_inputs for each calc - for i, calc_id in enumerate(calc_ids): + for i, calculator in enumerate(calculators): new_subsim_input = deepcopy(subsim_input) - new_subsim_input['args']['calc_id'] = calc_id + new_subsim_input['args']['calculator'] = calculator array_sim_input[f'{labels[i]}'] = new_subsim_input if env_ids is not None: diff --git a/asimtools/asimmodules/workflows/chained.py b/src/asimtools/asimmodules/workflows/chained.py similarity index 100% rename from asimtools/asimmodules/workflows/chained.py rename to src/asimtools/asimmodules/workflows/chained.py diff --git a/asimtools/asimmodules/workflows/distributed.py b/src/asimtools/asimmodules/workflows/distributed.py similarity index 100% rename from asimtools/asimmodules/workflows/distributed.py rename to src/asimtools/asimmodules/workflows/distributed.py diff --git a/asimtools/asimmodules/workflows/image_array.py b/src/asimtools/asimmodules/workflows/image_array.py similarity index 86% rename from asimtools/asimmodules/workflows/image_array.py rename to src/asimtools/asimmodules/workflows/image_array.py index 7c3705c..16c6e6d 100755 --- a/asimtools/asimmodules/workflows/image_array.py +++ b/src/asimtools/asimmodules/workflows/image_array.py @@ -14,7 +14,8 @@ def image_array( images: Dict, - subsim_input: Dict, + subsim_input: Optional[Dict] = None, + template_sim_input: Optional[Dict] = None, calc_input: Optional[Dict] = None, env_input: Optional[Dict] = None, array_max: Optional[int] = None, @@ -33,8 +34,14 @@ def image_array( :param images: Images specification, see :func:`asimtools.utils.get_images` :type images: Dict - :param subsim_input: sim_input of asimmodule to be run - :type subsim_input: Dict + :param subsim_input: sim_input of asimmodule to be run, included for backward + compatibility, please use template_sim_input instead, + defaults to None + :type subsim_input: Optional[Dict], optional + :param template_sim_input: sim_input of asimmodule to be run, defaults to None + :type template_sim_input: Optional[Dict], optional + :param str_btn_args: args to pass to :func:`asimtools.utils.get_str_btn` + :type str_btn_args: Optional[Sequence], optional :param calc_input: calc_input to override global file, defaults to None :type calc_input: Optional[Dict], optional :param env_input: env_input to override global file, defaults to None @@ -83,6 +90,8 @@ def image_array( secondary_array_values=secondary_array_values, ) + if template_sim_input is not None: + subsim_input = template_sim_input if key_sequence is None: key_sequence = ['args', 'image'] # For backwards compatibility where we don't have to specify image @@ -100,7 +109,7 @@ def image_array( array_sim_input = {} for i, val in enumerate(array_values): new_sim_input = change_dict_value( - d=subsim_input, + dct=subsim_input, new_value=val, key_sequence=key_sequence, return_copy=True, @@ -109,7 +118,7 @@ def image_array( if secondary_array_values is not None: for k, vs in zip(secondary_key_sequences, secondary_array_values): new_sim_input = change_dict_value( - d=new_sim_input, + dct=new_sim_input, new_value=vs[i], key_sequence=k, return_copy=False, diff --git a/asimtools/asimmodules/workflows/iterative.py b/src/asimtools/asimmodules/workflows/iterative.py similarity index 95% rename from asimtools/asimmodules/workflows/iterative.py rename to src/asimtools/asimmodules/workflows/iterative.py index 069c232..388292c 100755 --- a/asimtools/asimmodules/workflows/iterative.py +++ b/src/asimtools/asimmodules/workflows/iterative.py @@ -27,12 +27,9 @@ def iterative( env_ids: Optional[Union[Sequence[str],str]] = None, calc_input: Optional[Dict] = None, env_input: Optional[Dict] = None, - # labels: Optional[Union[Sequence,str]] = 'values', - # label_prefix: Optional[str] = None, str_btn_args: Optional[Dict] = None, secondary_key_sequences: Optional[Sequence] = None, secondary_array_values: Optional[Sequence] = None, - # array_max: Optional[int] = None, ) -> Dict: """Runs the same asimmodule, iterating over multiple values of a specified argument based on a sim_input template provided by the user @@ -107,7 +104,7 @@ def iterative( for i, val in enumerate(array_values): if key_sequence is not None: new_sim_input = change_dict_value( - d=template_sim_input, + dct=template_sim_input, new_value=val, key_sequence=key_sequence, return_copy=True, @@ -118,7 +115,7 @@ def iterative( if dependent_file_key_sequence is not None and i > 0: dep_arg = str(Path(f'../step-{i-1}') / dependent_file) new_sim_input = change_dict_value( - d=new_sim_input, + dct=new_sim_input, new_value=dep_arg, key_sequence=dependent_file_key_sequence, return_copy=False, @@ -129,7 +126,7 @@ def iterative( if secondary_array_values is not None: for k, vs in zip(secondary_key_sequences, secondary_array_values): new_sim_input = change_dict_value( - d=new_sim_input, + dct=new_sim_input, new_value=vs[i], key_sequence=k, return_copy=False, diff --git a/asimtools/asimmodules/workflows/sim_array.py b/src/asimtools/asimmodules/workflows/sim_array.py similarity index 82% rename from asimtools/asimmodules/workflows/sim_array.py rename to src/asimtools/asimmodules/workflows/sim_array.py index 46de2ba..b64d368 100755 --- a/asimtools/asimmodules/workflows/sim_array.py +++ b/src/asimtools/asimmodules/workflows/sim_array.py @@ -21,6 +21,7 @@ def sim_array( file_pattern: Optional[str] = None, linspace_args: Optional[Sequence] = None, arange_args: Optional[Sequence] = None, + placeholder: Optional[str] = None, env_ids: Optional[Union[Sequence[str],str]] = None, calc_input: Optional[Dict] = None, env_input: Optional[Dict] = None, @@ -29,8 +30,10 @@ def sim_array( str_btn_args: Optional[Dict] = None, secondary_key_sequences: Optional[Sequence] = None, secondary_array_values: Optional[Sequence] = None, + secondary_placeholders: Optional[Sequence] = None, array_max: Optional[int] = None, skip_failed: Optional[bool] = False, + as_integers: Optional[bool] = False, group_size: int = 1, ) -> Dict: """Runs the same asimmodule, iterating over multiple values of a specified @@ -53,6 +56,9 @@ def sim_array( :param arange_args: arguments to pass to :func:`numpy.arange` to be iterated over in each simulation, defaults to None :type arange_args: Optional[Sequence], optional + :param placeholder: placeholder is string dict value to replace with the + array value + :type placeholder: Optional[str], optional :param labels: Custom labels to use for each simulation, defaults to None :type labels: Sequence, optional :param label_prefix: Prefix to add before labels which can make extracting @@ -70,6 +76,10 @@ def sim_array( over in tandem with array_values to allow changing multiple key-value pairs, defaults to None :type secondary_array_values: Sequence, optional + :param secondary_placeholders: list of other other placeholders to iterate + over in tandem with array_values to allow changing multiple key-value + pairs, defaults to None + :type secondary_placeholders: Sequence, optional :param array_max: Number of jobs to run at once in scheduler :type array_max: int, optional :param calc_input: calc_input file to use, defaults to None @@ -80,6 +90,9 @@ def sim_array( :type skip_failed: Optional[bool], optional :param group_size: Number of jobs to group together, defaults to 1 :type group_size: int, optional + :param as_integers: Whether to return the values as integers, useful for + indexing + :type as_integers: bool, False :return: Results :rtype: Dict """ @@ -96,6 +109,7 @@ def sim_array( str_btn_args=str_btn_args, secondary_key_sequences=secondary_key_sequences, secondary_array_values=secondary_array_values, + as_integers=as_integers, ) array_values = results['array_values'] labels = results['labels'] @@ -109,21 +123,29 @@ def sim_array( for i, val in enumerate(array_values): if key_sequence is not None: new_sim_input = change_dict_value( - d=template_sim_input, + dct=template_sim_input, new_value=val, key_sequence=key_sequence, return_copy=True, + placeholder=placeholder, ) else: new_sim_input = deepcopy(template_sim_input) if secondary_array_values is not None: - for k, vs in zip(secondary_key_sequences, secondary_array_values): + for j, (k, vs) in enumerate( + zip(secondary_key_sequences, secondary_array_values) + ): + if secondary_placeholders is not None: + secondary_placeholder = secondary_placeholders[j] + else: + secondary_placeholder = None new_sim_input = change_dict_value( - d=new_sim_input, + dct=new_sim_input, new_value=vs[i], key_sequence=k, return_copy=False, + placeholder=secondary_placeholder, ) if env_ids is not None: diff --git a/asimtools/asimmodules/workflows/update_dependencies.py b/src/asimtools/asimmodules/workflows/update_dependencies.py similarity index 100% rename from asimtools/asimmodules/workflows/update_dependencies.py rename to src/asimtools/asimmodules/workflows/update_dependencies.py diff --git a/asimtools/asimmodules/workflows/utils.py b/src/asimtools/asimmodules/workflows/utils.py similarity index 79% rename from asimtools/asimmodules/workflows/utils.py rename to src/asimtools/asimmodules/workflows/utils.py index d1b6913..ce4cd10 100644 --- a/asimtools/asimmodules/workflows/utils.py +++ b/src/asimtools/asimmodules/workflows/utils.py @@ -1,8 +1,9 @@ from typing import Dict, Sequence, Optional, Union from glob import glob +from pathlib import Path from natsort import natsorted import numpy as np -from asimtools.utils import get_str_btn +from asimtools.utils import get_str_btn, get_nth_label def prepare_array_vals( key_sequence: Optional[Sequence[str]] = None, @@ -16,6 +17,7 @@ def prepare_array_vals( str_btn_args: Optional[Dict] = None, secondary_key_sequences: Optional[Sequence] = None, secondary_array_values: Optional[Sequence] = None, + as_integers: Optional[bool] = False, ): """Helper function for preparing things needed for the different arrays @@ -34,7 +36,10 @@ def prepare_array_vals( :param arange_args: arguments to pass to :func:`numpy.arange` to be iterated over in each simulation, defaults to None :type arange_args: Optional[Sequence], optional - :param labels: Custom labels to use for each simulation, defaults to None + :param labels: Custom labels to use for each simulation. If "str_btn" + provide arguments to :func:`asimtools.utils.get_str_btn` as additional + str_btn_args keyword. If labels is an integer N, the Nth label in the + file_pattern or array_values is used, defaults to None :type labels: Sequence, optional :param label_prefix: Prefix to add before labels which can make extracting data from file paths easier, defaults to None @@ -51,6 +56,9 @@ def prepare_array_vals( over in tandem with array_values to allow changing multiple key-value pairs, defaults to None :type secondary_array_values: Sequence, optional + :param as_integers: Whether to return the values as integers, useful for + indexing + :type as_integers: bool, False :return: Results :rtype: Dict """ @@ -65,14 +73,18 @@ def prepare_array_vals( if file_pattern is not None: array_values = natsorted(glob(str(file_pattern))) + assert len(array_values) > 0, f'No file_pattern matching {file_pattern}' elif linspace_args is not None: array_values = np.linspace(*linspace_args) array_values = [float(v) for v in array_values] elif arange_args is not None: array_values = np.arange(*arange_args) - array_values = [float(v) for v in array_values] + if as_integers: + array_values = [int(v) for v in array_values] + else: + array_values = [float(v) for v in array_values] - assert len(array_values) > 0, 'No array values or files found' + assert len(array_values) > 0, f'No array_values found' if labels == 'str_btn': assert str_btn_args is not None, 'Provide str_btn_args for labels' @@ -82,11 +94,15 @@ def prepare_array_vals( labels = [f'{key_sequence[-1]}-{val}' for val in array_values] else: labels = [f'value-{val}' for val in array_values] + elif isinstance(labels, int): + labels = [get_nth_label(s, labels) for s in array_values] elif labels is None: labels = [str(i) for i in range(len(array_values))] - # In case the label is a file path with / characters + # In case the label is a file path with / or space characters labels = [label.replace('/', '+') for label in labels] + labels = [label.replace(' ', '+') for label in labels] + labels = [label.replace('*', '') for label in labels] if label_prefix is not None: labels = [label_prefix + '-' + label for label in labels] @@ -94,6 +110,10 @@ def prepare_array_vals( assert len(labels) == len(array_values), \ f'Num. of array_values ({len(array_values)}) must match num.'\ f'of labels ({len(labels)})' + + # File patterns should be resolved fully for placeholders to work + if file_pattern is not None: + array_values = [str(Path(v).resolve()) for v in array_values] if secondary_array_values is not None: nvals = len(secondary_array_values) diff --git a/asimtools/calculators.py b/src/asimtools/calculators.py similarity index 56% rename from asimtools/calculators.py rename to src/asimtools/calculators.py index 40704d4..3c28061 100644 --- a/asimtools/calculators.py +++ b/src/asimtools/calculators.py @@ -11,34 +11,50 @@ # pylint: disable=import-error def load_calc( + calculator: Optional[Dict] = None, calc_id: Optional[str] = None, calc_input: Optional[Dict] = None, calc_params: Optional[Dict] = None, ): - """Loads a calculator using given calc_id or calc_input. Provide only one of - calc_id or calc_input and calc_id or calc_params - - :param calc_id: ID/key to use to load calculator from the supplied or \ - global calc_input file, defaults to None + """Loads a calculator using a calculator dict or legacy calc_id/calc_params + arguments. + + :param calculator: Dictionary with either a ``calc_id`` key (to look up + from the global or supplied calc_input) or a ``calc_params`` key + (to use directly). Takes precedence over the legacy arguments if + provided, defaults to None + :type calculator: Optional[Dict], optional + :param calc_id: Deprecated — use ``calculator={'calc_id': ...}`` instead. + ID/key to use to load calculator from the supplied or global + calc_input file, defaults to None :type calc_id: str, optional :param calc_input: calc_input dictionary, same form as calc_input yaml \ :type calc_input: Optional[Dict], optional - :param calc_params: calc_params dictionary for a single calculator \ - calc_params, defaults to None + :param calc_params: Deprecated — use ``calculator={'calc_params': ...}`` + instead. calc_params dictionary for a single calculator, + defaults to None :type calc_params: Optional[Dict], optional :return: ASE calculator instance :rtype: :class:`ase.calculators.calculators.Calculator` """ + if calculator is not None: + calc_id = calculator.get('calc_id', calc_id) + calc_params = calculator.get('calc_params', calc_params) + assert calc_id is not None or calc_params is not None, \ 'Provide one of calc_id or calc_id and calc_input or calc_params' if calc_id is not None: - if calc_input is None: - calc_input = get_calc_input() + if isinstance(calc_id, dict): + calc_input = {'custom': calc_id} + calc_id = 'custom' + else: + if calc_input is None: + calc_input = get_calc_input() try: calc_params = calc_input[calc_id] except KeyError as exc: msg = f'Calculator with calc_id: {calc_id} not found in' - msg += f'calc_input {calc_input}' + msg += f'calc_input {list(calc_input)}' raise KeyError(msg) from exc except AttributeError as exc: raise AttributeError('No calc_input found') from exc @@ -239,28 +255,30 @@ def load_espresso_profile(calc_params): if 'command' in calc_params['args']: calc_params = deepcopy(calc_params) command = calc_params['args'].pop('command') - command = command.split() - progind = command.index('pw.x') - argv = command[:progind+1] else: - argv = ['pw.x'] + command = 'pw.x' + if 'pseudo_dir' in calc_params['args']: + pseudo_dir = calc_params['args'].pop('pseudo_dir') + else: + pseudo_dir = None try: calc = Espresso( **calc_params['args'], - profile=EspressoProfile(argv=argv) + profile=EspressoProfile(command=command, pseudo_dir=pseudo_dir) ) except Exception: - logging.error("Failed to load MACE-OFF with parameters:\n %s", calc_params) + logging.error("Failed to load Espresso with parameters:\n %s", calc_params) raise return calc def load_m3gnet(calc_params): - """Load and M3GNet calculator + """Load any M3GNet or MatGL calculator - :param calc_params: parameters to be passed to matgl.ext.ase.M3GNetCalculator. Must include a key "model" that points to the model used to instantiate the potential + :param calc_params: parameters to be passed to matgl.ext.ase.M3GNetCalculator. + Must include a key "model" that points to the model used to instantiate the potential :type calc_params: Dict :return: M3GNet calculator :rtype: :class:`matgl.ext.ase.M3GNetCalculator` @@ -281,13 +299,40 @@ def load_m3gnet(calc_params): return calc -def load_omat24(calc_params): - """Load and OMAT24 calculator +def load_matgl(calc_params): + """Load any MatGL calculator - :param calc_params: parameters to be passed to fairchem.core.OCPCalculator. + :param calc_params: parameters to be passed to matgl.ext.ase.PESCalculator. Must include a key "model" that points to the model used to instantiate the potential :type calc_params: Dict - :return: OMAT24 calculator + :return: MatGL calculator + :rtype: :class:`matgl.ext.ase.PESCalculator` + """ + from matgl.ext.ase import PESCalculator + import matgl + calc_params = deepcopy(calc_params) + model = calc_params['args'].pop("model") + + try: + pot = matgl.load_model(model) + calc = PESCalculator( + pot, + **calc_params['args'], + ) + except Exception: + logging.error("Failed to load M3GNet with parameters:\n %s", calc_params) + raise + + return calc + +def load_fairchem_v1(calc_params): + """Load any fairchemV1 calculator + + :param calc_params: parameters to be passed to fairchem.core.OCPCalculator. + Must include a key "model" that points to the model files used to + instantiate the potential + :type calc_params: Dict + :return: fairchem calculator :rtype: :class:`fairchem.core.OCPCalculator` """ from fairchem.core import OCPCalculator @@ -295,7 +340,118 @@ def load_omat24(calc_params): try: calc = OCPCalculator(**calc_params['args']) except Exception: - logging.error("Failed to load OMAT24 with parameters:\n %s", calc_params) + logging.error( + "Failed to load OMAT24 with parameters:\n %s", calc_params + ) + raise + + return calc + +def load_fairchem_v2(calc_params): + """Load any fairchemV1 calculator + + :param calc_params: parameters to be passed to fairchem.core.FAIRChemCalculator. + Must include a key "model" that points to the model files used to + instantiate the potential + :type calc_params: Dict + :return: fairchem calculator + :rtype: :class:`fairchem.core.FAIRChemCalculator` + + Examples + -------- + >>> from asimtools.calculators import load_calc + >>> calc_params = { + ... 'name': 'fairchem', + ... 'args': { + ... 'model_name': 'uma-s-1', + ... 'device': 'cuda', + ... 'task_name': 'oc20' # Also 'omol','omat','oc20','odac' or 'omc' + ... } + ... } + >>> calc = load_calc(calc_params=calc_params) + + """ + from fairchem.core.units.mlip_unit import load_predict_unit + from fairchem.core import pretrained_mlip, FAIRChemCalculator + og_calc_params = deepcopy(calc_params) + task_name = calc_params['args'].pop('task_name', None) + try: + predictor = pretrained_mlip.get_predict_unit(**calc_params['args']) + except Exception as exc: # pylint: disable=broad-exception-caught + logging.error( + "Failed to load pretrained model trying predict unit" + ) + try: + predictor = load_predict_unit(**calc_params['args']) + except Exception: + logging.error( + "Failed to load predictor unit with parameters:\n %s", \ + og_calc_params + ) + raise exc from exc + + try: + calc = FAIRChemCalculator(predictor, task_name=task_name) + except Exception: + logging.error( + "Failed to load FAIRChemCalculator with parameters:\n %s", \ + og_calc_params + ) + raise + + return calc + +def load_ase_dftd3(calc_params): + """Load any calculator with DFTD3 correction as implemented in ASE + + :param calc_params: Dictionary with 2 keys. First is `d3_args` which is + passed to ase.calculators.dftd3.DFTD3 except for the dft argument. The + second is dft_calc_id or dft_calc_params which loads the calculator + to be wrapped. + :type calc_params: Dict + :return: ASE calculator + :rtype: :class:`ase.calculators.calculators.Calculator` + """ + from ase.calculators.dftd3 import DFTD3 + d3_args = calc_params['args'].get('d3_args', {}) + if 'dft' in d3_args: + raise ValueError('Do not specify dft arg for DFTD3, specify calc_id') + + dft_calc_id = calc_params['args'].get('dft_calc_id', None) + dft_calc_params = calc_params['args'].get('dft_calc_params', None) + if ( (dft_calc_id is not None) and (dft_calc_params is not None) ): + raise ValueError('Provide only one of dft_calc_id or dft_calc_params') + + if ( (dft_calc_id is not None) or (dft_calc_params is not None) ): + dft = load_calc(calc_id=dft_calc_id, calc_params=dft_calc_params) + else: + dft = None + try: + calc = DFTD3(dft=dft, **d3_args) + except Exception: + logging.error( + "Failed to load d3 calculator with parameters:\n %s", calc_params + ) + raise + + return calc + +def load_aqcat(calc_params): + """Load AQCat Calculator + :param calc_params: args to pass to loader, including checkpoint_path + :type calc_params: Dict + :return: AQCat calculator + :rtype: :class:`fairchem.core.common.relaxation.ase_utils.patched_calc` + """ + from fairchem.core.common.relaxation.ase_utils import patched_calc + + try: + calc = patched_calc(**calc_params['args']) + except Exception: + logging.error( + "Failed to load AQCat FAIRChemCalculator with parameters:\n %s", \ + calc_params['args'] + ) raise return calc @@ -309,5 +465,11 @@ def load_omat24(calc_params): 'MACECalculator': load_mace, 'EspressoProfile': load_espresso_profile, 'M3GNet': load_m3gnet, - 'OMAT24': load_omat24, + 'MatGL': load_matgl, + 'OMAT24': load_fairchem_v1, + 'fairchemV1': load_fairchem_v1, + 'fairchemV2': load_fairchem_v2, + 'fairchem': load_fairchem_v2, + 'ASEDFTD3': load_ase_dftd3, + 'AQCat': load_aqcat } diff --git a/asimtools/job.py b/src/asimtools/job.py similarity index 80% rename from asimtools/job.py rename to src/asimtools/job.py index dddc52a..950937a 100644 --- a/asimtools/job.py +++ b/src/asimtools/job.py @@ -11,8 +11,7 @@ from pathlib import Path from datetime import datetime import logging -from glob import glob -from typing import List, TypeVar, Dict, Tuple, Union, Sequence +from typing import TypeVar from copy import deepcopy from colorama import Fore import ase.io @@ -21,6 +20,7 @@ from asimtools.utils import ( read_yaml, write_yaml, + write_atoms, join_names, get_atoms, get_images, @@ -28,23 +28,24 @@ get_calc_input, get_logger, check_if_slurm_job_is_running, - parse_slice, ) Atoms = TypeVar('Atoms') -START_MESSAGE = '+' * 15 + ' ASIMTOOLS START' + '+' * 15 + '\n' -STOP_MESSAGE = '+' * 15 + ' ASIMTOOLS STOP' + '+' * 15 + '\n' +START_MESSAGE = '+' * 15 + ' ASIMTOOLS START ' + '+' * 15 + '\n' +STOP_MESSAGE = '+' * 15 + ' ASIMTOOLS STOP ' + '+' * 16 + '\n' class Job(): ''' Abstract class for the job object ''' # pylint: disable=too-many-instance-attributes - def __init__( + def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments self, - sim_input: Dict, - env_input: Union[Dict,None] = None, - calc_input: Union[Dict,None] = None, + sim_input: dict, + env_input: dict | None = None, + calc_input: dict | None = None, + asimrun_mode: bool = False, ) -> None: + ''' Initialise Job with simulation, environment and calculator inputs ''' if env_input is None: env_input = get_env_input() if calc_input is None: @@ -63,7 +64,7 @@ def __init__( self.sim_input['src_dir'] = self.launchdir self.env_id = self.sim_input.get('env_id', None) - if self.env_id is not None and self.env_input is not None: + if self.env_id is not None and not asimrun_mode: self.env = self.env_input[self.env_id] else: self.env = { @@ -85,16 +86,16 @@ def set_status(self, status: str) -> None: def start(self) -> None: ''' Updates the output to signal that the job was started ''' self.update_output({ - 'start_time': datetime.now().strftime('%H:%M:%S, %m/%d/%y') + 'start_time': datetime.now().strftime('%H:%M:%S, %m/%d/%y'), + 'status': 'started', }) - self.set_status('started') def complete(self) -> None: - ''' Updates the output to signal that the job was started ''' + ''' Updates the output to signal that the job completed ''' self.update_output({ - 'end_time': datetime.now().strftime('%H:%M:%S, %m/%d/%y') + 'end_time': datetime.now().strftime('%H:%M:%S, %m/%d/%y'), + 'status': 'complete', }) - self.set_status('complete') def fail(self) -> None: ''' Updates status to failed ''' @@ -114,25 +115,25 @@ def leave_workdir(self) -> None: ''' goes to directory from which job was launched ''' os.chdir(self.launchdir) - def add_output_files(self, file_dict: Dict) -> None: + def add_output_files(self, file_dict: dict) -> None: ''' Adds a file to the output file list ''' files = self.get_output().get('files', {}) files.update(file_dict) self.update_output({'files': file_dict}) - def get_sim_input(self) -> Dict: + def get_sim_input(self) -> dict: ''' Get simulation input ''' - return deepcopy(self.sim_input) + return self.sim_input - def get_calc_input(self) -> Dict: + def get_calc_input(self) -> dict: ''' Get calculator input ''' - return deepcopy(self.calc_input) + return self.calc_input - def get_env_input(self) -> Dict: + def get_env_input(self) -> dict: ''' Get environment input ''' - return deepcopy(self.env_input) + return self.env_input - def get_output_yaml(self) -> Dict: + def get_output_yaml(self) -> Path: ''' Get current output file ''' out_fname = 'output.yaml' output_yaml = self.workdir / out_fname @@ -142,36 +143,35 @@ def get_workdir(self) -> Path: ''' Get working directory ''' return self.workdir - def get_output(self) -> Dict: + def get_output(self) -> dict: ''' Get values in output.yaml ''' output_yaml = self.get_output_yaml() if output_yaml.exists(): return read_yaml(output_yaml) - else: - return {} + return {} - def update_sim_input(self, new_params) -> None: + def update_sim_input(self, new_params: dict) -> None: ''' Update simulation parameters ''' self.sim_input.update(new_params) - def update_calc_input(self, new_params) -> None: + def update_calc_input(self, new_params: dict) -> None: ''' Update calculator parameters ''' self.calc_input.update(new_params) - def update_env_input(self, new_params) -> None: + def update_env_input(self, new_params: dict) -> None: ''' Update calculator parameters ''' self.env_input.update(new_params) self.env_id = self.sim_input.get('env_id', None) if self.env_id is not None: self.env = self.env_input[self.env_id] - def set_workdir(self, workdir) -> None: + def set_workdir(self, workdir: str | Path) -> None: ''' Set working directory both in sim_input and instance ''' workdir = Path(workdir) self.sim_input['workdir'] = str(workdir.resolve()) self.workdir = workdir - def get_status(self, descend=False, display=False) -> Tuple[bool,str]: + def get_status(self, descend: bool = False, display: bool = False) -> tuple[bool, str]: ''' Check job status ''' output = self.get_output() job_id = output.get('job_id', False) @@ -180,7 +180,9 @@ def get_status(self, descend=False, display=False) -> Tuple[bool,str]: running = check_if_slurm_job_is_running(job_id) if running: status = 'started' - self.set_status(status) + # Write status without re-reading output.yaml + output['status'] = status + write_yaml(self.get_output_yaml(), output) complete = False else: if not descend: @@ -194,13 +196,13 @@ def get_status(self, descend=False, display=False) -> Tuple[bool,str]: print(f'Status: {status}') return complete, status - def update_output(self, output_update: Dict) -> None: + def update_output(self, output_update: dict) -> None: ''' Update output.yaml if it exists or write a new one ''' output = self.get_output() output.update(output_update) write_yaml(self.get_output_yaml(), output) - def get_logger(self, logfilename='stdout.txt', level='info'): + def get_logger(self, logfilename: str = 'stdout.txt', level: str = 'info') -> logging.Logger: ''' Get the logger ''' assert self.workdir.exists(), 'Work directory does not exist yet' logger = get_logger(logfile=self.workdir / logfilename, level=level) @@ -208,14 +210,14 @@ def get_logger(self, logfilename='stdout.txt', level='info'): def _gen_slurm_batch_preamble( self, - slurm_params=None, - extra_flags=None - ) -> None: + slurm_params: dict | None = None, + extra_flags: list | None = None, + ) -> str: ''' Generate the txt with job configuration but no run commands ''' if slurm_params is None: slurm_params = self.env.get('slurm', {}) - txt = '#!/usr/bin/sh\n\n' + txt = '#!/usr/bin/env sh\n\n' flags = slurm_params.get('flags', []) if isinstance(flags, dict): flag_list = [] @@ -252,24 +254,38 @@ def _gen_slurm_batch_preamble( return txt class UnitJob(Job): - ''' + ''' Unit job object with ability to submit a slurm/interactive job. - Unit jobs run in a specific environment and if required, run a + Unit jobs run in a specific environment and if required, run a specific calculator. More complex workflows are built up of unit jobs ''' def __init__( self, - sim_input: Dict, - env_input: Union[Dict,None] = None, - calc_input: Union[Dict,None] = None, + sim_input: dict, + env_input: dict | None = None, + calc_input: dict | None = None, ) -> None: super().__init__(sim_input, env_input, calc_input) - # Check if the asimmodule being called uses a calc_id to + # Check if the asimmodule being called uses a calculator/calc_id to # get precommands, postcommands, run_prefixes and run_suffixes - self.calc_id = self.sim_input.get('args', {}).get('calc_id', None) - if self.calc_id is not None: + args = self.sim_input.get('args', {}) + calculator = args.get('calculator', None) + self.calc_id = args.get('calc_id', None) + + if calculator is not None: + self.calc_id = calculator.get('calc_id', self.calc_id) + calc_params_direct = calculator.get('calc_params', None) + else: + calc_params_direct = None + + if calc_params_direct is not None: + self.calc_params = calc_params_direct + elif self.calc_id is not None: + if isinstance(self.calc_id, dict): + self.calc_input = {'custom': self.calc_id} + self.calc_id = 'custom' self.calc_params = self.calc_input[self.calc_id] else: self.calc_params = {} @@ -361,6 +377,7 @@ def gen_input_files( # This is the sim_input that will be written to disk so it will have # slightly different paths and the image(s) will be image_files sim_input = deepcopy(self.sim_input) + sim_input.pop('dry_run', None) # The sim_input file will already be in the work directory sim_input['workdir'] = '.' # Collect useful variables @@ -395,9 +412,9 @@ def gen_input_files( if image and write_image: atoms = get_atoms(**image) input_image_file = 'image_input.xyz' # Relative to workdir - atoms.write( + write_atoms( self.workdir / input_image_file, - format='extxyz' + atoms, ) sim_input['args']['image'] = { 'image_file': str(input_image_file), @@ -428,11 +445,11 @@ def gen_input_files( self._gen_slurm_script() return None - def submit( + def submit( # pylint: disable=too-many-locals,too-many-branches,too-many-statements self, - dependency: Union[List,None,str] = None, + dependency: list | str | None = None, write_image: bool = True, - ) -> Union[None,List[str]]: + ) -> list[str] | None: ''' Submit a job using slurm, interactively or in the terminal ''' @@ -462,6 +479,13 @@ def submit( %s', self.workdir) return None + if self.sim_input.get('dry_run', False): + logger.warning( + 'dry_run=True: input files written but job not submitted in %s', + self.workdir + ) + return None + cur_dir = Path('.').resolve() os.chdir(self.workdir) mode_params = self.env.get('mode', {}) @@ -535,11 +559,11 @@ def submit( class DistributedJob(Job): ''' Array job object with ability to submit simultaneous jobs ''' - def __init__( + def __init__( # pylint: disable=too-many-locals self, - sim_input: Dict, - env_input: Union[None,Dict] = None, - calc_input: Union[None,Dict] = None, + sim_input: dict, + env_input: dict | None = None, + calc_input: dict | None = None, ) -> None: super().__init__(sim_input, env_input, calc_input) # Set a standard for all subdirectories to start @@ -550,7 +574,7 @@ def __init__( assert njobs < 1000, \ f'ASIMTools id_nums are limited to {num_digits} digits, found \ {njobs} jobs! Having that many jobs is not very efficient. Try \ - grouping jobs together.' + grouping jobs together using the group_size argument to workflows.' sim_id_changed = False for i, (sim_id, subsim_input) in enumerate(sim_input.items()): @@ -581,28 +605,22 @@ def __init__( unitjobs.append(unitjob) # If all the jobs have the same config and use slurm, use a job array - env_id = unitjobs[0].env_id - same_env = np.all( - [(uj.env_id == env_id) for uj in unitjobs] - ) - - all_slurm = np.all( - [uj.env['mode'].get('use_slurm', False) for uj in unitjobs] - ) - - all_sh = np.all( - [uj.env['mode'].get('use_sh', False) for uj in unitjobs] - ) + first_env_id = unitjobs[0].env_id + same_env = True + all_slurm = True + all_sh = True + for uj in unitjobs: + if uj.env_id != first_env_id: + same_env = False + if not uj.env['mode'].get('use_slurm', False): + all_slurm = False + if not uj.env['mode'].get('use_sh', False): + all_sh = False + if not same_env and not all_slurm and not all_sh: + break - if same_env and all_slurm: - self.use_slurm = True - else: - self.use_slurm = False - - if all_sh: - self.use_sh = True - else: - self.use_sh = False + self.use_slurm = same_env and all_slurm + self.use_sh = all_sh self.unitjobs = unitjobs @@ -629,32 +647,29 @@ def _gen_array_script( ] ) - txt += '\necho "Job started on `hostname` at `date`"\n' - txt += 'CUR_DIR=`pwd`\n' + txt += '\necho "Job started on $(hostname) at $(date)"\n' + txt += 'CUR_DIR=$(pwd)\n' txt += 'echo "LAUNCHDIR: ${CUR_DIR}"\n' - txt += f'G={group_size} #Group size\n' + txt += f'G={group_size}\n' txt += 'N=${SLURM_ARRAY_TASK_ID}\n' - txt += f'WORKDIRS=($(ls -dv ./id-*))\n' - seqtxt = '$(seq $(($G*$N)) $(($G*$N+$G-1)) )' - txt += f'for i in {seqtxt}; do\n' - txt += ' WORKDIR=${WORKDIRS[$i]}\n' - txt += ' cd ${WORKDIR};\n' - # else: - # txt += '\nif [[ ! -z ${SLURM_ARRAY_TASK_ID} ]]; then\n' - # txt += ' fls=( id-* )\n' - # txt += ' WORKDIR=${fls[${SLURM_ARRAY_TASK_ID}]}\n' - # txt += 'fi\n\n' - # txt += 'cd ${WORKDIR}\n' - txt += ' ' + '\n'.join(slurm_params.get('precommands', [])) - txt += '\n '.join( + txt += 'START=$((G*N))\n' + txt += 'END=$((G*N+G-1))\n' + txt += 'i=0\n' + txt += 'for WORKDIR in $(ls -dv ./id-*); do\n' + txt += ' if [ $i -ge $START ] && [ $i -le $END ]; then\n' + txt += ' cd ${WORKDIR}\n' + txt += ' ' + '\n '.join(slurm_params.get('precommands', [])) + '\n' + txt += ' ' + '\n '.join( self.unitjobs[0].calc_params.get('precommands', []) ) + '\n' - txt += ' echo "WORKDIR: ${WORKDIR}"\n' - txt += ' ' + self.unitjobs[0].gen_run_command() + '\n' - txt += ' ' + '\n'.join(slurm_params.get('postcommands', [])) + '\n' - txt += ' cd ${CUR_DIR}\n' + txt += ' echo "WORKDIR: ${WORKDIR}"\n' + txt += ' ' + self.unitjobs[0].gen_run_command() + '\n' + txt += ' ' + '\n '.join(slurm_params.get('postcommands', [])) + '\n' + txt += ' cd ${CUR_DIR}\n' + txt += ' fi\n' + txt += ' i=$((i+1))\n' txt += 'done\n' - txt += 'echo "Job ended at `date`"' + txt += 'echo "Job ended at $(date)"' if write: slurm_file = self.workdir / 'job_array.sh' @@ -671,13 +686,13 @@ def _gen_sh_script( ''' txt = '#!/usr/bin/env sh\n\n' - txt += f'for WORKDIR in id-*; do\n' + txt += 'for WORKDIR in id-*; do\n' txt += ' cd ${WORKDIR};\n' txt += '\n'.join(self.unitjobs[0].calc_params.get('precommands', [])) txt += '\n asim-run sim_input.yaml -c calc_input.yaml ' txt += '>stdout.txt 2>stderr.txt\n' txt += '\n'.join(self.unitjobs[0].calc_params.get('precommands', [])) - txt += f'\n cd ../;\n' + txt += '\n cd ../;\n' txt += 'done' if write: @@ -697,7 +712,7 @@ def gen_input_files(self, **kwargs) -> None: def submit_jobs( self, **kwargs, - ) -> Union[None,List[int]]: + ) -> list[int] | None: ''' Submits the jobs. If submitting lots of batch jobs, we recommend using DistributedJob.submit_slurm_array @@ -709,9 +724,9 @@ def submit_jobs( write_image=kwargs.get('write_image', True) ) job_ids.append(job_id) - except Exception as exc: + except Exception as exc: # pylint: disable=broad-exception-caught logger = self.get_logger() - logger.error(f'Error submitting job in {unitjob.workdir}') + logger.error('Error submitting job in %s', unitjob.workdir) logger.error(exc) if not kwargs.get('skip_failed', False): raise exc @@ -719,8 +734,8 @@ def submit_jobs( def submit_sh_array( self, - **kwargs, - ) -> Union[None,List[int]]: + **_kwargs, + ) -> list[int] | None: ''' Submits jobs using a sh script. Proceeds even if some jobs fail ''' @@ -752,7 +767,7 @@ def submit_sh_array( if completed_process.stderr is not None: with paropen('stderr.txt', 'a+', encoding='utf-8') as err_file: err_file.write(completed_process.stderr) - print(completed_process.stderr) + print(completed_process.stderr) if completed_process.returncode != 0: err_msg = f'See {self.workdir / "stderr.txt"} for traceback.' @@ -765,14 +780,14 @@ def submit_sh_array( job_ids = None return job_ids - def submit_slurm_array( + def submit_slurm_array( # pylint: disable=too-many-locals,too-many-branches self, - array_max=None, - dependency: Union[List[str],None] = None, + array_max: int | None = None, + dependency: list[str] | None = None, group_size: int = 1, debug: bool = False, - **kwargs, - ) -> Union[None,List[int]]: + **_kwargs, + ) -> list[int] | None: ''' Submits a job array if all the jobs have the same env and use slurm ''' @@ -839,7 +854,7 @@ def submit_slurm_array( if completed_process.stderr is not None: with paropen('stderr.txt', 'a+', encoding='utf-8') as err_file: - err_file.write(completed_process.stderr) + err_file.write(completed_process.stderr) if completed_process.returncode != 0: err_msg = f'See {self.workdir / "stderr.txt"} for traceback.' @@ -847,15 +862,14 @@ def submit_slurm_array( completed_process.check_returncode() if debug: - # logging.error('STDOUT:'+f'{completed_process.stdout}') - logging.error('STDERR:'+f'{completed_process.stderr}') + logging.error('STDERR: %s', completed_process.stderr) job_ids = None else: job_ids = [int(completed_process.stdout.split(' ')[-1])] return job_ids - def submit(self, **kwargs) -> None: - ''' + def submit(self, **kwargs) -> list[int] | None: + ''' Submit a job using slurm, interactively or in the current console ''' @@ -881,9 +895,9 @@ class ChainedJob(Job): ''' def __init__( self, - sim_input: Dict, - env_input: Union[None,Dict] = None, - calc_input: Union[None,Dict] = None, + sim_input: dict, + env_input: dict | None = None, + calc_input: dict | None = None, ) -> None: super().__init__(sim_input, env_input, calc_input) @@ -903,18 +917,20 @@ def __init__( self.unitjobs = unitjobs - def get_last_output(self) -> Dict: + def get_last_output(self) -> dict: ''' Returns the output of the last job in the chain ''' return self.unitjobs[-1].get_output() - def submit(self, dependency: Union[List,None] = None, debug: bool = False) -> List: + def submit( # pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-nested-blocks + self, dependency: list | None = None, debug: bool = False) -> list: ''' Submit a job using slurm, interactively or in the terminal ''' cur_dir = Path('.').resolve() os.chdir(self.workdir) logger = self.get_logger() - step = 0 #self.get_current_step() TODO: This feature is not used yet + step = 0 # self.get_current_step() TODO: This feature is not used yet + status = 'unknown' are_interactive_jobs = [ uj.env['mode'].get('interactive', False) \ for uj in self.unitjobs @@ -961,44 +977,7 @@ def submit(self, dependency: Union[List,None] = None, debug: bool = False) -> Li curjob.env['slurm']['flags']['-J'] = \ f'step-{step+i}' - # if i < len(self.unitjobs)-1: - # nextjob = self.unitjobs[i+1] - - # curworkdir = os.path.relpath( - # curjob.workdir, - # nextjob.workdir - # ) - # nextjob.sim_input['dependent_dir'] = str(curworkdir) - - # #### dep in job script implementation - # #if there is a following job - # if i < len(self.unitjobs)-1: - # nextjob = self.unitjobs[i+1] - - # nextworkdir = os.path.relpath( - # nextjob.workdir, - # curjob.workdir - # ) - # curworkdir = os.path.relpath( - # curjob.workdir, - # nextjob.workdir - # ) - # # Add postcommands to go into the next workdir - # postcommands = curjob.env['slurm'].get( - # 'postcommands', [] - # ) - # postcommands += ['\n#Submitting next step:'] - # postcommands += [f'cd {nextworkdir}'] - # # submit the next job dependent in the current - # # sh script - # submit_txt = 'asim-execute sim_input.yaml ' - # submit_txt += '-c calc_input.yaml ' - # submit_txt += '-e env_input.yaml ' - # postcommands += [submit_txt] - # postcommands += [f'cd {curworkdir}'] - # curjob.env['slurm']['postcommands'] = postcommands - # ##### - # submit the next job dependent on the current one + # submit the next job dependent on the current one # Previous working solution write_image = False # Write image first step in chain being run/continued @@ -1022,16 +1001,12 @@ def submit(self, dependency: Union[List,None] = None, debug: bool = False) -> Li job_ids = dependency # Otherwise just submit them one after the other - # We only write the image if it's the first job, otherwise we refer to + # We only write the image if it's the first job, otherwise we refer to # wherever the image comes from in case it has to come from a previous # step else: for i, unitjob in enumerate(self.unitjobs[step:]): - if i == 0: - write_image = True - else: - write_image = False - + write_image = i == 0 job_ids = unitjob.submit(write_image=write_image) os.chdir(cur_dir) @@ -1039,7 +1014,7 @@ def submit(self, dependency: Union[List,None] = None, debug: bool = False) -> Li return job_ids -def load_job_from_directory(workdir: os.PathLike) -> Job: +def load_job_from_directory(workdir: str | Path, asimrun_mode: bool = False) -> Job: ''' Loads a job from a given directory ''' workdir = Path(workdir) assert workdir.exists(), f'Work directory "{workdir}" does not exist' @@ -1050,22 +1025,21 @@ def load_job_from_directory(workdir: os.PathLike) -> Job: logger.error('sim_input.yaml not found in %s', {str(workdir)}) raise exc - env_input_file = workdir / 'env_input.yaml' - if env_input_file.exists(): - env_input = read_yaml(env_input_file) - else: + try: + env_input = read_yaml(workdir / 'env_input.yaml') + except FileNotFoundError: env_input = None - calc_input_file = workdir / 'calc_input.yaml' - if calc_input_file.exists(): - calc_input = read_yaml(calc_input_file) - else: + try: + calc_input = read_yaml(workdir / 'calc_input.yaml') + except FileNotFoundError: calc_input = None job = Job( sim_input=sim_input, env_input=env_input, calc_input=calc_input, + asimrun_mode=asimrun_mode, ) # This makes sure that wherever we may be loading the job from, we refer @@ -1074,13 +1048,15 @@ def load_job_from_directory(workdir: os.PathLike) -> Job: job.workdir = Path(workdir) return job -def create_unitjob(sim_input, env_input, workdir, calc_input=None): +def create_unitjob( + sim_input: dict, + env_input: dict, + workdir: str | Path, + calc_input: dict | None = None, +) -> UnitJob: """Helper for making a generic UnitJob object, mostly for testing""" env_id = list(env_input.keys())[0] sim_input['env_id'] = env_id - if calc_input is not None: - calc_id = list(calc_input.keys())[0] - sim_input['calc_id'] = calc_id sim_input['workdir'] = workdir unitjob = UnitJob( sim_input, @@ -1089,13 +1065,13 @@ def create_unitjob(sim_input, env_input, workdir, calc_input=None): ) return unitjob -def get_subjobs(workdir): +def get_subjobs(workdir: Path) -> list[Path]: """Get all the directories with jobs in them - :param workdir: _description_ - :type workdir: _type_ - :return: _description_ - :rtype: _type_ + :param workdir: directory to search for subjobs + :type workdir: Path + :return: sorted list of subdirectory paths containing sim_input.yaml + :rtype: list[Path] """ subjob_dirs = [] for subdir in workdir.iterdir(): @@ -1106,14 +1082,14 @@ def get_subjobs(workdir): return sorted(subjob_dirs) def load_job_tree( - workdir: str = './', -) -> Dict: + workdir: str | Path = './', +) -> dict: """Loads all the jobs in a directory in a tree format using recursion :param workdir: root directory from which to recursively find jobs, defaults to './' :type workdir: str, optional :return: dictionary mimicking the job tree - :rtype: Dict + :rtype: dict """ workdir = Path(workdir).resolve() @@ -1133,22 +1109,21 @@ def load_job_tree( } return job_dict -def check_job_tree_complete(job_tree: Dict, skip_failed: bool=False) -> bool: +def check_job_tree_complete(job_tree: dict, skip_failed: bool = False) -> tuple[bool, str]: + ''' Recursively check if all jobs in a job tree are complete ''' if job_tree['subjobs'] is None: status = job_tree['job'].get_status()[1] - if status == 'complete' or status =='discard' or skip_failed: + if status == 'complete' or status == 'discard' or skip_failed: return True, status - else: + return False, status + complete_so_far = True + for subjob_id in job_tree['subjobs']: + subjob = job_tree['subjobs'][subjob_id] + complete, status = check_job_tree_complete( + subjob, + skip_failed=skip_failed + ) + complete_so_far = complete_so_far and complete + if not complete_so_far: return False, status - else: - complete_so_far = True - for subjob_id in job_tree['subjobs']: - subjob = job_tree['subjobs'][subjob_id] - complete, status = check_job_tree_complete( - subjob, - skip_failed=skip_failed - ) - complete_so_far = complete_so_far and complete - if not complete_so_far: - return False, status - return True, status + return True, status diff --git a/asimtools/scripts/__init__.py b/src/asimtools/scripts/__init__.py similarity index 100% rename from asimtools/scripts/__init__.py rename to src/asimtools/scripts/__init__.py diff --git a/asimtools/scripts/asim_check.py b/src/asimtools/scripts/asim_check.py similarity index 78% rename from asimtools/scripts/asim_check.py rename to src/asimtools/scripts/asim_check.py index f9e4e3d..6d74216 100755 --- a/asimtools/scripts/asim_check.py +++ b/src/asimtools/scripts/asim_check.py @@ -41,7 +41,7 @@ def main(args=None) -> None: :param args: cmdline args, defaults to None :type args: _type_, optional - """ + """ sim_input, rootdir, max_level = parse_command_line(args) workdir = sim_input.get('workdir', 'results') if not workdir.startswith('/'): @@ -86,7 +86,7 @@ def load_job_tree( else: subjob_dict = None - job = load_job_from_directory(workdir) + job = load_job_from_directory(workdir, asimrun_mode=True) job_dict = { 'workdir_name': workdir.name, 'job': job, @@ -94,18 +94,21 @@ def load_job_tree( } return job_dict -def get_status_and_color(job): - ''' Helper to get printing colors ''' - status = job.get_status()[1] +def _status_color(status): + ''' Map a status string to a colorama color ''' if status == 'complete': - color = Fore.GREEN - elif status == 'failed': - color = Fore.RED - elif status == 'started': - color = Fore.BLUE - else: - color = Fore.WHITE - return status, color + return Fore.GREEN + if status == 'failed': + return Fore.RED + if status == 'started': + return Fore.BLUE + return Fore.WHITE + +def get_status_and_color(job): + ''' Helper to get printing colors — reads output.yaml once ''' + output = job.get_output() + status = output.get('status', 'clean') + return status, _status_color(status) def print_job_tree( job_tree: Dict, @@ -131,10 +134,14 @@ def print_job_tree( pass elif subjobs is not None: workdir = job_tree['workdir_name'] - status, color = get_status_and_color(job_tree['job']) - asimmodule = job_tree['job'].sim_input['asimmodule'] + job = job_tree['job'] + output = job.get_output() # single read per node + status = output.get('status', 'clean') + color = _status_color(status) + asimmodule = job.sim_input['asimmodule'] + job_ids = output.get('job_ids', 'none') print(color + f'{indent_str}{workdir}, asimmodule: {asimmodule},' + \ - f'status: {status}' + reset) + f'status: {status}, job_ids: {job_ids}' + reset) if level > 0: indent_str = '| ' + ' ' * level for subjob_id in subjobs: @@ -144,14 +151,16 @@ def print_job_tree( level=level+1, max_level=max_level, ) - subjob = job_tree['job'] else: subjob_dir = job_tree['workdir_name'] subjob = job_tree['job'] + output = subjob.get_output() # single read per node + status = output.get('status', 'clean') + color = _status_color(status) asimmodule = subjob.sim_input['asimmodule'] - status, color = get_status_and_color(subjob) + job_ids = output.get('job_ids', 'none') print(color + f'{indent_str}{subjob_dir}, asimmodule: {asimmodule}, '+\ - f'status: {status}' + reset) + f'status: {status}, job_ids: {job_ids}' + reset) if __name__ == "__main__": main(sys.argv[1:]) diff --git a/asimtools/scripts/asim_execute.py b/src/asimtools/scripts/asim_execute.py similarity index 99% rename from asimtools/scripts/asim_execute.py rename to src/asimtools/scripts/asim_execute.py index 02c3b0f..b0152d7 100755 --- a/asimtools/scripts/asim_execute.py +++ b/src/asimtools/scripts/asim_execute.py @@ -71,7 +71,6 @@ def parse_command_line(args) -> Tuple[Dict, Dict, Dict]: else: dependency = None - calc_input = args.calc env_input = args.env if env_input is not None: env_input = read_yaml(env_input) diff --git a/asimtools/scripts/asim_run.py b/src/asimtools/scripts/asim_run.py similarity index 90% rename from asimtools/scripts/asim_run.py rename to src/asimtools/scripts/asim_run.py index 4fa90a4..6e5dcde 100755 --- a/asimtools/scripts/asim_run.py +++ b/src/asimtools/scripts/asim_run.py @@ -8,6 +8,7 @@ import importlib import sys import os +import socket from pathlib import Path import argparse import subprocess @@ -50,7 +51,7 @@ def parse_command_line(args) -> Tuple[Dict, str]: return sim_input, calc_input_file -def main(args=None) -> None: +def main(args=None) -> None: # pylint: disable=too-many-locals,too-many-branches,too-many-statements ''' Main ''' sim_input, calc_input_file = parse_command_line(args) @@ -70,7 +71,6 @@ def main(args=None) -> None: precommands = sim_input.get('precommands', []) for precommand in precommands: command = precommand.split() - completed_process = subprocess.run(command, check=True) completed_process = subprocess.run( command, check=False, capture_output=True, text=True, ) @@ -122,17 +122,17 @@ def main(args=None) -> None: try: spec.loader.exec_module(sim_module) except Exception as exc: - t = f'Failed to load asimmodule "{asimmodule}". Possible causes:\n' - t += '* The asimmodule has a bug, see traceback above\n' - t += '* ASIMTOOLS_ASIMMODULE_DIR variable not set properly.\n' - t += '* You can provide the full path to the asimmodule' - logger.error(t) - raise FileNotFoundError(t) from exc + txt = f'Failed to load asimmodule "{asimmodule}". Possible causes:\n' + txt += '* The asimmodule has a bug, see traceback above\n' + txt += '* ASIMTOOLS_ASIMMODULE_DIR variable not set properly.\n' + txt += '* You can provide the full path to the asimmodule' + logger.error(txt) + raise FileNotFoundError(txt) from exc sim_func = getattr(sim_module, func_name) cwd = Path('.').resolve() - job = load_job_from_directory(cwd) + job = load_job_from_directory(cwd, asimrun_mode=True) job.start() try: @@ -165,6 +165,8 @@ def main(args=None) -> None: job_ids = os.getenv('SLURM_JOB_ID') results['job_ids'] = job_ids + results['hostname'] = socket.gethostname() + job.update_output(results) job.complete() diff --git a/asimtools/utils.py b/src/asimtools/utils.py similarity index 62% rename from asimtools/utils.py rename to src/asimtools/utils.py index d05ace3..77b026f 100644 --- a/asimtools/utils.py +++ b/src/asimtools/utils.py @@ -14,12 +14,13 @@ import yaml from natsort import natsorted import numpy as np +import matplotlib.pyplot as plt import pandas as pd -from ase.io import read +from ase.io import read, write from ase.parallel import paropen import ase.db import ase.build -from pymatgen.core import Structure +from pymatgen.core import Structure, Lattice from pymatgen.io.ase import AseAtomsAdaptor Atoms = TypeVar('Atoms') @@ -39,17 +40,17 @@ def read_yaml(yaml_path: str) -> Dict: return {} return output -def write_yaml(yaml_path: str, yaml_Dict: Dict) -> None: +def write_yaml(yaml_path: str, yaml_dict: Dict) -> None: """Write a dictionary to a yaml file :param yaml_path: Path to write yaml to :type yaml_path: str - :param yaml_Dict: Dictionary to write - :type yaml_Dict: Dict - """ + :param yaml_dict: Dictionary to write + :type yaml_dict: Dict + """ # Use paropen so that only the master process is updating outputs with paropen(yaml_path, 'w', encoding='utf-8') as f: - yaml.dump(yaml_Dict, f) + yaml.dump(yaml_dict, f, sort_keys=False) def get_axis_lims(x: Sequence, y: Sequence, padding: float=0.1): """Get an estimate of good limits for a plot axis""" @@ -59,6 +60,28 @@ def get_axis_lims(x: Sequence, y: Sequence, padding: float=0.1): lims = [data_min - padding * diff, data_max + padding * diff] return lims +def improve_plot(ax=None, fontsize=14): + ''' Apply standard formatting improvements to a matplotlib axis ''' + if ax is None: + ax = plt.gca() + + ax.tick_params(labelsize=fontsize) + ax.set_xlabel(ax.get_xlabel(), fontsize=fontsize+2) + ax.set_ylabel(ax.get_ylabel(), fontsize=fontsize+2) + ax.set_title(ax.get_title(), fontsize=fontsize+4) + + # Format Legends + if ax.get_legend() is not None: + plt.rc('legend', fontsize=fontsize) + leg = ax.get_legend() + leg.fontsize = fontsize + if leg.get_title() is not None: + leg.set_title( + leg.get_title().get_text(), + prop={'size': fontsize+2} + ) + plt.tight_layout() + def write_csv_from_dict( fname: str, data: Dict, @@ -127,16 +150,106 @@ def join_names(substrs: Sequence[str]) -> str: name = '__'.join(final_substrs) + '__' return name -def get_atoms( +def write_atoms( + image_file: str, + atoms: Union[Atoms,list[Atoms]], + fmt: str = 'extxyz', + write_info: bool = True, + columns: Optional[Sequence] = None, + **kwargs +): + """ + Writes image/images to a file. The default format is extxyz + and the default columns are symbols and positions. + All the images should have the same metadata + + :param image_file: Path to file to write to + :type image_file: str + :param atoms: Atoms object or list of atoms objects to write + :type atoms: Atoms or list[Atoms] + :param fmt: Format to write, defaults to 'extxyz' + :type fmt: str + :param write_info: Whether to write info, defaults to True + :type write_info: bool + :param columns: Columns to write, mostly for debugging, if used, specify + all columns including positions, symbols etc. defaults to None, + :type columns: Sequence, optional + :param kwargs: Extra keyword arguments passed to :func:`ase.io.write` + :type kwargs: Any + :raises ValueError: If the format is not supported + :return: None + :rtype: None + """ + if kwargs.get('format', False): + fmt = kwargs.pop('format') + + if isinstance(atoms, list): + if len(atoms) == 0: + raise ValueError('No images to write') + images = atoms + atoms = images[0] + else: + images = [atoms] + + if fmt in ['extxyz']: + if kwargs.get('write_info', False): + write_info = kwargs.pop('write_info') + + reserved_ks = ['symbols', 'positions', 'numbers', 'species', 'pos'] + columns = ['symbols', 'positions'] + [ + k for k in atoms.arrays.keys() if k not in reserved_ks + ] + + if len(atoms.constraints) > 0: + columns.append('move_mask') + + if images[0].calc is not None: + if 'forces' in images[0].calc.results: + columns.append('forces') + + user_columns = kwargs.get('columns', None) + if user_columns is not None: + columns = list(set(columns + user_columns)) + + for image in images: + # Current workaround magmoms being NaNs is to replace NaNs with 0.0 + if 'initial_magmoms' in image.arrays: + image.arrays['initial_magmoms'] = np.where( + np.isnan(image.arrays['initial_magmoms']), + 0.0, + image.arrays['initial_magmoms'], + ) + + write( + image_file, + images, + format=fmt, + write_info=write_info, + write_results=True, + columns=columns, + **kwargs + ) + else: + write( + image_file, + images, + format=fmt, + **kwargs + ) + + +def get_atoms( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements image_file: Optional[str] = None, interface: str = 'ase', builder: Optional[str] = 'bulk', atoms: Optional[Atoms] = None, repeat: Optional[Tuple[int, int, int]] = None, + repeat_to_n_args: Optional[Dict] = None, rattle_stdev: Optional[float] = None, mp_id: Optional[str] = None, user_api_key: Optional[str] = None, return_type: str = 'ase', + constraints: Sequence[dict] = None, **kwargs ) -> Union[Atoms, Structure]: """Return an ASE Atoms or pymatgen Structure object based on specified @@ -164,6 +277,9 @@ def get_atoms( :param user_api_key: Material Project API key, must be provided to get structures from Materials Project, defaults to None :type user_api_key: str, optional + :param constraints: List of constraints to apply to the atoms object, + currently only supports FixAtoms constraints, defaults to None + :type constraints: Sequence[dict], optional :param return_type: When set to `ase` returns a :class:`ase.Atoms` object, when set to `pymatgen` returns a :class:`pymatgen.core.structure.Structure` object, defaults to 'ase' @@ -174,8 +290,8 @@ def get_atoms( There are three options one could use to specify and image or an atoms objects: - #. image_file + \*\*kwargs - #. builder + \*\*kwargs. + #. image_file + ``**kwargs`` + #. builder + ``**kwargs``. #. atoms Examples @@ -186,12 +302,19 @@ def get_atoms( >>> get_atoms(builder='molecule', name='H2O') Atoms(symbols='OH2', pbc=False) - >>> get_atoms(builder='bulk', name='Cu') - Atoms(symbols='Cu', pbc=True, cell=[[0.0, 1.805, 1.805], [1.805, 0.0, 1.805], [1.805, 1.805, 0.0]]) + >>> get_atoms(builder='bulk', name='Cu') # doctest: +ELLIPSIS + Atoms(symbols='Cu', pbc=True, ...) >>> get_atoms(builder='bulk', name='Ar', crystalstructure='fcc', a=3.4, cubic=True) Atoms(symbols='Ar4', pbc=True, cell=[3.4, 3.4, 3.4]) - >>> get_atoms(builder='fcc100', symbol='Fe', vacuum=8, size=[4,4, 5]) - Atoms(symbols='Cu80', pbc=[True, True, False], cell=[10.210621920333747, 10.210621920333747, 23.22], tags=...) + >>> get_atoms(builder='fcc100', symbol='Fe', vacuum=8, size=[4,4,5]) # doctest: +ELLIPSIS + Atoms(symbols='Cu80', pbc=[True, True, False], ...) + + You can also specify constraints to fix atoms in place, for example + >>> get_atoms( # doctest: +ELLIPSIS + ... builder='fcc100', symbol='Fe', vacuum=8, size=[4,4,5], + ... constraints=[{'constraint': 'FixAtoms', 'indices': [0,1]}]) + Atoms(symbols='Cu80', pbc=[True, True, False], ...) + Some examples for reading an image from a file using :func:`ase.io.read` are given below. All ``**kwargs`` are passed to :func:`ase.io.read` @@ -203,26 +326,31 @@ def get_atoms( >>> get_atoms(image_file='h2o.cif', format='cif') Atoms(symbols='OH2', pbc=False) >>> from ase.io import write - >>> molecules = [get_atoms(builder='molecule', name='H2O'), get_atoms(builder='molecule', name='H2')] + >>> molecules = [get_atoms(builder='molecule', name='H2O'), + ... get_atoms(builder='molecule', name='H2')] >>> write('molecules.xyz', molecules, format='extxyz') >>> get_atoms(image_file='molecules.xyz', index=0) # Pick out one structure using indexing Atoms(symbols='OH2', pbc=False) - You can also make supercells and rattle the atoms + You can also make supercells and rattle the atoms or repeat to a target >>> li_bulk = get_atoms(name='Li') >>> li_bulk.write('POSCAR', format='vasp') - >>> get_atoms(image_file='POSCAR', repeat=[3,3,3]) - Atoms(symbols='Li27', pbc=True, cell=[[-5.235, 5.235, 5.235], [5.235, -5.235, 5.235], [5.235, 5.235, -5.235]]) - >>> get_atoms(builder='bulk', name='Li', repeat=[2,2,2], rattle_stdev=0.01) - Atoms(symbols='Li8', pbc=True, cell=[[-3.49, 3.49, 3.49], [3.49, -3.49, 3.49], [3.49, 3.49, -3.49]]) + >>> get_atoms(image_file='POSCAR', repeat=[3,3,3]) # doctest: +ELLIPSIS + Atoms(symbols='Li27', pbc=True, ...) + >>> get_atoms( # doctest: +ELLIPSIS + ... builder='bulk', name='Li', repeat=[2,2,2], rattle_stdev=0.01) + Atoms(symbols='Li8', pbc=True, ...) + >>> get_atoms( # doctest: +ELLIPSIS + ... builder='bulk', name='Li', repeat_to_n_args={'n': 16, 'max_dim': 10.0}) + Atoms(symbols='Li16', pbc=True, ...) Mostly for internal use and use in asimmodules, one can specify atoms directly >>> li_bulk = get_atoms(name='Li') - >>> get_atoms(atoms=li_bulk) - Atoms(symbols='Li', pbc=True, cell=[[-1.745, 1.745, 1.745], [1.745, -1.745, 1.745], [1.745, 1.745, -1.745]]) + >>> get_atoms(atoms=li_bulk) # doctest: +ELLIPSIS + Atoms(symbols='Li', pbc=True, ...) In an asimmodule, the ``image`` argument is always given as a dictionary, you therefore have to expand it before passing it to ``get_atoms`` @@ -244,12 +372,28 @@ def get_atoms( You can also specify whether you want the primitive(default) or conventional unit cell as a keyword argument - >>> {'mp_id': 'mp-14', 'interface': 'pymatgen', 'user_api_key': "USER_API_KEY", 'conventional_unit_cell': True}, + >>> image = { + ... 'mp_id': 'mp-14', 'interface': 'pymatgen', + ... 'user_api_key': "USER_API_KEY", 'conventional_unit_cell': True} >>> get_atoms(**image) Structure Summary Lattice abc : 2.7718585822512662 2.7718585822512662 2.7718585822512662 ... + + You can also specify a builder from pymatgen.core.structure or + pymatgen.core.molecule, for example pymatgen.core.surface.Structure. + The lattice paramters are passed as an ArrayLike with shape [3,3] to + :class:`pymatgen.core.lattice.Lattice` or dictionary to the + :func:`pymatgen.core.lattice.Lattice.from_parameters` function. + >>> image = { # doctest: +ELLIPSIS + ... 'builder': 'pymatgen.core.surface.Structure', + ... 'lattice': {'a': 2.7, 'b': 2.7, 'c': 2.7, 'alpha': 90, 'beta': 90, 'gamma': 90}} + >>> get_atoms(**image) + Structure Summary + Lattice + abc : 2.7 2.7 2.7 + ... """ if interface == 'ase': assert image_file is not None or \ @@ -272,14 +416,19 @@ def get_atoms( raise else: assert atoms is not None, 'Specify an input structure' - + if interface == 'pymatgen': + # pylint: disable=import-outside-toplevel if mp_id is not None: from pymatgen.ext.matproj import MPRester with MPRester(user_api_key) as mpr: - struct = mpr.get_structure_by_material_id(mp_id, **kwargs) + struct = mpr.get_structure_by_material_id(mp_id, **kwargs) elif builder is not None: - builder_func = getattr(Structure, builder) + import pymatgen.core # pylint: disable=redefined-outer-name + builder_func = getattr(pymatgen.core, builder) + lattice = kwargs.get('lattice', False) + if isinstance(lattice, dict): + kwargs['lattice'] = Lattice.from_parameters(**lattice) try: struct = builder_func(**kwargs) except ValueError: @@ -302,14 +451,37 @@ def get_atoms( elif rattle_stdev is not None and interface == 'pymatgen': struct.perturb(distance=rattle_stdev, min_distance=0) + if repeat_to_n_args is not None and interface == 'ase': + atoms = repeat_to_n(atoms, **repeat_to_n_args) + elif repeat_to_n_args is not None and interface == 'pymatgen': + raise NotImplementedError( + 'repeat_to_n_args is only implemented for ASE interface' + ) + + if constraints is not None and interface == 'ase': + consts = [] + for constraint_args in constraints: + name = constraint_args.pop('constraint') + assert name == 'FixAtoms', \ + 'Only FixAtoms constraints are supported for ASE interface' + constraint_cls = getattr(ase.constraints, name, None) + const = constraint_cls(**constraint_args) + consts.append(const) + + for const in consts: + atoms.set_constraint(const) + if return_type == 'ase' and interface == 'ase': return atoms - elif return_type == 'pymatgen' and interface == 'ase': + if return_type == 'pymatgen' and interface == 'ase': + if builder == 'molecule': + return AseAtomsAdaptor.get_molecule(atoms) return AseAtomsAdaptor.get_structure(atoms) - elif return_type == 'pymatgen' and interface == 'pymatgen': + if return_type == 'pymatgen' and interface == 'pymatgen': return struct - elif return_type == 'ase' and interface == 'pymatgen': + if return_type == 'ase' and interface == 'pymatgen': return AseAtomsAdaptor.get_atoms(struct, msonable=False) + return None def parse_slice(value: str, bash: bool = False) -> slice: """Parses a :func:`slice` from string, like `start:stop:step`. @@ -329,16 +501,56 @@ def parse_slice(value: str, bash: bool = False) -> slice: parts.append(index) if not bash: return slice(*[int(p) if p else None for p in parts]) - else: - if not parts[0]: - parts[0] = '0' - if not parts[1]: - parts[1] = '$END' - if len(parts) == 2: - parts.append('1') - return f'$(seq {parts[0]} {parts[2]} {parts[1]})' - -def get_images( + if not parts[0]: + parts[0] = '0' + if not parts[1]: + parts[1] = '$END' + if len(parts) == 2: + parts.append('1') + return f'$(seq {parts[0]} {parts[2]} {parts[1]})' + +def repeat_to_n( + atoms: Atoms, + n: int, + max_dim: float = 50.0, +) -> Atoms: + """Scale a structure to have approximately n atoms in the unit cell. + The function repeats the shortest axis of the unit cell until the + number of atoms >= n or the longest axis of the unit cell is > max_dim. + + :param atoms: Input atoms object + :type atoms: Atoms + :param n: Target number of atoms + :type n: int + :param max_dim: Maximum length of the longest axis of the unit cell, + defaults to 50.0 + :type max_dim: float, optional + :raises ValueError: If it fails to scale the structure to n atoms + :return: Scaled atoms object + :rtype: Atoms + """ + cell = atoms.get_cell() + lengths = [np.linalg.norm(vec) for vec in cell] + num_atoms = len(atoms) + new_atoms = atoms.copy() + + while num_atoms < n and np.max(lengths) < max_dim: + shortest_axis = np.argmin(lengths) + repeat_vec = [1, 1, 1] + repeat_vec[shortest_axis] += 1 + new_atoms = new_atoms.repeat(repeat_vec) + cell = new_atoms.get_cell() + lengths = [np.linalg.norm(vec) for vec in cell] + num_atoms = len(new_atoms) + + if num_atoms < n: + raise ValueError( + f'Failed to scale structure to {n} atoms without exceeding ' + f'max_dim of {max_dim} Angstroms' + ) + return new_atoms + +def get_images( # pylint: disable=too-many-positional-arguments image_file: str = None, pattern: str = None, patterns: List[str] = None, @@ -399,12 +611,12 @@ def get_images( >>> molecules.append(get_atoms(builder='molecule', name='H2')) >>> molecules.append(get_atoms(builder='molecule', name='N2')) >>> write('molecules.xyz', molecules, format='extxyz') - >>> get_images(image_file='molecules.xyz') - [Atoms(symbols='OH2', pbc=False), Atoms(symbols='H2', pbc=False), Atoms(symbols='N2', pbc=False)] + >>> get_images(image_file='molecules.xyz') # doctest: +ELLIPSIS + [Atoms(symbols='OH2', pbc=False), ...] >>> get_images(image_file='molecules.xyz', index=':2') [Atoms(symbols='OH2', pbc=False), Atoms(symbols='H2', pbc=False)] - You can also use a wildcard (\*) by specifying the pattern argument. Notice + You can also use a wildcard (``*``) by specifying the pattern argument. Notice that the files don't have to be the same format if ASE can guess all the file formats, otherwise you can specify the format argument which should apply to all the images. @@ -415,28 +627,27 @@ def get_images( >>> fe.write('bulk_fe.cif') >>> pt = get_atoms(name='Pt') >>> pt.write('bulk_pt.cfg') - >>> get_images(pattern='bulk*') - [Atoms(symbols='Cu', pbc=True, cell=[[0.0, 1.805, 1.805], [1.805, 0.0, 1.805], [1.805, 1.805, 0.0]], masses=..., momenta=...), Atoms(symbols='Fe', pbc=True, cell=[[2.48549, 0.0, 0.0], [-0.8284876429214074, 2.3433456351179887, 0.0], [-0.8284876429214074, -1.171653675382785, 2.0294079014797743]], spacegroup_kinds=...), Atoms(symbols='Pt', pbc=True, cell=[[0.0, 1.96, 1.96], [1.96, 0.0, 1.96], [1.96, 1.96, 0.0]], masses=..., momenta=...)] - Atoms(symbols='OH2', pbc=False) - >>> get_images(pattern='bulk*.cfg', format='cfg') - [Atoms(symbols='Cu', pbc=True, cell=[[0.0, 1.805, 1.805], [1.805, 0.0, 1.805], [1.805, 1.805, 0.0]], masses=..., momenta=...), Atoms(symbols='Pt', pbc=True, cell=[[0.0, 1.96, 1.96], [1.96, 0.0, 1.96], [1.96, 1.96, 0.0]], masses=..., momenta=...)] - + >>> get_images(pattern='bulk*') # doctest: +ELLIPSIS + [Atoms(symbols='Cu', ...), ...] + >>> get_images(pattern='bulk*.cfg', format='cfg') # doctest: +ELLIPSIS + [Atoms(symbols='Cu', ...), ...] + You can also specify multiple patterns - >>> get_images(patterns=['bulk*.cfg', 'bulk\*.cif']) - [Atoms(symbols='Cu', pbc=True, cell=[[0.0, 1.805, 1.805], [1.805, 0.0, 1.805], [1.805, 1.805, 0.0]], masses=..., momenta=...), Atoms(symbols='Pt', pbc=True, cell=[[0.0, 1.96, 1.96], [1.96, 0.0, 1.96], [1.96, 1.96, 0.0]], masses=..., momenta=...), Atoms(symbols='Fe', pbc=True, cell=[[2.48549, 0.0, 0.0], [-0.8284876429214074, 2.3433456351179887, 0.0], [-0.8284876429214074, -1.171653675382785, 2.0294079014797743]], spacegroup_kinds=...)] - + >>> get_images(patterns=['bulk*.cfg', 'bulk*.cif']) # doctest: +ELLIPSIS + [Atoms(symbols='Cu', ...), ...] + Or you can directly pass a list of Atoms, mostly for internal use - >>> get_images(images=molecules) - [Atoms(symbols='OH2', pbc=False), Atoms(symbols='H2', pbc=False), Atoms(symbols='N2', pbc=False)] + >>> get_images(images=molecules) # doctest: +ELLIPSIS + [Atoms(symbols='OH2', pbc=False), ...] In an asimmodule, the ``images`` argument is always given as a dictionary, you therefore have to expand it before passing it to ``get_images`` >>> images = {'pattern': 'bulk*'} - >>> get_images(**images) - [Atoms(symbols='Cu', pbc=True, cell=[[0.0, 1.805, 1.805], [1.805, 0.0, 1.805], [1.805, 1.805, 0.0]], masses=..., momenta=...), Atoms(symbols='Fe', pbc=True, cell=[[2.48549, 0.0, 0.0], [-0.8284876429214074, 2.3433456351179887, 0.0], [-0.8284876429214074, -1.171653675382785, 2.0294079014797743]], spacegroup_kinds=...), Atoms(symbols='Pt', pbc=True, cell=[[0.0, 1.96, 1.96], [1.96, 0.0, 1.96], [1.96, 1.96, 0.0]], masses=..., momenta=...)] + >>> get_images(**images) # doctest: +ELLIPSIS + [Atoms(symbols='Cu', ...), ...] """ assert (image_file is not None) or \ (pattern is not None) or \ @@ -455,21 +666,16 @@ def get_images( f'No images matching pattern "{pattern}" from "{os.getcwd()}"' images = [] - for image_file in image_files: - image_file = Path(image_file).resolve() + for fpath in image_files: + fpath = Path(fpath).resolve() try: - new_images = read( - image_file, - index=index, - **kwargs - ) - except Exception as exc: + new_images = read(fpath, index=index, **kwargs) + except Exception as exc: # pylint: disable=broad-exception-caught if not skip_failed: raise IOError( - f"Failed to read {image_file} from {os.getcwd()}" + f"Failed to read {fpath} from {os.getcwd()}" ) from exc - else: - new_images = [] + new_images = [] # Output of read can either be list of atoms or Atoms, depending on index if not isinstance(new_images, list): @@ -478,12 +684,12 @@ def get_images( elif patterns is not None: images = [] - for pattern in patterns: - image_files = natsorted(glob(pattern)) - assert len(image_files) > 0, \ - f'Don\'t include pattern "{pattern}" if no files match' + for pat in patterns: + pat_files = natsorted(glob(pat)) + assert len(pat_files) > 0, \ + f'Don\'t include pattern "{pat}" if no files match' images += get_images( - pattern=pattern, + pattern=pat, index=index, skip_failed=skip_failed, **kwargs @@ -494,7 +700,8 @@ def get_images( images = [] if not skip_failed: - assert len(images) > 0, 'No images found' + addontxt = f' in image_file: {image_file}' if image_file else '' + assert len(images) > 0, 'No images found' + addontxt return images @@ -581,76 +788,82 @@ def check_if_slurm_job_is_running(slurm_job_id: Union[str,int]): """ slurm_job_id = str(slurm_job_id) completed_process = subprocess.run( - ['squeue', '--job', slurm_job_id], + ['squeue', '--job', slurm_job_id], check=False, capture_output=True, text=True, ) stdout = completed_process.stdout - if str(' '+slurm_job_id) in stdout: - return True - else: - return False + return str(' ' + slurm_job_id) in stdout def change_dict_value( - d: Dict, + dct: Dict, new_value, key_sequence: Sequence, - return_copy: bool = True + return_copy: Optional[bool] = True, + placeholder: Optional[str] = None, ) -> Dict: - """Changes a value in the specified dictionary given by following the + """Changes a value in the specified dictionary given by following the key sequence - :param d: dictionary to be changed - :type d: Dict + :param dct: dictionary to be changed + :type dct: Dict :param new_value: The new value that will replace the old one :type new_value: _type_ - :param key_sequence: List of keys in the order in which they access the dictionary key + :param key_sequence: List of keys in the order in which they access the + dictionary key :type key_sequence: Sequence - :param return_copy: Whether to return a copy only or to modify the dictionary in-place as well, defaults to True + :param return_copy: Whether to return a copy only or to modify the + dictionary in-place as well, defaults to True :type return_copy: bool, optional :return: The changed dictionary :rtype: Dict """ if return_copy: - d = deepcopy(d) + dct = deepcopy(dct) if len(key_sequence) == 1: - d[key_sequence[0]] = new_value - return d - else: - new_d = change_dict_value( - d[key_sequence[0]], - new_value, - key_sequence[1:], - return_copy=return_copy - ) - d[key_sequence[0]] = new_d - return d + if placeholder is None: + dct[key_sequence[0]] = new_value + else: + dct[key_sequence[0]] = dct[key_sequence[0]].replace( + placeholder, new_value + ) + return dct + new_d = change_dict_value( + dct[key_sequence[0]], + new_value, + key_sequence[1:], + return_copy=return_copy, + placeholder=placeholder, + ) + dct[key_sequence[0]] = new_d + return dct def change_dict_values( - d: Dict, + dct: Dict, new_values: Sequence, key_sequences: Sequence, return_copy: bool = True ) -> Dict: - """Changes values in the specified dictionary given by following the + """Changes values in the specified dictionary given by following the key sequences. Key-value pairs are set in the given order - :param d: dictionary to be changed - :type d: Dict + :param dct: dictionary to be changed + :type dct: Dict :param new_values: The new values that will replace the old one :type new_values: Sequence - :param key_sequence: List of list of keys in the order in which they access the dictionary key + :param key_sequence: List of list of keys in the order in which they + access the dictionary key :type key_sequence: Sequence - :param return_copy: Whether to return a copy only or to modify the dictionary in-place as well, defaults to True + :param return_copy: Whether to return a copy only or to modify the + dictionary in-place as well, defaults to True :type return_copy: bool, optional :return: The changed dictionary :rtype: Dict """ for key_sequence, new_value in zip(key_sequences, new_values): - d = change_dict_value(d, new_value, key_sequence, return_copy) - - return d + dct = change_dict_value(dct, new_value, key_sequence, return_copy) + return dct def get_logger( logfile='job.log', @@ -675,16 +888,16 @@ def get_logger( def get_str_btn( - s: Union[str,os.PathLike], + string: Union[str, os.PathLike], s1: str, s2: str, occurence: Optional[int] = 0, start_index: Optional[int] = 0, ): - """Returns the substring between strings s1 and s2 from s + """Returns the substring between strings s1 and s2 from string - :param s: string/path from which to extract substring - :type s: str + :param string: string/path from which to extract substring + :type string: str :param s1: substring before the desired substring, None starts from the beginning of s :type s1: str @@ -700,25 +913,36 @@ def get_str_btn( :return: substring :rtype: _type_ """ - s = str(s) + string = str(string) j = 0 - stop_index = len(s) + 1 - s = s[start_index:stop_index] + stop_index = len(string) + 1 + string = string[start_index:stop_index] while occurence - j >= 0: if s1 is not None: - i1 = s.index(s1) + len(s1) + try: + i1 = string.index(s1) + len(s1) + except ValueError as exc: + raise ValueError( + f'substring {s1} not found in {string}' + ) from exc else: i1 = 0 if s2 is not None: - i2 = s[i1:].index(s2) + i1 + try: + i2 = string[i1:].index(s2) + i1 + except ValueError as exc: + raise ValueError( + f'substring {s2} not found in {string}' + ) from exc else: - i2 = len(s) + i2 = len(string) if occurence - j == 0: - return s[i1:i2] + return string[i1:i2] - s = s[i1:] + string = string[i1:] j += 1 + return None def find_nth(haystack: str, needle: str, n: int) -> int: ''' Return index of nth occurence of substring in string ''' @@ -729,27 +953,29 @@ def find_nth(haystack: str, needle: str, n: int) -> int: return start def get_nth_label( - s: os.PathLike, + string: os.PathLike, n: int = 1, ): ''' Return nth label in a string potentially containing multiple labels, indexing starts from 0 ''' - s = str(s) - start = find_nth(s, '__', n=(n*2+1)) - return get_str_btn(s, '__', '__', start_index=start) + string = str(string) + start = find_nth(string, '__', n=n*2+1) + return get_str_btn(string, '__', '__', start_index=start) -def expand_wildcards(d: Dict, root_path: os.PathLike = None) -> Dict: +def expand_wildcards(dct: Dict, root_path: os.PathLike = None) -> Dict: """Expands paths in a dictionary - :param d: Dictionary to expand paths in - :type d: Dict[str, Any] + :param dct: Dictionary to expand paths in + :type dct: Dict[str, Any] :param root_path: Root path to expand paths from :type root_path: os.PathLike :return: Dictionary with expanded paths :rtype: Dict[str, Any] """ - import os - def expand_value(value: str, root_path: os.PathLike = None) -> Union[str, list]: + def expand_value( + value: str, + root_path: os.PathLike = None + ) -> Union[str, list]: if '*' in value: if root_path is None: root_path = Path('./') @@ -762,11 +988,10 @@ def expand_value(value: str, root_path: os.PathLike = None) -> Union[str, list]: value = os.path.relpath(value, root_path) return value - for key, value in d.items(): + for key, value in dct.items(): if isinstance(value, str): - d[key] = expand_value(value, root_path=root_path) + dct[key] = expand_value(value, root_path=root_path) elif isinstance(value, dict): - d[key] = expand_wildcards(value, root_path=root_path) - + dct[key] = expand_wildcards(value, root_path=root_path) - return d \ No newline at end of file + return dct diff --git a/tests/asimmodules/workflows/test_distributed.py b/tests/asimmodules/workflows/test_distributed.py index 0c59605..6ff3039 100644 --- a/tests/asimmodules/workflows/test_distributed.py +++ b/tests/asimmodules/workflows/test_distributed.py @@ -17,7 +17,7 @@ def create_distjob(sim_input, env_input, workdir, calc_input=None): sim_input['env_id'] = env_id if calc_input is not None: calc_id = list(calc_input.keys())[0] - sim_input['calc_id'] = calc_id + sim_input.setdefault('args', {})['calculator'] = {'calc_id': calc_id} sim_input['workdir'] = workdir distjob = DistributedJob( sim_input['args']['subsim_inputs'], @@ -86,5 +86,3 @@ def test_batch_distributed(env_input, calc_input, sim_input, tmp_path, request): uj = load_job_from_directory(d) print('job_info:', uj.workdir, uj.get_status()) assert uj.get_status()[1] == statuses[d_ind] - - # assert distjob.get_status(descend=False) == (True, 'complete') diff --git a/tests/asimmodules/workflows/test_sim_array.py b/tests/asimmodules/workflows/test_sim_array.py index c02e467..0ea29fc 100644 --- a/tests/asimmodules/workflows/test_sim_array.py +++ b/tests/asimmodules/workflows/test_sim_array.py @@ -59,6 +59,18 @@ 'id-0003__value-3.0__' ] ), + ( + { + "arange_args": (0,4,1), + "as_integers": True, + }, + [ + 'id-0000__value-0__', + 'id-0001__value-1__', + 'id-0002__value-2__', + 'id-0003__value-3__' + ] + ), ( { "linspace_args": (0,3,4) diff --git a/tests/conftest.py b/tests/conftest.py index 2e799f0..3654aa7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -189,14 +189,14 @@ def do_nothing_sim_input(): @pytest.fixture def lj_distributed_sim_input(): - ''' + ''' Sim input for a distributed job that does some lj calculations ''' subsim_input = { 'asimmodule': 'singlepoint', 'env_id': 'inline', 'args': { - 'calc_id': 'lj', + 'calculator': {'calc_id': 'lj'}, 'image': { 'name': 'Ar', }, @@ -219,7 +219,7 @@ def lj_distributed_sim_input(): @pytest.fixture def lj_distributed_skip_failed_sim_input(): - ''' + ''' Sim input for a distributed job that does some lj calculations but the first job fails ''' @@ -227,7 +227,7 @@ def lj_distributed_skip_failed_sim_input(): 'asimmodule': 'singlepoint', 'env_id': 'inline', 'args': { - 'calc_id': 'lj', + 'calculator': {'calc_id': 'lj'}, 'image': { 'name': 'Ar', }, @@ -254,14 +254,14 @@ def lj_distributed_skip_failed_sim_input(): @pytest.fixture def lj_distributed_custom_name_sim_input(): - ''' + ''' Sim input for a distributed job that does some lj calculations ''' subsim_input = { 'asimmodule': 'singlepoint', 'env_id': 'inline', 'args': { - 'calc_id': 'lj', + 'calculator': {'calc_id': 'lj'}, 'image': { 'name': 'Ar', }, @@ -285,14 +285,14 @@ def lj_distributed_custom_name_sim_input(): @pytest.fixture def lj_distributed_batch_sim_input(): - ''' + ''' Sim input for a distributed job that does some lj calculations ''' subsim_input = { 'asimmodule': 'singlepoint', 'env_id': 'batch', 'args': { - 'calc_id': 'lj', + 'calculator': {'calc_id': 'lj'}, 'image': { 'name': 'Ar', }, @@ -316,14 +316,14 @@ def lj_distributed_batch_sim_input(): @pytest.fixture def lj_distributed_group_batch_sim_input(): - ''' + ''' Sim input for a distributed job that does some lj calculations ''' subsim_input = { 'asimmodule': 'singlepoint', 'env_id': 'batch', # This should be overwrriten by the group env 'args': { - 'calc_id': 'lj', + 'calculator': {'calc_id': 'lj'}, 'image': { 'name': 'Ar', }, diff --git a/tests/data/structures/adslab.xyz b/tests/data/structures/adslab.xyz new file mode 100644 index 0000000..fe4ea29 --- /dev/null +++ b/tests/data/structures/adslab.xyz @@ -0,0 +1,57 @@ +55 +Lattice="10.989862374024527 0.0 6.729349889709534e-16 -3.6632874580081776 10.361341611972845 6.729349889709536e-16 0.0 0.0 29.910616844190965" Properties=species:S:1:pos:R:3:initial_magmoms:R:1:surface_properties:S:1:bulk_wyckoff:S:1:bulk_equivalent:S:1:move_mask:L:1 pbc="T T T" +Na 0.00000000 0.00000000 4.48659253 nan subsurface a 0 F +Na -1.22109582 3.45378054 4.48659253 nan subsurface a 0 F +Na -2.44219164 6.90756107 4.48659253 nan subsurface a 0 F +Na 3.66328746 0.00000000 4.48659253 nan subsurface a 0 F +Na 2.44219164 3.45378054 4.48659253 nan subsurface a 0 F +Na 1.22109582 6.90756107 4.48659253 nan subsurface a 0 F +Na 7.32657492 0.00000000 4.48659253 nan subsurface a 0 F +Na 6.10547910 3.45378054 4.48659253 nan subsurface a 0 F +Na 4.88438328 6.90756107 4.48659253 nan subsurface a 0 F +Na 1.22109582 1.72689027 1.49553084 nan subsurface a 0 F +Na -0.00000000 5.18067081 1.49553084 nan subsurface a 0 F +Na -1.22109582 8.63445134 1.49553084 nan subsurface a 0 F +Na 4.88438328 1.72689027 1.49553084 nan subsurface a 0 F +Na 3.66328746 5.18067081 1.49553084 nan subsurface a 0 F +Na 2.44219164 8.63445134 1.49553084 nan subsurface a 0 F +Na 8.54767074 1.72689027 1.49553084 nan subsurface a 0 F +Na 7.32657492 5.18067081 1.49553084 nan subsurface a 0 F +Na 6.10547910 8.63445134 1.49553084 nan subsurface a 0 F +Na 0.05040356 0.07128126 10.61153565 nan subsurface a 0 T +Na -1.22088381 3.45363060 10.47942977 nan subsurface a 0 T +Na -2.43700358 6.91641549 10.46332553 nan subsurface a 0 T +Na 3.66307537 0.00014975 10.47942998 nan subsurface a 0 T +Na 2.39178795 3.38249920 10.61153531 nan subsurface a 0 T +Na 1.21590808 6.89870710 10.46332525 nan subsurface a 0 T +Na 7.33319379 0.00784251 10.46332545 nan subsurface a 0 T +Na 6.09886082 3.44593812 10.46332545 nan subsurface a 0 T +Na 4.88438293 6.90756134 10.52503220 nan subsurface a 0 T +Na 1.22109579 1.72689044 7.49352623 nan subsurface a 0 T +Na 0.00009148 5.17841763 7.48202082 nan subsurface a 0 T +Na -1.22118690 8.63670456 7.48202064 nan subsurface a 0 T +Na 4.88222840 1.72622555 7.48202032 nan subsurface a 0 T +Na 3.66029354 5.17643697 7.50198302 nan subsurface a 0 T +Na 2.44173987 8.63477030 7.48098737 nan subsurface a 0 T +Na 8.54982542 1.72755533 7.48202044 nan subsurface a 0 T +Na 7.32702677 5.18035170 7.48098729 nan subsurface a 0 T +Na 6.10847301 8.63868512 7.50198293 nan subsurface a 0 T +Na -0.05716541 -0.08084452 16.64749813 nan surface a 0 T +Na -0.66173064 3.05824944 16.35385328 nan surface a 0 T +Na -2.36879258 6.61089299 16.32311991 nan surface a 0 T +Na 3.10392211 0.39553082 16.35385370 nan surface a 0 T +Na 2.49935741 3.53462570 16.64749940 nan surface a 0 T +Na 1.14769723 7.20422919 16.32311967 nan surface a 0 T +Na 7.02240734 -0.02968818 16.32312005 nan surface a 0 T +Na 6.40964675 3.48346858 16.32312000 nan surface a 0 T +Na 4.88438260 6.90756112 16.41278280 nan surface a 0 T +Na 1.22109540 1.72689040 13.93937426 nan subsurface a 0 T +Na -0.06036830 5.24521596 13.41401878 nan subsurface a 0 T +Na -1.16072730 8.56990626 13.41401807 nan subsurface a 0 T +Na 4.96535977 1.69148936 13.41401871 nan subsurface a 0 T +Na 3.64865650 5.15997986 13.52384317 nan subsurface a 0 T +Na 2.48037426 8.60745143 13.39723386 nan subsurface a 0 T +Na 8.46669414 1.76229130 13.41401834 nan subsurface a 0 T +Na 7.28839235 5.20767012 13.39723382 nan subsurface a 0 T +Na 6.12011011 8.65514262 13.52384285 nan subsurface a 0 T +O 1.22109586 1.72688973 16.17505098 2.00000000 adsorbate None None T \ No newline at end of file diff --git a/tests/unit/test_calculators.py b/tests/unit/test_calculators.py new file mode 100644 index 0000000..6d808b2 --- /dev/null +++ b/tests/unit/test_calculators.py @@ -0,0 +1,135 @@ +''' +Tests for calculators.py +''' +import pytest +from ase.calculators.emt import EMT +from asimtools.calculators import load_calc, load_ase_calc + +EMT_PARAMS = { + 'name': 'EMT', + 'module': 'ase.calculators.emt', + 'args': {}, +} + +CALC_INPUT = {'emt': EMT_PARAMS} + + +# ── load_ase_calc ───────────────────────────────────────────────────────────── + +def test_load_ase_calc_returns_correct_type(): + ''' load_ase_calc with EMT params returns an EMT instance ''' + calc = load_ase_calc(EMT_PARAMS) + assert isinstance(calc, EMT) + + +def test_load_ase_calc_with_args(): + ''' load_ase_calc passes args to the calculator constructor ''' + params = { + 'name': 'LennardJones', + 'module': 'ase.calculators.lj', + 'args': {'epsilon': 2.0, 'sigma': 1.5}, + } + from ase.calculators.lj import LennardJones + calc = load_ase_calc(params) + assert isinstance(calc, LennardJones) + assert calc.parameters['epsilon'] == 2.0 + assert calc.parameters['sigma'] == 1.5 + + +def test_load_ase_calc_bad_module(): + ''' load_ase_calc raises when module cannot be imported ''' + params = {'name': 'EMT', 'module': 'nonexistent.module', 'args': {}} + with pytest.raises(ModuleNotFoundError): + load_ase_calc(params) + + +def test_load_ase_calc_bad_name(): + ''' load_ase_calc raises when class name is not found in module ''' + params = {'name': 'NotAClass', 'module': 'ase.calculators.emt', 'args': {}} + with pytest.raises(AttributeError): + load_ase_calc(params) + + +# ── load_calc: new calculator dict interface ────────────────────────────────── + +def test_load_calc_calculator_with_calc_params(): + ''' calculator={"calc_params": ...} loads without a calc_input lookup ''' + calc = load_calc(calculator={'calc_params': EMT_PARAMS}) + assert isinstance(calc, EMT) + + +def test_load_calc_calculator_with_calc_id(): + ''' calculator={"calc_id": ...} looks up params from supplied calc_input ''' + calc = load_calc( + calculator={'calc_id': 'emt'}, + calc_input=CALC_INPUT, + ) + assert isinstance(calc, EMT) + + +def test_load_calc_calculator_with_calc_id_global(tmp_path, monkeypatch): + ''' calculator={"calc_id": ...} falls back to global calc_input env var ''' + from asimtools.utils import write_yaml + calc_input_file = tmp_path / 'calc_input.yaml' + write_yaml(calc_input_file, CALC_INPUT) + monkeypatch.setenv('ASIMTOOLS_CALC_INPUT', str(calc_input_file)) + calc = load_calc(calculator={'calc_id': 'emt'}) + assert isinstance(calc, EMT) + + +def test_load_calc_calculator_sets_label(): + ''' load_calc sets calc.label from calc_params name when no label given ''' + calc = load_calc(calculator={'calc_params': EMT_PARAMS}) + assert calc.label == 'EMT' + + +def test_load_calc_calculator_sets_custom_label(): + ''' load_calc sets calc.label from explicit label key in calc_params ''' + params = dict(EMT_PARAMS, label='my_emt') + calc = load_calc(calculator={'calc_params': params}) + assert calc.label == 'my_emt' + + +# ── load_calc: legacy interface (backward compatibility) ────────────────────── + +def test_load_calc_legacy_calc_params(): + ''' Legacy calc_params kwarg still works ''' + calc = load_calc(calc_params=EMT_PARAMS) + assert isinstance(calc, EMT) + + +def test_load_calc_legacy_calc_id(): + ''' Legacy calc_id kwarg still works with explicit calc_input ''' + calc = load_calc(calc_id='emt', calc_input=CALC_INPUT) + assert isinstance(calc, EMT) + + +# ── load_calc: error cases ──────────────────────────────────────────────────── + +def test_load_calc_no_args_raises(): + ''' load_calc raises AssertionError when called with no identifying args ''' + with pytest.raises(AssertionError): + load_calc() + + +def test_load_calc_missing_calc_id_raises(): + ''' load_calc raises KeyError when calc_id is not in calc_input ''' + with pytest.raises(KeyError): + load_calc(calc_id='missing', calc_input=CALC_INPUT) + + +def test_load_calc_no_module_or_external_name_raises(): + ''' load_calc raises KeyError when calc_params has unknown name and no module ''' + params = {'name': 'UnknownCalc', 'args': {}} + with pytest.raises(KeyError): + load_calc(calc_params=params) + + +def test_load_calc_calculator_takes_precedence_over_legacy(): + ''' calculator kwarg overrides legacy calc_params when both provided ''' + other_params = {'name': 'LennardJones', 'module': 'ase.calculators.lj', 'args': {}} + calc = load_calc( + calculator={'calc_params': EMT_PARAMS}, + calc_params=other_params, + ) + assert isinstance(calc, EMT) diff --git a/tests/unit/test_job.py b/tests/unit/test_job.py index 7691699..b571b36 100644 --- a/tests/unit/test_job.py +++ b/tests/unit/test_job.py @@ -4,10 +4,20 @@ #pylint: disable=missing-function-docstring #pylint: disable=redefined-outer-name +import logging +import os from pathlib import Path import pytest -from asimtools.job import UnitJob, check_job_tree_complete -from asimtools.utils import read_yaml +from asimtools.job import ( + UnitJob, + DistributedJob, + ChainedJob, + check_job_tree_complete, + load_job_from_directory, + get_subjobs, + load_job_tree, +) +from asimtools.utils import read_yaml, write_yaml def create_unitjob(sim_input, env_input, workdir, calc_input=None, status=None): """Helper for making a generic UnitJob object""" @@ -15,7 +25,7 @@ def create_unitjob(sim_input, env_input, workdir, calc_input=None, status=None): sim_input['env_id'] = env_id if calc_input is not None: calc_id = list(calc_input.keys())[0] - sim_input['calc_id'] = calc_id + sim_input.setdefault('args', {})['calculator'] = {'calc_id': calc_id} sim_input['workdir'] = workdir unitjob = UnitJob( sim_input, @@ -207,7 +217,7 @@ def test_slurm_asimmodule(flags, tmp_path): 'env_id': 'test_batch', 'workdir': wdir, 'job_name': jobname, - 'args': {'calc_id': 'test_calc_id'}, + 'args': {'calculator': {'calc_id': 'test_calc_id'}}, } env_input = { @@ -427,5 +437,237 @@ def test_check_job_tree_complete(tmp_path, test_input, expected): ''' Test check_job_tree_complete ''' assert check_job_tree_complete(test_input) == expected - assert check_job_tree_complete(test_input, skip_failed=True)[0] == True - \ No newline at end of file + assert check_job_tree_complete(test_input, skip_failed=True)[0] is True + + +# --------------------------------------------------------------------------- +# Job getter / updater methods +# --------------------------------------------------------------------------- + +def test_get_sim_input_returns_internal(inline_env_input, do_nothing_sim_input, tmp_path): + unitjob = create_unitjob(do_nothing_sim_input, inline_env_input, tmp_path / 'wdir') + assert unitjob.get_sim_input() is unitjob.sim_input + + +def test_get_calc_input_returns_internal(inline_env_input, do_nothing_sim_input, lj_argon_calc_input, tmp_path): + unitjob = create_unitjob( + do_nothing_sim_input, inline_env_input, tmp_path / 'wdir', calc_input=lj_argon_calc_input + ) + assert unitjob.get_calc_input() is unitjob.calc_input + + +def test_get_env_input_returns_internal(inline_env_input, do_nothing_sim_input, tmp_path): + unitjob = create_unitjob(do_nothing_sim_input, inline_env_input, tmp_path / 'wdir') + assert unitjob.get_env_input() is unitjob.env_input + + +def test_update_sim_input(inline_env_input, do_nothing_sim_input, tmp_path): + unitjob = create_unitjob(do_nothing_sim_input, inline_env_input, tmp_path / 'wdir') + unitjob.update_sim_input({'new_key': 'new_value'}) + assert unitjob.get_sim_input()['new_key'] == 'new_value' + + +def test_add_output_files(inline_env_input, do_nothing_sim_input, tmp_path): + wdir = tmp_path / 'wdir' + unitjob = create_unitjob(do_nothing_sim_input, inline_env_input, wdir) + unitjob.gen_input_files() + unitjob.add_output_files({'result': 'out.xyz'}) + assert unitjob.get_output().get('files', {}).get('result') == 'out.xyz' + + +def test_get_logger(inline_env_input, do_nothing_sim_input, tmp_path): + wdir = tmp_path / 'wdir' + unitjob = create_unitjob(do_nothing_sim_input, inline_env_input, wdir) + unitjob.gen_input_files() + logger = unitjob.get_logger() + assert isinstance(logger, logging.Logger) + + +# --------------------------------------------------------------------------- +# UnitJob.gen_run_command +# --------------------------------------------------------------------------- + +def test_gen_run_command_basic(inline_env_input, do_nothing_sim_input, tmp_path): + unitjob = create_unitjob(do_nothing_sim_input, inline_env_input, tmp_path / 'wdir') + cmd = unitjob.gen_run_command() + assert 'asim-run' in cmd + assert 'sim_input.yaml' in cmd + + +def test_gen_run_command_prefix_suffix(tmp_path): + env_input = { + 'inline': { + 'mode': {'use_slurm': False, 'interactive': True}, + } + } + calc_input = { + 'lj': { + 'name': 'LennardJones', + 'module': 'ase.calculators.lj', + 'run_prefix': 'mpirun -n 4', + 'run_suffix': '--some-flag', + 'args': {}, + } + } + sim_input = { + 'asimmodule': 'do_nothing', + 'env_id': 'inline', + 'workdir': str(tmp_path / 'wdir'), + 'args': {'calculator': {'calc_id': 'lj'}}, + } + unitjob = UnitJob(sim_input, env_input=env_input, calc_input=calc_input) + cmd = unitjob.gen_run_command() + assert cmd.index('mpirun -n 4') < cmd.index('asim-run') + assert cmd.index('asim-run') < cmd.index('--some-flag') + + +# --------------------------------------------------------------------------- +# DistributedJob +# --------------------------------------------------------------------------- + +def _make_distributed_sim_input(env_id, n=3): + subsim = {'asimmodule': 'do_nothing', 'env_id': env_id} + return {f'job{i}': dict(subsim) for i in range(n)} + + +def test_distributed_job_init_inline(inline_env_input): + sim_input = _make_distributed_sim_input('inline', n=3) + djob = DistributedJob(sim_input, env_input=inline_env_input) + assert len(djob.unitjobs) == 3 + assert djob.use_slurm is False + # Workdir names must be prefixed with 'id-' + for uj in djob.unitjobs: + assert uj.workdir.name.startswith('id-') + + +def test_distributed_job_init_slurm(batch_env_input): + sim_input = _make_distributed_sim_input('batch', n=2) + djob = DistributedJob(sim_input, env_input=batch_env_input) + assert djob.use_slurm is True + + +def test_distributed_job_init_too_many_jobs(inline_env_input): + sim_input = {f'job{i}': {'asimmodule': 'do_nothing', 'env_id': 'inline'} for i in range(1000)} + with pytest.raises(AssertionError): + DistributedJob(sim_input, env_input=inline_env_input) + + +def test_distributed_job_gen_input_files(inline_env_input, tmp_path): + original_dir = Path('.').resolve() + os.chdir(tmp_path) + try: + sim_input = _make_distributed_sim_input('inline', n=2) + djob = DistributedJob(sim_input, env_input=inline_env_input) + djob.gen_input_files() + for uj in djob.unitjobs: + assert (uj.workdir / 'sim_input.yaml').exists() + finally: + os.chdir(original_dir) + + +def test_distributed_job_submit_inline(inline_env_input, tmp_path): + original_dir = Path('.').resolve() + os.chdir(tmp_path) + try: + sim_input = _make_distributed_sim_input('inline', n=2) + djob = DistributedJob(sim_input, env_input=inline_env_input) + djob.submit() + for uj in djob.unitjobs: + assert uj.get_status()[1] == 'complete' + finally: + os.chdir(original_dir) + + +# --------------------------------------------------------------------------- +# ChainedJob +# --------------------------------------------------------------------------- + +def _make_chained_sim_input(env_id, n=2): + return {f'step-{i}': {'asimmodule': 'do_nothing', 'env_id': env_id} for i in range(n)} + + +def test_chained_job_init(inline_env_input): + sim_input = _make_chained_sim_input('inline', n=3) + cjob = ChainedJob(sim_input, env_input=inline_env_input) + assert len(cjob.unitjobs) == 3 + for i, uj in enumerate(cjob.unitjobs): + assert str(uj.workdir) == f'step-{i}' + + +def test_chained_job_init_bad_keys(inline_env_input): + sim_input = {'bad_key': {'asimmodule': 'do_nothing', 'env_id': 'inline'}} + with pytest.raises(AssertionError): + ChainedJob(sim_input, env_input=inline_env_input) + + +def test_chained_job_get_last_output(tmp_path): + sim_input = _make_chained_sim_input('inline', n=2) + env_input = {'inline': {'mode': {'use_slurm': False, 'interactive': True}}} + chain_dir = tmp_path / 'chain' + chain_dir.mkdir() + cjob = ChainedJob(sim_input, env_input=env_input) + cjob.set_workdir(chain_dir) + cjob.unitjobs[-1].set_workdir(chain_dir / 'step-1') + cjob.unitjobs[-1].gen_input_files() + cjob.unitjobs[-1].update_output({'my_result': 42}) + assert cjob.get_last_output()['my_result'] == 42 + + +# --------------------------------------------------------------------------- +# load_job_from_directory, get_subjobs, load_job_tree +# --------------------------------------------------------------------------- + +def test_load_job_from_directory(inline_env_input, do_nothing_sim_input, tmp_path): + wdir = tmp_path / 'wdir' + unitjob = create_unitjob(do_nothing_sim_input, inline_env_input, wdir) + unitjob.gen_input_files(write_env_input=True) + job = load_job_from_directory(wdir) + assert job.sim_input['asimmodule'] == do_nothing_sim_input['asimmodule'] + assert job.workdir == wdir + + +def test_load_job_from_directory_missing(tmp_path): + with pytest.raises(AssertionError): + load_job_from_directory(tmp_path / 'nonexistent') + + +def test_get_subjobs(inline_env_input, do_nothing_sim_input, tmp_path): + root = tmp_path / 'root' + root.mkdir() + write_yaml(root / 'sim_input.yaml', do_nothing_sim_input) + + # Two subdirs with sim_input.yaml, one without + for name in ('sub_a', 'sub_b', 'empty_sub'): + (root / name).mkdir() + write_yaml(root / 'sub_a' / 'sim_input.yaml', do_nothing_sim_input) + write_yaml(root / 'sub_b' / 'sim_input.yaml', do_nothing_sim_input) + + subjobs = get_subjobs(root) + assert len(subjobs) == 2 + assert all(p.name in ('sub_a', 'sub_b') for p in subjobs) + # Must be sorted + assert subjobs == sorted(subjobs) + + +def test_load_job_tree_flat(inline_env_input, do_nothing_sim_input, tmp_path): + wdir = tmp_path / 'wdir' + unitjob = create_unitjob(do_nothing_sim_input, inline_env_input, wdir) + unitjob.gen_input_files(write_env_input=True) + tree = load_job_tree(wdir) + assert tree['workdir_name'] == wdir.name + assert tree['subjobs'] is None + + +def test_load_job_tree_nested(inline_env_input, do_nothing_sim_input, tmp_path): + root = tmp_path / 'root' + root.mkdir() + write_yaml(root / 'sim_input.yaml', do_nothing_sim_input) + for name in ('sub_a', 'sub_b'): + subdir = root / name + subdir.mkdir() + write_yaml(subdir / 'sim_input.yaml', do_nothing_sim_input) + + tree = load_job_tree(root) + assert tree['subjobs'] is not None + assert set(tree['subjobs'].keys()) == {'sub_a', 'sub_b'} + assert tree['subjobs']['sub_a']['subjobs'] is None diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 7b3acce..67c7f35 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -2,9 +2,17 @@ Tests for utils.py ''' from pathlib import Path +from unittest.mock import patch, MagicMock import os import pytest +import numpy as np +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import pandas as pd from ase.io import read +from ase.calculators.emt import EMT +from pymatgen.core import Structure, Molecule, IStructure, IMolecule from asimtools.utils import ( join_names, get_atoms, @@ -20,6 +28,14 @@ get_nth_label, get_str_btn, expand_wildcards, + write_atoms, + repeat_to_n, + strip_symbols, + get_axis_lims, + improve_plot, + write_csv_from_dict, + new_db, + check_if_slurm_job_is_running, ) import ase.build @@ -58,14 +74,87 @@ def test_join_names(test_input, expected): ase.build.fcc111('Al', size=(2,2,3))), ({'image_file': STRUCT_DIR / 'Ar.xyz'}, ase.build.bulk('Ar')), - ({'image_file': STRUCT_DIR / 'Ar', 'format': 'cfg'}, + ({'image_file': STRUCT_DIR / 'Ar.xyz'}, ase.build.bulk('Ar')), ({'atoms': ase.build.bulk('Ar')}, ase.build.bulk('Ar')), + ({ + 'interface': 'pymatgen', + 'builder': 'Structure', + 'lattice': [[3, 0, 0], [0, 3, 0], [0, 0, 3]], + 'coords': [[0, 0, 0]], + 'species': ['Ar'], + 'return_type': 'pymatgen', + }, Structure( + lattice=[[3, 0, 0], [0, 3, 0], [0, 0, 3]], + coords=[[0, 0, 0]], + species=['Ar'], + coords_are_cartesian=False, + )), + ({ + 'interface': 'pymatgen', + 'builder': 'Structure', + 'lattice': {'a': 3, 'b': 4, 'c': 5, 'alpha': 90, 'beta': 90, 'gamma': 90}, + 'coords': [[0, 0, 0]], + 'species': ['Ar'], + 'return_type': 'pymatgen', + }, Structure( + lattice=[[3, 0, 0], [0, 4, 0], [0, 0, 5]], + coords=[[0, 0, 0]], + species=['Ar'], + coords_are_cartesian=False, + )), + ({ + 'interface': 'pymatgen', + 'builder': 'IStructure', + 'lattice': {'a': 3, 'b': 4, 'c': 5, 'alpha': 90, 'beta': 90, 'gamma': 90}, + 'coords': [[0, 0, 0]], + 'species': ['Ar'], + 'return_type': 'pymatgen', + }, IStructure( + lattice=[[3, 0, 0], [0, 4, 0], [0, 0, 5]], + coords=[[0, 0, 0]], + species=['Ar'], + coords_are_cartesian=False, + )), + ({ + 'interface': 'pymatgen', + 'builder': 'Molecule', + 'coords': [[0, 0, 0], [1.5, 1.5, 1.5], [1.5, -1.5, -1.5]], + 'species': ['O', 'H', 'H'], + 'spin_multiplicity': 1, + 'return_type': 'pymatgen', + }, Molecule( + species=['O', 'H', 'H'], + coords=[[0, 0, 0], [1.5, 1.5, 1.5], [1.5, -1.5, -1.5]], + spin_multiplicity=1, + )), + ({ + 'interface': 'pymatgen', + 'builder': 'IMolecule', + 'coords': [[0, 0, 0], [1.5, 1.5, 1.5], [1.5, -1.5, -1.5]], + 'species': ['O', 'H', 'H'], + 'spin_multiplicity': 1, + 'return_type': 'pymatgen', + }, IMolecule( + species=['O', 'H', 'H'], + coords=[[0, 0, 0], [1.5, 1.5, 1.5], [1.5, -1.5, -1.5]], + spin_multiplicity=1, + )), + ]) def test_get_atoms(test_input, expected): ''' Test getting atoms from different inputs ''' + print('e', expected) assert get_atoms(**test_input) == expected +def test_get_atoms_constraints(tmp_path): + atoms = ase.build.bulk('Cu').repeat((2,2,2)) + constrained_atoms = get_atoms( + atoms=atoms, + constraints=[{'constraint': 'FixAtoms', 'indices': [0, 1]}] + ) + assert len(constrained_atoms.constraints) == 1 + @pytest.mark.parametrize("test_input, expected",[ ({'image_file': str(STRUCT_DIR / 'images.xyz')}, [ase.build.bulk('Ar'), ase.build.bulk('Cu'), ase.build.bulk('Fe')]), @@ -101,13 +190,64 @@ def test_get_atoms(test_input, expected): ]) def test_get_images(test_input, expected): ''' Test getting iterable of atoms from different inputs ''' - print('++input:', get_images(**test_input)) - print('++expected:', expected) input_images = get_images(**test_input) assert len(input_images) == len(expected) for image in input_images: assert image in expected +@pytest.mark.parametrize("test_input, expected",[ + ( + {'image_file': str(STRUCT_DIR / 'adslab.xyz')}, + ('surface_properties', 'bulk_wyckoff'), + ), +]) +def test_write_atoms(test_input, expected, tmp_path): + init_atoms = get_atoms(**test_input) + testfile = tmp_path / 'test.xyz' + write_atoms(testfile, init_atoms) + + with open(testfile, 'r') as f: + lines = f.readlines() + + for prop in expected: + assert prop in lines[1], f'"{prop}" not in file header' + +def test_write_atoms_constraints(tmp_path): + atoms = ase.build.bulk('Cu').repeat((2,2,2)) + atoms.set_constraint(ase.constraints.FixAtoms(indices=[0, 1])) + write_atoms(tmp_path / 'test_constraints.xyz', atoms) + read_atoms = read(tmp_path / 'test_constraints.xyz') + assert len(read_atoms.constraints) > 0 + +@pytest.mark.parametrize("test_input, expected",[ + ( + {'name': 'Cu', 'interface': 'ase', 'builder': 'bulk'}, + ('forces', 'energy', 'stress'), + ), +]) +def test_write_atoms_calc(test_input, expected, tmp_path): + init_atoms = get_atoms(**test_input) + init_atoms.calc = EMT() + init_atoms.get_potential_energy() + testfile = tmp_path / 'test.xyz' + write_atoms(testfile, init_atoms) + + with open(testfile, 'r') as f: + lines = f.readlines() + + for prop in expected: + assert prop in lines[1], f'"{prop}" not in file header' + +def test_write_atoms_magmoms(tmp_path): + ''' Test write_atoms. + Also test that when magmoms are provided by ASE which makes them nans + and kills jobs using VASP etc., we change them to zero ''' + slab_ = read(STRUCT_DIR / 'adslab.xyz') + write_atoms(tmp_path / 'slab.xyz', slab_) + slab = read(tmp_path / 'slab.xyz') + assert not np.any(np.isnan(slab.arrays['initial_magmoms'])) + assert 'surface_properties' in slab.arrays + @pytest.mark.parametrize("test_input, expected",[ (['l1', 'l2', 'l3'], {'l1': {'l2': {'l3': 'new_value'}}}), (['l1', 'l2'], {'l1': {'l2': 'new_value'}}), @@ -122,6 +262,18 @@ def test_change_dict_value(test_input, expected): assert new_d == expected assert new_d != d +@pytest.mark.parametrize("test_input, expected",[ + (['l1', 'l2', 'l3'], {'l1': {'l2': {'l3': 'l3_NEW'}}}), +]) +def test_change_dict_value_placeholder(test_input, expected): + ''' Test getting iterable of atoms from different inputs ''' + d = {'l1': {'l2': {'l3': 'l3_PLACEHOLDER'}}} + new_d = change_dict_value( + d, 'NEW', test_input, return_copy=True, placeholder='PLACEHOLDER', + ) + assert new_d == expected + assert new_d != d + @pytest.mark.parametrize("test_input, expected",[ ([['l1', 'l21', 'l31'], ['l1', 'l21', 'l32']], {'l1': {'l21': {'l31': 'v1', 'l32': 'v2'}, 'l22': 'l22v'}}), @@ -234,4 +386,117 @@ def test_expand_wildcards(test_input, expected, tmp_path): f.write('') print(f'Found paths in {os.getcwd()}: {[f for f in Path(tmp_path).glob("*")]}') - assert expand_wildcards(test_input, root_path=tmp_path) == expected \ No newline at end of file + assert expand_wildcards(test_input, root_path=tmp_path) == expected + +def test_repeat_to_n(): + ''' Test repeating unit cell to at least N atoms ''' + atoms = ase.build.bulk('Cu', crystalstructure='fcc', cubic=True, a=2.0) + repeated_atoms = repeat_to_n(atoms, 16) + assert len(repeated_atoms) == 16 + assert np.abs(repeated_atoms.get_cell()[0][0] - 2*2.0) < 1e-6 + assert np.abs(repeated_atoms.get_cell()[1][1] - 2*2.0) < 1e-6 + assert np.abs(repeated_atoms.get_cell()[2][2] - 1*2.0) < 1e-6 + assert len(repeat_to_n(atoms, 15)) == 16 + with pytest.raises(ValueError): + repeat_to_n(atoms, 16, max_dim=4) + + +@pytest.mark.parametrize("test_input, expected", [ + ('hello', 'hello'), + ('_hello_', 'hello'), + ('-hello-', 'hello'), + ('.hello.', 'hello'), + (' hello ', 'hello'), + ('_-hello-_', 'hello'), + ('', ''), +]) +def test_strip_symbols(test_input, expected): + ''' Test stripping leading/trailing bad symbols from a string ''' + assert strip_symbols(test_input) == expected + + +@pytest.mark.parametrize("x, y, padding, expected_min, expected_max", [ + ([0, 1], [0, 1], 0.1, -0.1, 1.1), + ([0, 2], [1, 3], 0.0, 0.0, 3.0), + ([-1, 1], [-1, 1], 0.5, -2.0, 2.0), +]) +def test_get_axis_lims(x, y, padding, expected_min, expected_max): + ''' Test axis limit computation with padding ''' + lims = get_axis_lims(x, y, padding=padding) + assert np.isclose(lims[0], expected_min) + assert np.isclose(lims[1], expected_max) + + +def test_improve_plot(): + ''' Test that improve_plot sets label and tick fontsizes ''' + fig, ax = plt.subplots() + ax.set_xlabel('X') + ax.set_ylabel('Y') + ax.set_title('Title') + fontsize = 16 + improve_plot(ax=ax, fontsize=fontsize) + assert ax.xaxis.label.get_fontsize() == fontsize + 2 + assert ax.yaxis.label.get_fontsize() == fontsize + 2 + assert ax.title.get_fontsize() == fontsize + 4 + plt.close(fig) + + +def test_improve_plot_with_legend(): + ''' Test that improve_plot handles legends ''' + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], label='line') + ax.legend(title='legend_title') + improve_plot(ax=ax, fontsize=12) + leg = ax.get_legend() + assert leg is not None + plt.close(fig) + + +@pytest.mark.parametrize("data, columns, header", [ + ({'a': [1, 2, 3], 'b': [4, 5, 6]}, None, ''), + ({'a': [1, 2], 'b': [3, 4], 'c': [5, 6]}, ['a', 'c'], 'my header'), +]) +def test_write_csv_from_dict(data, columns, header, tmp_path): + ''' Test writing a dict to CSV and reading it back ''' + fpath = tmp_path / 'test.csv' + result_df = write_csv_from_dict(fpath, data, columns=columns, header=header) + + used_columns = columns if columns is not None else list(data.keys()) + assert list(result_df.columns) == used_columns + + read_df = pd.read_csv(fpath, comment='#') + assert list(read_df.columns) == used_columns + for col in used_columns: + assert list(read_df[col]) == data[col] + + if header: + with open(fpath, 'r', encoding='utf-8') as f: + first_line = f.readline() + assert header in first_line + + +def test_new_db(tmp_path): + ''' Test creating a new ASE database ''' + import ase.db + dbpath = tmp_path / 'test.db' + db = new_db(str(dbpath)) + assert dbpath.exists() + atoms = ase.build.bulk('Cu') + db.write(atoms) + assert db.count() == 1 + + +def test_check_if_slurm_job_is_running_true(): + ''' Test that a job ID found in squeue stdout returns True ''' + mock_result = MagicMock() + mock_result.stdout = ' 12345 some other text' + with patch('asimtools.utils.subprocess.run', return_value=mock_result): + assert check_if_slurm_job_is_running(12345) is True + + +def test_check_if_slurm_job_is_running_false(): + ''' Test that a job ID not in squeue stdout returns False ''' + mock_result = MagicMock() + mock_result.stdout = ' 99999 some other text' + with patch('asimtools.utils.subprocess.run', return_value=mock_result): + assert check_if_slurm_job_is_running(12345) is False \ No newline at end of file