diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..cda2c8f2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: "/" + schedule: + interval: monthly + open-pull-requests-limit: 10 + ignore: + - dependency-name: mypy + versions: + - "0.800" diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 00000000..494f1bbb --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,57 @@ +name: testing + +on: + push: + + pull_request: + branches: + - master + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[strict,tests,with_m3gnet] + pip install prospector[with_mypy,with_bandit] + + - name: Lint with prospector + run: | + prospector src/mpmorph + + - name: Lint with black + run: | + black --check --diff --color src/mpmorph + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10"] + + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[strict,tests,with_m3gnet] + - name: Test + run: pytest diff --git a/.gitignore b/.gitignore index 8137b002..1d07ae11 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,113 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class -mpmorph/\.idea/dictionaries/esivonxay\.xml +# C extensions +*.so -mpmorph/\.idea/misc\.xml +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST -mpmorph/\.idea/modules\.xml +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec -mpmorph/\.idea/mpmorph\.iml +# Installer logs +pip-log.txt +pip-delete-this-directory.txt -mpmorph/\.idea/workspace\.xml +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ -*.pyc +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +\.DS_Store + +\.idea/ +/dask-worker-space/ + +.vscode/ +out.traj +Al1-2000.0K-16.472.traj diff --git a/.prospector.yaml b/.prospector.yaml new file mode 100644 index 00000000..83d9e85a --- /dev/null +++ b/.prospector.yaml @@ -0,0 +1,39 @@ +max-line-length: 88 +test-warnings: false +doc-warnings: false +ignore-paths: + - docs + - tests + +bandit: + run: false + +mccabe: + run: false + +pylint: + disable: + - unsubscriptable-object + - invalid-name + - arguments-differ # to account for jobflow + - logging-fstring-interpolation + - arguments-renamed + - assignment-from-none + - unsupported-membership-test + - cell-var-from-loop + - broad-except + - import-outside-toplevel + - arguments_differ + - duplicate-code + - too-many-branches + - too-many-statements + - too-many-boolean-expressions + - R0902 + - R0903 + - R0913 + - R0914 +mypy: + run: true + options: + ignore-missing-imports: true + follow-imports: skip diff --git a/CITATION.bib b/CITATION.bib new file mode 100644 index 00000000..a6c32e5c --- /dev/null +++ b/CITATION.bib @@ -0,0 +1,33 @@ +@article{Aykol.2018, +year = {2018}, +title = {{Thermodynamic limit for synthesis of metastable inorganic materials}}, +author = {Aykol, Muratahan and Dwaraknath, Shyam S. and Sun, Wenhao and Persson, Kristin A.}, +journal = {Science Advances}, +issn = {2375-2548}, +doi = {10.1126/sciadv.aaq0148}, +pmid = {29725618}, +pmcid = {PMC5930398}, +url = {http://advances.sciencemag.org/lookup/doi/10.1126/sciadv.aaq0148}, +abstract = {{Realizing the growing number of possible or hypothesized metastable crystalline materials is extremely challenging. There is no rigorous metric to identify which compounds can or cannot be synthesized. We present a thermodynamic upper limit on the energy scale, above which the laboratory synthesis of a polymorph is highly unlikely. The limit is defined on the basis of the amorphous state, and we validate its utility by effectively classifying more than 700 polymorphs in 41 common inorganic material systems in the Materials Project for synthesizability. The amorphous limit is highly chemistry-dependent and is found to be in complete agreement with our knowledge of existing polymorphs in these 41 systems, whether made by the nature or in a laboratory. Quantifying the limits of metastability for realizable compounds, the approach is expected to find major applications in materials discovery.}}, +pages = {eaaq0148}, +number = {4}, +volume = {4}, +note = {Publisher: American Association for the Advancement of Science}, +keywords = {}, +month = {04}, +local-url = {file://localhost/Users/mcdermott/Zotero/storage/4FRBFEZJ/Aykol%20et%20al.%20-%202018%20-%20Thermodynamic%20limit%20for%20synthesis%20of%20metastable%20inorganic%20materials(3).pdf} +} +@article{10.1021/acsami.7b14868, +year = {2018}, +title = {{Oxidation Protection with Amorphous Surface Oxides: Thermodynamic Insights from Ab Initio Simulations on Aluminum}}, +author = {Aykol, Muratahan and Persson, Kristin A.}, +journal = {ACS Applied Materials \& Interfaces}, +issn = {1944-8244}, +doi = {10.1021/acsami.7b14868}, +pmid = {29297220}, +abstract = {{Native surface films play a key role in the oxidation and corrosion protection of functional and structural materials. Here, we present a fully ab initio approach for understanding the thermodynamic driving force behind the initial phase selection among amorphous and crystalline structures for a surface film growing on a crystalline substrate. We apply the approach to elucidate the competition among corundum (α), spinel (γ), and amorphous (am.) Al2O3 films growing on aluminum metal. We show that the amorphous Al2O3 film becomes thermodynamically the most stable form below around ∼1 nm, that is, the relative energetic stabilities of thin polymorphic Al2O films follow am. < γ < α. As the film thickness increases, the relative stability relation first changes to γ < α < am. and then to the bulk limit of α < γ < am. The nanoscale γ films distort substantially to form exclusively four- and fivefold-coordinated Al–O polyhedra, lose the close-packed O framework, and become “amorphous-like”, that is, exhibit both short-range order and energetic characteristics that are commensurate with the amorphous form. Our results provide a quantitative, first-principles confirmation for the early hypotheses on the thermodynamic stability of amorphous surface films and provide insights for the critical role they play in oxidation protection. Handling the complexities associated with the initial film growth, including bulk, surface, interface, and strain energy effects in realistically complex ab initio simulations, we expect this approach to contribute to understanding of the mechanism behind effective passivation films for aluminum alloys and beyond.}}, +pages = {3039--3045}, +number = {3}, +volume = {10}, +keywords = {} +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..f9b67c02 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at: mcdermott@lbl.gov. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available +at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..2345fd21 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing to reaction-network + +We would love to have your input! This includes: +* Reporting a bug +* Discussing the current state of the code +* Submitting a fix +* Proposing or implementing new features +* Becoming a maintainer + +## Reporting bugs, getting help, and discussion + +Please make bug reports via the `Issues` section in the repository. +If you are making a bug report, incorporate as many elements of the following as possible to ensure a timely response and avoid the need for followups: +* A quick summary and/or background +* Steps to reproduce - be specific! **Provide sample code.** +* What you expected would happen, compared to what actually happens +* The full stack trace of any errors you encounter +* Notes (possibly including why you think this might be happening, or steps you tried that didn't work) + +## Contributing code modifications or additions through Github + +We use Github to host code, to track issues and feature requests, as well as accept pull requests. + +Pull requests are the best way to propose changes to the codebase. Follow the +[Github flow](https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow) for more information on this procedure. + +The basic procedure for making a PR is: +* Fork the repo and create your branch from master. +* Commit your improvements to your branch and push to your Github fork (repo). +* When you're finished, go to your fork and make a Pull Request. It will automatically update if you need to make further changes. + +### How to Make a **Great** Pull Request + +We have a few tips for writing good PRs that are accepted into the main repo: + +* Use the Google Code style for all of your code. Find an example [here.](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html) +* Your code should have (4) spaces instead of tabs. +* If needed, update the documentation. +* **Write tests** for new features! Good tests are 100%, absolutely necessary for good code. We use the python `pytest` framework -- +see some of the other tests in this repo for examples, or review the [Hitchhiker's guide to python](https://docs.python-guide.org/writing/tests/) +for some good resources on writing good tests. +* Understand your contributions will fall under the same license as this repo. + +When you submit your PR, our CI service will automatically run your tests. +We welcome good discussion on the best ways to write your code, and the comments on your PR are an excellent area for discussion. + +#### Acknowledgments + +This document was adapted from the open-source contribution guidelines for Facebook's Draft, and briandk's +[contribution template](https://gist.github.com/briandk/3d2e8b3ec8daf5a27a62). diff --git a/LICENSE.md b/LICENSE.md index 04af9953..5f10a414 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,4 +1,4 @@ -MPmorph Copyright (c) 2015, The Regents of the University of +MPMorph Copyright (c) 2022, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. @@ -43,4 +43,4 @@ for such Enhancements, then you hereby grant the following license: a non-exclusive, royalty-free perpetual license to install, use, modify, prepare derivative works, incorporate into other computer software, distribute, and sublicense such enhancements or derivative -works thereof, in binary and source code form. \ No newline at end of file +works thereof, in binary and source code form. diff --git a/README.md b/README.md index d5dea60a..0efda8b6 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,34 @@ If you wish to make amorphous structures, please install [packmol](http://m3g.iq export PACKMOL_PATH="path_to_packmol_executable_here" ``` +## Installation with M3Gnet + +This gets tricky. m3gnet is difficult to install on Apple silicon (M1) because of the tensorflow dependency. The most reliable method is as follows: + +1) Create and activate a new conda environment/virtualenv. Python 3.10.6 works well. +2) ```conda install -c apple tensorflow-deps``` +3) ```pip install tensorflow-macos``` +4) Perform a test here by opening a python REPL and running ```import tensorflow```. If you see an issue regarding a numpy datatype not being convertible to a python datatype, reinstall numpy: +``` +conda uninstall numpy +conda install numpy +``` +5) Install mpmorph +``` +pip install mpmorph +``` +6) Next, install m3gnet without dependencies (because it will break when it reaches tensorflow) +``` +pip install --no-deps m3gnet +``` +7) Finally, install the other m3gnet dependencies manually: +``` +pip install protobuf==3.19.6 pymatgen ase cython +``` +8) Test your installation by opening a python REPL and running: +```import m3gnet``` + + # Using MPmorph Before diving headfirst into mpmorph, one should get familiar with how to run fireworks before jumping into mpmorph. Relevant features of fireworks include setting up a fireworks database, adding workflows to the database, configuring a conda environment for running fireworks on a supercomputer, launching jobs to a workload manager (using qlaunch), and monitoring fireworks jobs. For a quick tutorial, check out [beginner](https://www.youtube.com/watch?v=-MalOMJt34U) and [intermediate](https://www.youtube.com/watch?v=zYA_BbKwVO4) atomate/fireworks tutorials from our 2019 Materials project workshop. diff --git a/mpmorph.egg-info/PKG-INFO b/mpmorph.egg-info/PKG-INFO deleted file mode 100644 index 1b7f1bcf..00000000 --- a/mpmorph.egg-info/PKG-INFO +++ /dev/null @@ -1,10 +0,0 @@ -Metadata-Version: 1.0 -Name: mpmorph -Version: 1.0 -Summary: UNKNOWN -Home-page: https://github.com/aykol/mpmorph -Author: Muratahan Aykol -Author-email: maykol@lbl.gov -License: modified BSD -Description: UNKNOWN -Platform: UNKNOWN diff --git a/mpmorph.egg-info/SOURCES.txt b/mpmorph.egg-info/SOURCES.txt deleted file mode 100644 index d56b6eb9..00000000 --- a/mpmorph.egg-info/SOURCES.txt +++ /dev/null @@ -1,20 +0,0 @@ -setup.py -mpmorph/__init__.py -mpmorph.egg-info/PKG-INFO -mpmorph.egg-info/SOURCES.txt -mpmorph.egg-info/dependency_links.txt -mpmorph.egg-info/top_level.txt -mpmorph/analysis/__init__.py -mpmorph/analysis/clustering_analysis.py -mpmorph/analysis/diffusion.py -mpmorph/analysis/environment_tracking.py -mpmorph/analysis/md_data.py -mpmorph/analysis/rings_analysis.py -mpmorph/analysis/structural_analysis.py -mpmorph/runners/__init__.py -mpmorph/runners/amorphous_maker.py -mpmorph/runners/rescale_volume.py -mpmorph/workflow/Test_WF.py -mpmorph/workflow/__init__.py -mpmorph/workflow/mdtasks.py -mpmorph/workflow/workflows.py \ No newline at end of file diff --git a/mpmorph.egg-info/dependency_links.txt b/mpmorph.egg-info/dependency_links.txt deleted file mode 100644 index 8b137891..00000000 --- a/mpmorph.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/mpmorph.egg-info/top_level.txt b/mpmorph.egg-info/top_level.txt deleted file mode 100644 index 069d4548..00000000 --- a/mpmorph.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -mpmorph diff --git a/mpmorph/__init__.py b/mpmorph/__init__.py deleted file mode 100644 index 3b3dacb9..00000000 --- a/mpmorph/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '2.0' diff --git a/mpmorph/analysis/__init__.py b/mpmorph/analysis/__init__.py deleted file mode 100644 index 6aef55a2..00000000 --- a/mpmorph/analysis/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__author__ = 'Muratahan Aykol ' diff --git a/mpmorph/analysis/__pycache__/__init__.cpython-35.pyc b/mpmorph/analysis/__pycache__/__init__.cpython-35.pyc deleted file mode 100644 index 6f9c9b28..00000000 Binary files a/mpmorph/analysis/__pycache__/__init__.cpython-35.pyc and /dev/null differ diff --git a/mpmorph/analysis/__pycache__/__init__.cpython-36.pyc b/mpmorph/analysis/__pycache__/__init__.cpython-36.pyc deleted file mode 100644 index eea7995f..00000000 Binary files a/mpmorph/analysis/__pycache__/__init__.cpython-36.pyc and /dev/null differ diff --git a/mpmorph/analysis/__pycache__/clustering_analysis.cpython-35.pyc b/mpmorph/analysis/__pycache__/clustering_analysis.cpython-35.pyc deleted file mode 100644 index 5e984fbc..00000000 Binary files a/mpmorph/analysis/__pycache__/clustering_analysis.cpython-35.pyc and /dev/null differ diff --git a/mpmorph/analysis/__pycache__/clustering_analysis.cpython-36.pyc b/mpmorph/analysis/__pycache__/clustering_analysis.cpython-36.pyc deleted file mode 100644 index af6cd90f..00000000 Binary files a/mpmorph/analysis/__pycache__/clustering_analysis.cpython-36.pyc and /dev/null differ diff --git a/mpmorph/analysis/__pycache__/diffusion.cpython-35.pyc b/mpmorph/analysis/__pycache__/diffusion.cpython-35.pyc deleted file mode 100644 index c1fc5726..00000000 Binary files a/mpmorph/analysis/__pycache__/diffusion.cpython-35.pyc and /dev/null differ diff --git a/mpmorph/analysis/__pycache__/environment_tracking.cpython-35.pyc b/mpmorph/analysis/__pycache__/environment_tracking.cpython-35.pyc deleted file mode 100644 index 2daecc95..00000000 Binary files a/mpmorph/analysis/__pycache__/environment_tracking.cpython-35.pyc and /dev/null differ diff --git a/mpmorph/analysis/__pycache__/environment_tracking.cpython-36.pyc b/mpmorph/analysis/__pycache__/environment_tracking.cpython-36.pyc deleted file mode 100644 index 9f64e9ce..00000000 Binary files a/mpmorph/analysis/__pycache__/environment_tracking.cpython-36.pyc and /dev/null differ diff --git a/mpmorph/analysis/__pycache__/md_data.cpython-35.pyc b/mpmorph/analysis/__pycache__/md_data.cpython-35.pyc deleted file mode 100644 index 278dbe2b..00000000 Binary files a/mpmorph/analysis/__pycache__/md_data.cpython-35.pyc and /dev/null differ diff --git a/mpmorph/analysis/__pycache__/md_data.cpython-36.pyc b/mpmorph/analysis/__pycache__/md_data.cpython-36.pyc deleted file mode 100644 index d3810ec0..00000000 Binary files a/mpmorph/analysis/__pycache__/md_data.cpython-36.pyc and /dev/null differ diff --git a/mpmorph/analysis/__pycache__/structural_analysis.cpython-35.pyc b/mpmorph/analysis/__pycache__/structural_analysis.cpython-35.pyc deleted file mode 100644 index f22647e9..00000000 Binary files a/mpmorph/analysis/__pycache__/structural_analysis.cpython-35.pyc and /dev/null differ diff --git a/mpmorph/analysis/__pycache__/structural_analysis.cpython-36.pyc b/mpmorph/analysis/__pycache__/structural_analysis.cpython-36.pyc deleted file mode 100644 index 9e63a6b8..00000000 Binary files a/mpmorph/analysis/__pycache__/structural_analysis.cpython-36.pyc and /dev/null differ diff --git a/mpmorph/database/__pycache__/__init__.cpython-35.pyc b/mpmorph/database/__pycache__/__init__.cpython-35.pyc deleted file mode 100644 index a75b20f9..00000000 Binary files a/mpmorph/database/__pycache__/__init__.cpython-35.pyc and /dev/null differ diff --git a/mpmorph/database/__pycache__/__init__.cpython-36.pyc b/mpmorph/database/__pycache__/__init__.cpython-36.pyc deleted file mode 100644 index 8b3b6eeb..00000000 Binary files a/mpmorph/database/__pycache__/__init__.cpython-36.pyc and /dev/null differ diff --git a/mpmorph/database/__pycache__/database.cpython-35.pyc b/mpmorph/database/__pycache__/database.cpython-35.pyc deleted file mode 100644 index 78a6abac..00000000 Binary files a/mpmorph/database/__pycache__/database.cpython-35.pyc and /dev/null differ diff --git a/mpmorph/database/__pycache__/database.cpython-36.pyc b/mpmorph/database/__pycache__/database.cpython-36.pyc deleted file mode 100644 index d67a3803..00000000 Binary files a/mpmorph/database/__pycache__/database.cpython-36.pyc and /dev/null differ diff --git a/mpmorph/runners/__init__.py b/mpmorph/runners/__init__.py deleted file mode 100644 index 6aef55a2..00000000 --- a/mpmorph/runners/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__author__ = 'Muratahan Aykol ' diff --git a/mpmorph/workflows/__init__.py b/mpmorph/workflows/__init__.py deleted file mode 100644 index 6aef55a2..00000000 --- a/mpmorph/workflows/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__author__ = 'Muratahan Aykol ' diff --git a/mpmorph/workflows/converge.py b/mpmorph/workflows/converge.py deleted file mode 100644 index 28111ad8..00000000 --- a/mpmorph/workflows/converge.py +++ /dev/null @@ -1,138 +0,0 @@ -import uuid -from copy import deepcopy - -from fireworks import Workflow -from mpmorph.fireworks import powerups -from mpmorph.fireworks.core import MDFW -from mpmorph.util import recursive_update - -__author__ = 'Eric Sivonxay, Jianli Cheng, and Muratahan Aykol' -__maintainer__ = 'Eric Sivonxay' -__email__ = 'esivonxay@lbl.gov' - - -def get_converge_wf(structure, temperature, converge_scheme='EOS', priority=None, - max_steps=5000, target_steps=10000, preconverged=False, - notes=None, save_data="all", **kwargs): - """ - - Args: - structure: Starting structure for the run - temperature: Temperature for the MD runs - converge_scheme: Equation of state is normally faster and preferred - priority: Priority of all fireworks in the workflows - max_steps: Maximum number of steps per chunk of production run MD simulation - target_steps: Target number of steps for production MD run - preconverged: Whether the structure already converged (i.e. Pressure 0bar) - or volume rescaling not desired - notes: Any additional comments to propagate with this run - save_data: Level to save job outputs. Options are "all", 'production', and None - **kwargs: Arguments such as spawner_args, converge_args, convergence_criteria, - tag_id, prod_count, etc. - - Returns: Workflow object - - """ - # Generate a unique identifier for the fireworks belonging to this workflows - tag_id = kwargs.get('tag_id', uuid.uuid4()) - prod_count = kwargs.get('prod_count', 0) - wf_name = kwargs.get('wf_name', f'{structure.composition.reduced_formula}_{temperature}_diffusion') - - fw_list = [] - - # Setup initial Run and convergence of structure - run_args = {"md_params": {"start_temp": temperature, "end_temp": temperature, "nsteps": 2000}, - "run_specs": {"vasp_input_set": None, "vasp_cmd": ">>vasp_cmd<<", "db_file": ">>db_file<<"}, - "optional_fw_params": { - "override_default_vasp_params": {'user_incar_settings': {'ISIF': 1, 'LWAVE': False, - 'PREC': 'Normal'}}, - "spec": {'_priority': priority} - } - } - run_args = recursive_update(run_args, kwargs.get('converge_args', {})) - - # Setup Dictionary specifying parameters of the spawner for convergence tasks - _spawner_args = { - "converge_params": {"max_rescales": 15, "density_spawn_count": 1, "energy_spawn_count": 0, - 'converge_type': kwargs.get('convergence_criteria', - [("density", 5), ('ionic', 0.001)])}, - "rescale_params": {"beta": 5e-7}, - "run_specs": run_args["run_specs"], - "md_params": run_args["md_params"], - "optional_fw_params": run_args["optional_fw_params"], - "tag_id": tag_id - } - _spawner_args["md_params"].update({"start_temp": run_args["md_params"]["end_temp"]}) - _spawner_args = recursive_update(_spawner_args, kwargs.get('spawner_args', {})) - - # Converge the pressure (volume) of the system - if not preconverged: - insert_converge_data = True if save_data == "all" else False - - if converge_scheme == 'EOS': - # Create structures for varying volumes - images = kwargs.get('image_scale', [0.8, 1, 1.2]) - structures = [structure.copy() for i in images] - for i, factor in enumerate(images): - structures[i].scale_lattice(structure.volume * factor) - - # Create firework for each structure - EOS_run_args = deepcopy(run_args) - EOS_run_args = recursive_update(EOS_run_args, kwargs.get('converge_args', {})) - volume_fws = [] - for n, (i, vol_structure) in enumerate(zip(images, structures)): - save_structure = True if n == len(images) - 1 else False - _fw = MDFW(structure=vol_structure, name=f'volume_{i}-{tag_id}', - previous_structure=False, insert_db=insert_converge_data, save_structure=save_structure, - **EOS_run_args["md_params"], **EOS_run_args["run_specs"], - **EOS_run_args["optional_fw_params"]) - - _fw = powerups.add_pass_pv(_fw) - volume_fws.append(_fw) - fw_list.extend(volume_fws) - - # Create firework to converge pressure/volume - spawner_fw = MDFW(structure=structure, name=f'run1-{tag_id}', - previous_structure=True, insert_db=insert_converge_data, - parents=volume_fws, **run_args["md_params"], - **run_args["run_specs"], **run_args["optional_fw_params"]) - - spawner_fw = powerups.add_pv_volume_rescale(spawner_fw) - spawner_fw = powerups.add_pass_pv(spawner_fw) - _spawner_args['run_specs']['insert_db'] = insert_converge_data - spawner_fw = powerups.add_converge_task(spawner_fw, **_spawner_args) - fw_list.append(spawner_fw) - else: - fw1 = MDFW(structure=structure, name="run0" + "-" + str(tag_id), - previous_structure=False, insert_db=insert_converge_data, - **run_args["md_params"], **run_args["run_specs"], **run_args["optional_fw_params"]) - fw1 = powerups.add_converge_task(fw1, **_spawner_args) - fw_list.append(fw1) - - # Production length MD runs - insert_prod_data = True if save_data == "all" or save_data == "production" else False - prod_steps = 0 - while prod_steps <= target_steps - max_steps: - # Create Dictionary with production run parameters - run_args = {"md_params": {"start_temp": run_args["md_params"]["end_temp"], - "end_temp": run_args["md_params"]["end_temp"], - "nsteps": max_steps}, - "run_specs": {"vasp_input_set": None, "vasp_cmd": ">>vasp_cmd<<", "db_file": ">>db_file<<"}, - "optional_fw_params": {"override_default_vasp_params": - {'user_incar_settings': {'ISIF': 1, 'LWAVE': False, - 'PREC': 'Normal'}}, - "spec": {'_priority': priority}}} - run_args = recursive_update(run_args, kwargs.get('prod_args', {})) - - parents = fw_list[-1] if len(fw_list) > 0 else [] - previous_structure = False if preconverged and prod_steps == 0 else True - fw = MDFW(structure=structure, name=f'{temperature}_prod_run_{prod_count}-{tag_id}', - previous_structure=previous_structure, insert_db=insert_prod_data, **run_args["md_params"], - **run_args["run_specs"], **run_args["optional_fw_params"], parents=parents) - fw_list.append(fw) - - prod_steps += max_steps - prod_count += 1 - - wf = Workflow(fireworks=fw_list, name=wf_name) - return wf diff --git a/mpmorph/workflows/diffusion.py b/mpmorph/workflows/diffusion.py deleted file mode 100644 index 8485b1d4..00000000 --- a/mpmorph/workflows/diffusion.py +++ /dev/null @@ -1,8 +0,0 @@ -__author__ = 'Eric Sivonxay and Muratahan Aykol' -__maintainer__ = 'Eric Sivonxay' -__email__ = 'esivonxay@lbl.gov' - - -def get_diffusion(structure, temperatures=[500, 1000, 1500]): - # TODO - return diff --git a/mpmorph/workflows/quench.py b/mpmorph/workflows/quench.py deleted file mode 100644 index 984ae76f..00000000 --- a/mpmorph/workflows/quench.py +++ /dev/null @@ -1,120 +0,0 @@ -import numpy as np -from fireworks import Workflow -from mpmorph.fireworks import powerups -from mpmorph.fireworks.core import StaticFW, MDFW, OptimizeFW -from mpmorph.util import recursive_update - -__author__ = 'Eric Sivonxay and Muratahan Aykol' -__maintainer__ = 'Eric Sivonxay' -__email__ = 'esivonxay@lbl.gov' - - -def get_quench_wf(structures, priority=None, quench_type="slow_quench", - descriptor="", **kwargs): - """ - - Args: - structure: Starting structure for the run - priority: Priority of all fireworks in the workflows - quench_type: use "slow_quench" for a gradual decrease in temperature or - "mp_quench" for a instantaneous DFT relaxation - target_steps: Target number of steps for production MD run - descriptor: Extra description to add to the name of the firework - **kwargs: Arguments such as cool_args, hold_args, quench_args, etc. Cool_args and hold args are only applicable - when using "slow_quench" - - Returns: Workflow object - - """ - - fw_list = [] - temperatures = kwargs.get('temperatures', {"start_temp": 3000, "end_temp": 500, "temp_step": 500}) - cool_args = kwargs.get('cool_args', {"md_params": {"nsteps": 200}}) - hold_args = kwargs.get('hold_args', {"md_params": {"nsteps": 500}}) - quench_args = kwargs.get('quench_args', {}) - - for (i, structure) in enumerate(structures): - _fw_list = [] - if quench_type == "slow_quench": - for temp in np.arange(temperatures["start_temp"], temperatures["end_temp"], -temperatures["temp_step"]): - # get fw for cool step - use_prev_structure = False - if len(_fw_list) > 0: - use_prev_structure = True - _fw = get_MDFW(structure, temp, temp - temperatures["temp_step"], - name="snap_" + str(i) + "_cool_" + str(temp - temperatures["temp_step"]), - args=cool_args, parents=[_fw_list[-1]] if len(_fw_list) > 0 else [], - priority=priority, previous_structure=use_prev_structure, - insert_db=True, **kwargs) - _fw_list.append(_fw) - # get fw for hold step - _fw = get_MDFW(structure, temp - temperatures["temp_step"], temp - temperatures["temp_step"], - name="snap_" + str(i) + "_hold_" + str(temp - temperatures["temp_step"]), - args=hold_args, parents=[_fw_list[-1]], priority=priority, - previous_structure=True, insert_db=True, **kwargs) - _fw_list.append(_fw) - - if quench_type in ["slow_quench", "mp_quench"]: - # Relax OptimizeFW and StaticFW - run_args = {"run_specs": {"vasp_input_set": None, "vasp_cmd": ">>vasp_cmd<<", - "db_file": ">>db_file<<", - "spec": {"_priority": priority} - }, - "optional_fw_params": {"override_default_vasp_params": {}} - } - run_args = recursive_update(run_args, quench_args) - _name = "snap_" + str(i) - - use_prev_structure = True if len(_fw_list) > 0 else False - fw1 = OptimizeFW(structure=structure, name=f'{_name}{descriptor}_optimize', - parents=[_fw_list[-1]] if len(_fw_list) > 0 else [], - previous_structure=use_prev_structure, - **run_args["run_specs"], **run_args["optional_fw_params"], - max_force_threshold=None) - - - fw2 = StaticFW(structure=structure, name=f'{_name}{descriptor}_static', - parents=[fw1], previous_structure=True, - **run_args["run_specs"], - **run_args["optional_fw_params"]) - - _fw_list.extend([fw1, fw2]) - - fw_list.extend(_fw_list) - - name = structure.composition.reduced_formula + descriptor + "_quench" - wf = Workflow(fw_list, name=name) - return wf - - -def get_MDFW(structure, start_temp, end_temp, name="molecular dynamics", priority=None, args={}, - **kwargs): - """ - - Helper function to get molecular dynamics firework for quench workflow - - Args: - structure: Initial structure for molecular dynamics run - start_temp: Starting Temperature - end_temp: Ending Temperature - name: name of firework - priority: priority of job in database - args: custom arguments dictionary for molecular dynamics run - kwargs: kwargs for MDFW - - Returns: Molecular Dynamics Firework - - """ - # Get customized firework - run_args = {"md_params": {"nsteps": 500, "start_temp": start_temp, "end_temp": end_temp}, - "run_specs": {"vasp_input_set": None, "vasp_cmd": ">>vasp_cmd<<", "db_file": ">>db_file<<", - "wall_time": 40000}, - "optional_fw_params": {"override_default_vasp_params": {}, - "spec": {'_priority': priority}}} - - run_args["optional_fw_params"]["override_default_vasp_params"].update( - {'user_incar_settings': {'ISIF': 1, 'LWAVE': False, 'PREC': 'Low'}}) - run_args = recursive_update(run_args, args) - _mdfw = MDFW(structure=structure, name=name, **run_args["md_params"], - **run_args["run_specs"], **run_args["optional_fw_params"], **kwargs) - return _mdfw diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..62dc0ff1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,87 @@ +[build-system] +requires = ["setuptools>=42", "wheel", "setuptools-git-versioning"] +build-backend = "setuptools.build_meta" + +[project] +authors = [ + { name = "Eric Sivonxay", email = "esivonxay@lbl.gov" }, + { name = "Muratahan Aykol", email = "maykol@lbl.gov" }, + { name = "Hui Zheng", email = "huizheng@lbl.gov" }, +] +name = "mpmorph" +dependencies = [ + "mp-api>=0.27.1", + "fireworks>=2.0.3", + "atomate>=1.0.3", + "atomate2>=0.0.8", + "monty>=2022.9.9" +] +description = "MPmorph is a collection of tools to run and analyze ab-initio molecular dynamics (AIMD) calculations run with VASP, and is currently under development. It relies heavily on tools developed by the Materials Project (pymatgen, custodian, fireworks) and atomate." +readme = "README.md" +keywords = ["amorphous", "materials", "computational", "dft", "simulation"] +license = { text = "modified BSD" } +dynamic = ["version"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering :: Information Analysis", + "Topic :: Scientific/Engineering :: Physics", + "Topic :: Scientific/Engineering :: Chemistry", + "Topic :: Software Development :: Libraries :: Python Modules", +] +requires-python = '>=3.8' + +[project.optional-dependencies] +strict = [ + "mp-api==0.27.5", + "fireworks==2.0.3", + "atomate==1.0.3", + "atomate2==0.0.8", + "m3gnet==0.1.0", +] +with_m3gnet = ["m3gnet==0.1.0"] +tests = ["pytest==7.1.3", "pytest-cov==4.0.0"] + +[tool.coverage.run] +include = ["src/*"] +parallel = true +branch = true + +[tool.coverage.paths] +source = ["src/"] + +[tool.coverage.report] +skip_covered = true +show_missing = true +exclude_lines = [ + '\#\s*pragma: no cover', + '^\s*assert False(,|$)', + 'if typing.TYPE_CHECKING:', + '^\s*@overload( |$)', +] + +[tool.flake8] +exclude = ".git,__pycache__,tests" +max-line-length = 88 +max-doc-length = 88 +extend-ignore = "E741,W291,W293,E501,E231,E203,F401" +min-python-version = "3.8.0" +docstring-convention = "google" + +[tool.isort] +profile = "black" +known_first_party = "mpmorph" + +[tool.pytest] +addopts = "--durations=10" + +[tool.setuptools.package-data] +rxn_network = ["py.typed"] + +[tool.setuptools-git-versioning] +enabled = true diff --git a/setup.py b/setup.py deleted file mode 100644 index 8fdb6498..00000000 --- a/setup.py +++ /dev/null @@ -1,11 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name='mpmorph', - version='2.0', - packages=find_packages(), - url='https://github.com/materialsproject/mpmorph', - license='modified BSD', - author='Muratahan Aykol', - author_email='maykol@lbl.gov' -) diff --git a/src/mpmorph/__init__.py b/src/mpmorph/__init__.py new file mode 100644 index 00000000..f2dc0e40 --- /dev/null +++ b/src/mpmorph/__init__.py @@ -0,0 +1 @@ +__version__ = "2.0" diff --git a/src/mpmorph/analysis/__init__.py b/src/mpmorph/analysis/__init__.py new file mode 100644 index 00000000..db6a0457 --- /dev/null +++ b/src/mpmorph/analysis/__init__.py @@ -0,0 +1 @@ +__author__ = "Muratahan Aykol " diff --git a/mpmorph/analysis/diffusion.py b/src/mpmorph/analysis/diffusion.py similarity index 73% rename from mpmorph/analysis/diffusion.py rename to src/mpmorph/analysis/diffusion.py index c5387f19..bb06bd9a 100644 --- a/mpmorph/analysis/diffusion.py +++ b/src/mpmorph/analysis/diffusion.py @@ -4,19 +4,19 @@ from MD calculations. """ -__author__ = 'Muratahan Aykol ' +__author__ = "Muratahan Aykol " import matplotlib.pyplot as plt import numpy as np -import scipy.integrate as integrate from pymatgen.core import Element from pymatgen.io.vasp import Xdatcar -from scipy import stats +from scipy import integrate, stats class Diffusion(object): """ - Robust calculation of diffusion coefficients with different statistical analysis techniques: + Robust calculation of diffusion coefficients with different statistical + analysis techniques: - Block averaging (default) - Jackknife (to be implemented) - Bootstrap (to be implemented) @@ -25,15 +25,18 @@ class Diffusion(object): structures: (list) list of Structures corr_t: (float) correlation time (in terms of # of steps). Each time origin will be this many steps apart. - block_l: (int) defines length of a block in terms of corr_t. (block_t = block_l * corr_t) + block_l: (int) defines length of a block in terms of corr_t. + (block_t = block_l * corr_t) t_step: (float) time-step in MD simulation. Defaults to 2.0 fs. - l_lim: (int) this many time-steps are skipped in MSD while fitting D. I.e. approximate length of - ballistic and cage regions. Defaults to 50. + l_lim: (int) this many time-steps are skipped in MSD while fitting D. + i.e. approximate length of ballistic and cage regions. Defaults to 50. skip_first: (int) this many initial time-steps are skipped. Defaults to 0. ci: (float) confidence interval desired estimating the mean D of population. """ - def __init__(self, structures, corr_t, block_l, t_step=2.0, l_lim=50, skip_first=0, ci=0.95): + def __init__( + self, structures, corr_t, block_l, t_step=2.0, l_lim=50, skip_first=0, ci=0.95 + ): self.structures = structures self.abc = self.structures[0].lattice.abc self.natoms = len(self.structures[0]) @@ -71,11 +74,17 @@ def _getd(self, el): # remove other elements from the rest of the calculations s = set(self.structures[0].indices_from_symbol(el)) - self.md = np.delete(self.md, [x for x in list(range(self.natoms)) if x not in s], 1) + self.md = np.delete( + self.md, [x for x in list(range(self.natoms)) if x not in s], 1 + ) msds = [] for i in range(self.n_origins): - su = np.square(np.cumsum(self.md[i * self.corr_t: i * self.corr_t + self.block_t], axis=0)) + su = np.square( + np.cumsum( + self.md[i * self.corr_t : i * self.corr_t + self.block_t], axis=0 + ) + ) msds.append(np.mean(su, axis=1)) self.msds = msds @@ -95,14 +104,17 @@ def getD(self, el): D = [[], [], []] for i in self.msds: for j in range(3): - slope, intercept, r_value, p_value, std_err = \ - stats.linregress(np.arange(self.l_lim, self.block_t), i[:, j][self.l_lim:]) + slope, intercept, r_value, p_value, std_err = stats.linregress( + np.arange(self.l_lim, self.block_t), i[:, j][self.l_lim :] + ) D[j].append(slope / 2.0) D = np.array(D) * self.scaling_factor self.D_blocks = D alpha = 1.0 - self.ci - tn = stats.t.ppf(1.0 - alpha / 2.0, len(self.D_blocks) - 1) / np.sqrt(len(self.D_blocks)) + tn = stats.t.ppf(1.0 - alpha / 2.0, len(self.D_blocks) - 1) / np.sqrt( + len(self.D_blocks) + ) if tn == "nan": tn = 1 @@ -147,10 +159,16 @@ def get_v(self, el): _structures_sites = [structure.sites for structure in _structures] # Iterate through each site through each timestep and find velocity - vel_matrix = [[0 for y in range(len(_structures) - 1)] for x in range(len(_structures[0].sites))] + vel_matrix = [ + [0 for y in range(len(_structures) - 1)] + for x in range(len(_structures[0].sites)) + ] for i in range(len(vel_matrix)): for j in range(len(vel_matrix[0])): - vel_matrix[i][j] = _structures_sites[j][i].distance(_structures_sites[j + 1][i]) / self.t_step + vel_matrix[i][j] = ( + _structures_sites[j][i].distance(_structures_sites[j + 1][i]) + / self.t_step + ) self.vel_matrix = vel_matrix return @@ -168,12 +186,17 @@ def get_v_vector(self, el): _structures_sites = [structure.sites for structure in _structures] # Iterate through each site through each timestep and find velocity - vel_matrix = [[[0, 0, 0] for y in range(len(_structures) - 1)] for x in range(len(_structures[0].sites))] + vel_matrix = [ + [[0, 0, 0] for y in range(len(_structures) - 1)] + for x in range(len(_structures[0].sites)) + ] for i in range(len(vel_matrix)): for j in range(len(vel_matrix[0])): dist_x = _structures_sites[j][i].x - _structures_sites[j + 1][i].x if dist_x > _structures[i].lattice.a / 2: - dist_x = (_structures[i].lattice.a - np.abs(dist_x)) * (-1 * np.sign(dist_x)) + dist_x = (_structures[i].lattice.a - np.abs(dist_x)) * ( + -1 * np.sign(dist_x) + ) dist_y = _structures_sites[j][i].y - _structures_sites[j + 1][i].y # if dist_y > _structures[i].lattice.b/2: # dist_y = (_structures[i].lattice.b-np.abs(dist_y))*(-1*np.sign(dist_y)) @@ -212,11 +235,22 @@ def __init__(self, D_t): def LS(self): self.x = np.array([1 / float(t[0]) for t in self.D_t]) self.y = np.array([np.log(t[1]["D"]) for t in self.D_t]) - self.yerr = np.array([[-np.log((t[1]["D"] - t[1]["D_std"]) / t[1]["D"]), - np.log((t[1]["D"] + t[1]["D_std"]) / t[1]["D"]) - ] for t in self.D_t]) - self.Q, self.intercept, self.r_value, self.p_value, self.std_err = \ - stats.linregress(self.x, self.y) + self.yerr = np.array( + [ + [ + -np.log((t[1]["D"] - t[1]["D_std"]) / t[1]["D"]), + np.log((t[1]["D"] + t[1]["D_std"]) / t[1]["D"]), + ] + for t in self.D_t + ] + ) + ( + self.Q, + self.intercept, + self.r_value, + self.p_value, + self.std_err, + ) = stats.linregress(self.x, self.y) self.Q *= -1 return self.Q @@ -238,32 +272,56 @@ def fit_func(p, t): self.intercept_std = self.output.sd_beta[1] return self.Q, self.Q_std - def plot(self, title=None, annotate=True, el='', **kwargs): + def plot(self, title=None, annotate=True, el="", **kwargs): # fig = plt.figure() line = np.polyval([-self.Q, self.intercept], self.x) tx = str(int(np.rint(self.Q))) if self.Q_std: tx += "$\pm${}".format(str(int(np.rint(self.Q_std)))) - c = kwargs.get('color', '') - plt.plot(self.x * 1000, line, c + '-', ) - plt.errorbar(self.x * 1000, self.y, yerr=self.yerr.T, label="Q[{}]: ".format(el) + tx + " K", **kwargs) + c = kwargs.get("color", "") + plt.plot( + self.x * 1000, + line, + c + "-", + ) + plt.errorbar( + self.x * 1000, + self.y, + yerr=self.yerr.T, + label="Q[{}]: ".format(el) + tx + " K", + **kwargs, + ) plt.ylabel("ln(D cm$^2$/s)", fontsize=15) plt.xlabel("1000/T K$^{-1}$", fontsize=15) if annotate: - plt.annotate("Q: " + tx + " K", xy=(0.98, 0.95), xycoords='axes fraction', fontsize=14, - horizontalalignment='right', verticalalignment='top') + plt.annotate( + "Q: " + tx + " K", + xy=(0.98, 0.95), + xycoords="axes fraction", + fontsize=14, + horizontalalignment="right", + verticalalignment="top", + ) if title: plt.title = title # return fig @classmethod - def from_run_paths(cls, p, T, el, corr_t, block_l, t_step=2.0, l_lim=50, skip_first=0): + def from_run_paths( + cls, p, T, el, corr_t, block_l, t_step=2.0, l_lim=50, skip_first=0 + ): D_t = [] for t in range(len(p)): xdatcar = Xdatcar(p[t]) - d = Diffusion(xdatcar.structures, corr_t=corr_t, block_l=block_l, - t_step=t_step, l_lim=l_lim, skip_first=skip_first) + d = Diffusion( + xdatcar.structures, + corr_t=corr_t, + block_l=block_l, + t_step=t_step, + l_lim=l_lim, + skip_first=skip_first, + ) D_t.append([T[t], d.getD(el)]) return cls(D_t) diff --git a/mpmorph/analysis/examples/Example-Get_D_and_Q.ipynb b/src/mpmorph/analysis/examples/Example-Get_D_and_Q.ipynb similarity index 100% rename from mpmorph/analysis/examples/Example-Get_D_and_Q.ipynb rename to src/mpmorph/analysis/examples/Example-Get_D_and_Q.ipynb diff --git a/mpmorph/analysis/examples/liquid_Na/Na_df_671.0/run0/XDATCAR.gz b/src/mpmorph/analysis/examples/liquid_Na/Na_df_671.0/run0/XDATCAR.gz similarity index 100% rename from mpmorph/analysis/examples/liquid_Na/Na_df_671.0/run0/XDATCAR.gz rename to src/mpmorph/analysis/examples/liquid_Na/Na_df_671.0/run0/XDATCAR.gz diff --git a/mpmorph/analysis/examples/liquid_Na/Na_df_763.5/run0/XDATCAR.gz b/src/mpmorph/analysis/examples/liquid_Na/Na_df_763.5/run0/XDATCAR.gz similarity index 100% rename from mpmorph/analysis/examples/liquid_Na/Na_df_763.5/run0/XDATCAR.gz rename to src/mpmorph/analysis/examples/liquid_Na/Na_df_763.5/run0/XDATCAR.gz diff --git a/mpmorph/analysis/examples/liquid_Na/Na_df_856.0/run0/XDATCAR.gz b/src/mpmorph/analysis/examples/liquid_Na/Na_df_856.0/run0/XDATCAR.gz similarity index 100% rename from mpmorph/analysis/examples/liquid_Na/Na_df_856.0/run0/XDATCAR.gz rename to src/mpmorph/analysis/examples/liquid_Na/Na_df_856.0/run0/XDATCAR.gz diff --git a/mpmorph/analysis/md_data.py b/src/mpmorph/analysis/md_data.py similarity index 78% rename from mpmorph/analysis/md_data.py rename to src/mpmorph/analysis/md_data.py index 41e236f5..8e43a948 100644 --- a/mpmorph/analysis/md_data.py +++ b/src/mpmorph/analysis/md_data.py @@ -5,11 +5,9 @@ import numpy as np -__author__ = 'Muratahan Aykol ' - def get_MD_data(outcar_path, search_keys=None, search_data_column=None): - ''' + """ Extracts the pressure, kinetic energy and total energy data from VASP MD OUTCAR. @@ -22,7 +20,7 @@ def get_MD_data(outcar_path, search_keys=None, search_data_column=None): Returns: - A nested list of MD steps where each search key value is listed. - ''' + """ # Initial map of keywords to serach for and data to map out from that line in OUTCAR # search_keys = ['external', 'kinetic energy EKIN', 'ETOTAL'] # index of stripped column of data in that line, starts from 0 @@ -30,11 +28,11 @@ def get_MD_data(outcar_path, search_keys=None, search_data_column=None): if search_data_column is None: search_data_column = [3, 4, 4, 4] if search_keys is None: - search_keys = ['external', 'kinetic energy EKIN', '% ion-electron', 'ETOTAL'] + search_keys = ["external", "kinetic energy EKIN", "% ion-electron", "ETOTAL"] if "OUTCAR.gz" in outcar_path: - with gzip.open(outcar_path, 'rb') as f_in: - with open(outcar_path[:-3], 'wb') as f_out: + with gzip.open(outcar_path, "rb") as f_in: + with open(outcar_path[:-3], "wb") as f_out: shutil.copyfileobj(f_in, f_out) outcar_path = outcar_path[:-3] @@ -49,10 +47,14 @@ def get_MD_data(outcar_path, search_keys=None, search_data_column=None): if re.search(search_keys[key_index], line): if key_index == 0: data_list.append([[]] * len(search_keys)) - data_list[md_step][0] = float(line.split()[search_data_column[key_index]]) + data_list[md_step][0] = float( + line.split()[search_data_column[key_index]] + ) else: try: - data_list[md_step][key_index] = float(line.split()[search_data_column[key_index]]) + data_list[md_step][key_index] = float( + line.split()[search_data_column[key_index]] + ) except IndexError: break if key_index == len(search_keys) - 1: @@ -73,8 +75,8 @@ def autocorrelation(data_list, search_keys=None, skip_first=0): """ if search_keys is None: - search_keys = ['external', 'kinetic energy EKIN', '% ion-electron', 'ETOTAL'] - pressures = [x[search_keys.index('external')] for x in data_list][skip_first:] + search_keys = ["external", "kinetic energy EKIN", "% ion-electron", "ETOTAL"] + pressures = [x[search_keys.index("external")] for x in data_list][skip_first:] pres_fluc = pressures - np.mean(pressures) correlation = np.zeros(len(pressures) - 1) for i in range(0, len(pressures) - 1): @@ -94,7 +96,7 @@ def get_correlation_time(data_list, skip_first=0): for i in range(len(autocorr)): if autocorr[i] <= 0: return i - raise ReferenceError('Simulation too short') + raise ReferenceError("Simulation too short") def get_MD_stats(data_list): @@ -111,15 +113,19 @@ def get_MD_stats(data_list): def parse_pressure(path, averaging_fraction=0.5): - os.system("grep external " + path + "/OUTCAR | awk '{print $4}' > " + path + "/pres") - os.system("grep volume/ion " + path + "/OUTCAR | awk '{print $5}' > " + path + "/vol") + os.system( + "grep external " + path + "/OUTCAR | awk '{print $4}' > " + path + "/pres" + ) + os.system( + "grep volume/ion " + path + "/OUTCAR | awk '{print $5}' > " + path + "/vol" + ) if os.path.isfile(path + "/OUTCAR"): with open(path + "/pres") as f: p = [float(line.rstrip()) for line in f] with open(path + "/vol") as f: vol = [float(line.rstrip()) for line in f][0] pressure = np.array(p) - avg_pres = np.mean(pressure[int(averaging_fraction * (len(pressure) - 1)):]) + avg_pres = np.mean(pressure[int(averaging_fraction * (len(pressure) - 1)) :]) else: raise ValueError("No OUTCAR found.") return avg_pres, vol, pressure diff --git a/src/mpmorph/analysis/melting_points.py b/src/mpmorph/analysis/melting_points.py new file mode 100644 index 00000000..30cfcbec --- /dev/null +++ b/src/mpmorph/analysis/melting_points.py @@ -0,0 +1,334 @@ +import numpy as np +from scipy.stats import linregress +import matplotlib.pyplot as plt +from sklearn.cluster import AgglomerativeClustering +from sklearn.metrics import mean_squared_error +import numpy as np +import matplotlib.pyplot as plt +import math +from abc import ABC, abstractmethod +import numpy.polynomial.polynomial as poly +from numpy.polynomial import Polynomial as P + +class AbstractMeltingPointEstimator(ABC): + + def plot(self, ts, vs, plot_title=None): + fig, axs = self._plot_ts_vs(ts, vs) + Tm = self.estimate(ts, vs) + axs.plot([Tm, Tm], [min(vs), max(vs)], color="r") + + if plot_title is None: + axs.set_title("Volume vs Temperature by Polynomial Fit") + else: + axs.set_title(plot_title) + + return fig, axs + + @abstractmethod + def estimate(self, temps, vols): + pass + + def _plot_ts_vs(self, ts, vs): + fig, axs = plt.subplots() + axs.scatter(ts, vs) + axs.set_xlabel("Temperature (K)") + axs.set_ylabel("Volume (A^3)") + return fig, axs + +class MeltingPointEnsembleEstimator(AbstractMeltingPointEstimator): + + def __init__(self, estimators): + self._estimators = estimators + + def estimate(self, temps, vols): + tm_estimates = [e.estimate(temps, vols) for e in self._estimators] + return np.mean(tm_estimates) + + def plot(self, ts, vs, plot_title = None): + _, axs = self._plot_ts_vs(ts, vs) + tm = self.estimate(ts, vs) + axs.plot([tm, tm], [np.min(vs), np.max(vs)]) + + def plot_all_estimates(self, ts, vs): + _, axs = self._plot_ts_vs(ts, vs) + for e in self._estimators: + tm = e.estimate(ts, vs) + axs.plot([tm, tm], [np.min(vs), np.max(vs)], label=e.name) + + avg = self.estimate(ts, vs) + axs.plot([avg , avg ], [np.min(vs), np.max(vs)], label="Mean Estimate") + axs.set_title("Ensemble Tm Estimates") + axs.legend() + + + +class MeltingPointClusterEstimator(AbstractMeltingPointEstimator): + + name: str = "Clustering" + + def plot_clusters(self, ts, vs, plot_title=None): + points = np.array(list(zip(ts, vs))) + cluster1, cluster2 = self._get_clusters(points) + fig, axs = plt.subplots() + axs.scatter(*cluster1) + axs.scatter(*cluster2) + axs.set_xlabel("Temperature (K)") + axs.set_ylabel("Volume (A^3)") + Tm = self.estimate(ts, vs) + axs.plot([Tm, Tm], [min(vs), max(vs)], color="r") + + if plot_title is None: + axs.title("Volume vs Temperature by Clustering") + else: + axs.title(plot_title) + + return fig, axs + + def estimate(self, temps, vols): + points = np.array(list(zip(temps, vols))) + cluster1, cluster2 = self._get_clusters(points) + if min(cluster1[0]) < min(cluster2[0]): + solid_range = cluster1[0] + liquid_range = cluster2[0] + else: + solid_range = cluster2[0] + liquid_range = cluster1[0] + + return np.mean([max(solid_range), min(liquid_range)]) + + def _get_clusters(self, points): + clustering = AgglomerativeClustering(n_clusters=2).fit(points) + cluster1 = points[np.argwhere(clustering.labels_ == 1).squeeze()].T + cluster2 = points[np.argwhere(clustering.labels_ == 0).squeeze()].T + return cluster1, cluster2 + +class MeltingPointTrisectionEstimator(AbstractMeltingPointEstimator): + + def _get_fit_error_total(self, xs, ys): + slope, intercept, r_value, p_value, std_err = linregress(xs, ys) + y_pred = intercept + slope * np.array(xs) + err = np.sum(np.abs(y_pred - ys)) + return slope, intercept, err + + def _unzip_pts(self, pts): + ts = [pt[0] for pt in pts] + vs = [pt[1] for pt in pts] + return ts, vs + + def _plot_pts(self, pts): + ts, vs = self._unzip_pts(pts) + plt.scatter(ts,vs, color='grey') + + + def _split_pts(self, pts, x1, x2): + set1 = [pt for pt in pts if pt[0] < x1] + set2 = [pt for pt in pts if pt[0] > x1 and pt[0] < x2] + set3 = [pt for pt in pts if pt[0] > x2] + return set1, set2, set3 + + def _plot_split(self, pts, x1, x2): + set1, set2, set3 = self._plit_pts(pts, x1, x2) + self._plot_pts(set1) + self._plot_pts(set2) + self._plot_pts(set3) + + def _get_linear_ys(self, m, b, xs): + return [m * x + b for x in xs] + + def _plot_fit_line(self, m, b, xs): + fit_ys = self._get_linear_ys(m, b, xs) + plt.plot(xs, fit_ys) + + def _plot_fits(self, pts, x1, x2): + set1, set2, set3 = self._split_pts(pts, x1, x2) + for dset in [set1, set2, set3]: + xs, ys = self._unzip_pts(dset) + m, b, err = self._get_fit_error_total(xs, ys) + self._plot_pts(dset) + self._plot_fit_line(m, b, xs) + + + def _find_best_trisection(self, points, min_x = None, max_x = None, min_window_size = 100, step_size = 50): + lowest_err = math.inf + + xs, ys = self._unzip_pts(points) + if min_x is None: + min_x = math.floor(np.min(xs)) + + if max_x is None: + max_x = math.ceil(np.max(xs)) + + for pt1 in range(min_x + min_window_size, max_x - 2 * min_window_size, step_size): + for pt2 in range(pt1 + min_window_size, max_x - min_window_size, step_size): + set1, set2, set3 = self._split_pts(points, pt1, pt2) + errs_total = 0 + errs = [] + try: + for dset in [set1, set2, set3]: + xs, ys = self._unzip_pts(dset) + m, b, err = self._get_fit_error_total(xs, ys) + errs.append(err) + errs_total += err ** 2 + + errs_total = math.sqrt(errs_total) + + if errs_total < lowest_err: + lowest_err = errs_total + best_pt1 = pt1 + best_pt2 = pt2 + except: + print("problem encountered") + + return best_pt1, best_pt2 + + def _estimate_melting_pt(self, points): + pt1, pt2 = self._find_best_trisection(points, step_size=100) + # print(f'Coarse guess: {pt1, pt2}') + pt1, pt2 = self._find_best_trisection(points, pt1 - 150, pt2 + 150, step_size = 5) + # print(f'Fine guess: {pt1, pt2}') + return pt1, pt2, (pt2 - pt1) / 2 + pt1 + + def estimate(self, temps, vols): + pts = list(zip(temps, vols)) + pt1, pt2, tm = self._estimate_melting_pt(pts) + return tm + + def plot(self, ts, vs, plot_title=None): + pts = list(zip(ts, vs)) + pt1, pt2, tm = self._estimate_melting_pt(pts) + self._plot_fits(pts, pt1, pt2) + + +class MeltingPointBisectionEstimator(AbstractMeltingPointEstimator): + + def plot_best_split(self, temps, vols): + split_idx = self.get_best_split(temps, vols) + fig, axs = self._plot_split(temps, vols, split_idx) + Tm = self.estimate(temps, vols) + axs.plot([Tm, Tm], [min(vols), max(vols)], color="r") + return fig, axs + + def estimate(self, temps, vols): + best_split_idx = self.get_best_split(temps, vols) + return np.mean([temps[best_split_idx], temps[best_split_idx - 1]]) + + def _split_dset(self, pts, split_idx): + return pts[0:split_idx], pts[split_idx:] + + def _assess_splits(self, xs, ys): + dset_size = len(xs) + buffer = max(round(dset_size / 10), 3) + + pt_idxs = list(range(buffer + 1, len(xs) - buffer - 1)) + errs = [] + for idx in pt_idxs: + _, _, _, _, total_err = self._get_split_fit(xs, ys, idx) + errs.append(total_err) + + return list(zip(pt_idxs, errs)) + + def _get_linear_ys(self, m, b, xs): + return [m * x + b for x in xs] + + def _plot_split(self, xs, ys, split_idx): + m1, b1, m2, b2, _ = self._get_split_fit(xs, ys, split_idx) + leftxs, rightxs = self._split_dset(xs, split_idx) + leftys, rightys = self._split_dset(ys, split_idx) + + fig, axs = plt.subplots() + + left_fit_ys = self._get_linear_ys(m1, b1, leftxs) + axs.scatter(leftxs, leftys) + axs.plot(leftxs, left_fit_ys) + + + right_fit_ys = self._get_linear_ys(m2, b2, rightxs) + axs.scatter(rightxs, rightys) + axs.plot(rightxs, right_fit_ys) + + axs.title("Volume vs Temperature (w/ best fits by Slope Method") + axs.xlabel("Temperature (K)") + axs.ylabel("Equil. Volume (cubic Angstroms)") + return fig, axs + + def get_best_split(self, xs, ys): + split_errs = self._assess_splits(xs, ys) + errs = [pt[1] for pt in split_errs] + idxs = [pt[0] for pt in split_errs] + best_split_idx = idxs[np.argmin(errs)] + return best_split_idx + + +class MeltingPointSlopeRMSEEstimator(MeltingPointBisectionEstimator): + + name: str = "RMSE Bisection" + + def _get_fit_error(self, xs, ys): + slope, intercept, r_value, p_value, std_err = linregress(xs, ys) + y_pred = intercept + slope * np.array(xs) + err = mean_squared_error(y_true=ys, y_pred=y_pred, squared=False) + return slope, intercept, err + + def _get_split_fit(self, xs, ys, split_idx): + leftx, rightx = self._split_dset(xs, split_idx) + lefty, righty = self._split_dset(ys, split_idx) + + lslope, lintercept, r_value, p_value, std_err = linregress(leftx, lefty) + left_y_pred = lintercept + lslope * np.array(leftx) + lefterr = mean_squared_error(y_true=lefty, y_pred=left_y_pred, squared=False) + + rslope, rintercept, r_value, p_value, std_err = linregress(rightx, righty) + right_y_pred = rintercept + rslope * np.array(rightx) + righterr = mean_squared_error(y_true=righty, y_pred=right_y_pred, squared=False) + + combined_err = math.sqrt(lefterr**2 + righterr**2) + combined_err = lefterr + righterr + return lslope, lintercept, rslope, rintercept, combined_err + + +class MeltingPointSlopeStdErrEstimator(MeltingPointBisectionEstimator): + + name: str = "StdErr Bisection" + + def _get_split_fit(self, xs, ys, split_idx): + leftx, rightx = self._split_dset(xs, split_idx) + lefty, righty = self._split_dset(ys, split_idx) + + leftfit = linregress(leftx, lefty) + lefterr = leftfit.stderr + + rightfit = linregress(rightx, righty) + righterr = rightfit.stderr + + combined_err = math.sqrt(lefterr**2 + righterr**2) + combined_err = lefterr + righterr + return ( + leftfit.slope, + leftfit.intercept, + rightfit.slope, + rightfit.intercept, + combined_err, + ) + +class MeltingPointPolyFitEstimator(AbstractMeltingPointEstimator): + + name: str = "Polynomial Fit" + + def plot_fit(self, ts, vs, plot_title=None): + fig, axs = super().plot(ts, vs, plot_title=plot_title) + p = self._polyfit(ts, vs) + fit_ts = p(vs) + axs.plot(fit_ts, vs, color='r') + return fig, axs + + def estimate(self, temps, vols): + p = self._polyfit(temps, vols) + second = p.deriv(2) + melting_v = second.roots()[0] + melting_t = p(melting_v) + return melting_t + + def _polyfit(self, temps, vols): + coefs = poly.polyfit(vols, temps, 3) + p = P(coefs) + return p \ No newline at end of file diff --git a/mpmorph/analysis/structural_analysis.py b/src/mpmorph/analysis/structural_analysis.py similarity index 83% rename from mpmorph/analysis/structural_analysis.py rename to src/mpmorph/analysis/structural_analysis.py index 12f524d7..ef2884cf 100644 --- a/mpmorph/analysis/structural_analysis.py +++ b/src/mpmorph/analysis/structural_analysis.py @@ -8,8 +8,6 @@ from pymatgen.util.coord import get_angle from scipy.spatial import Voronoi -__author__ = 'Muratahan Aykol ' - def polyhedra_connectivity(structures, pair, cutoff, step_freq=1): """ @@ -49,7 +47,6 @@ def polyhedra_connectivity(structures, pair, cutoff, step_freq=1): polyhedra_list.append(set(current_poly)) for polypair in itertools.combinations(polyhedra_list, 2): - polyhedra_pair_type = (len(polypair[0]), len(polypair[1])) shared_vertices = len(polypair[0].intersection(polypair[1])) @@ -59,15 +56,32 @@ def polyhedra_connectivity(structures, pair, cutoff, step_freq=1): if shared_vertices: if polyhedra_pair_type in connectivity_sub_categories: - if shared_vertices in connectivity_sub_categories[polyhedra_pair_type]: - connectivity_sub_categories[polyhedra_pair_type][shared_vertices] += 1 + if ( + shared_vertices + in connectivity_sub_categories[polyhedra_pair_type] + ): + connectivity_sub_categories[polyhedra_pair_type][ + shared_vertices + ] += 1 elif polyhedra_pair_type[::-1] in connectivity_sub_categories: - if shared_vertices in connectivity_sub_categories[polyhedra_pair_type[::-1]]: - connectivity_sub_categories[polyhedra_pair_type[::-1]][shared_vertices] += 1 + if ( + shared_vertices + in connectivity_sub_categories[polyhedra_pair_type[::-1]] + ): + connectivity_sub_categories[polyhedra_pair_type[::-1]][ + shared_vertices + ] += 1 else: - connectivity_sub_categories[polyhedra_pair_type] = deepcopy(connectivity_template) - if shared_vertices in connectivity_sub_categories[polyhedra_pair_type]: - connectivity_sub_categories[polyhedra_pair_type][shared_vertices] = 1 + connectivity_sub_categories[polyhedra_pair_type] = deepcopy( + connectivity_template + ) + if ( + shared_vertices + in connectivity_sub_categories[polyhedra_pair_type] + ): + connectivity_sub_categories[polyhedra_pair_type][ + shared_vertices + ] = 1 return connectivity, connectivity_sub_categories @@ -128,19 +142,20 @@ class BondAngleDistribution(object): """ def __init__(self, structures, cutoffs, step_freq=1): - self.bond_angle_distribution = None self.structures = structures self.step_freq = step_freq self.unique_triplets = self.get_unique_triplets(structures[0]) if isinstance(cutoffs, dict): self.cutoffs = cutoffs - self._cutoff_type = 'dict' + self._cutoff_type = "dict" elif isinstance(cutoffs, float): self.cutoffs = cutoffs - self._cutoff_type = 'constant' + self._cutoff_type = "constant" else: - raise ValueError("Cutoffs must be specified as dict of pairs or globally as a single flaot.") + raise ValueError( + "Cutoffs must be specified as dict of pairs or globally as a single flaot." + ) @property def n_frames(self): @@ -160,7 +175,9 @@ def get_angle(self, s_index, i, j, k): (float) Angle in degrees. """ structure = self.structures[s_index] - lat_vec = np.array([structure.lattice.a, structure.lattice.b, structure.lattice.c]) + lat_vec = np.array( + [structure.lattice.a, structure.lattice.b, structure.lattice.c] + ) v1 = structure[i].coords - structure[j].coords v2 = structure[k].coords - structure[j].coords @@ -176,6 +193,7 @@ def get_angle(self, s_index, i, j, k): def get_unique_triplets(s): central_atoms = s.symbol_set import itertools + possible_end_members = [] for i in itertools.combinations_with_replacement(central_atoms, 2): possible_end_members.append(i) @@ -220,7 +238,7 @@ def get_bond_angle_distribution(self): s = self.structures[s_index] # Narrow down the search space around a given atom for neighbors - if self._cutoff_type == 'dict': + if self._cutoff_type == "dict": neighbor_search_cutoff = max(self.cutoffs.values()) else: neighbor_search_cutoff = self.cutoffs @@ -231,9 +249,8 @@ def get_bond_angle_distribution(self): # get all pair combinations of neoghbor sites of i: for p in itertools.combinations(neighbors[i], 2): - # check if pairs are within the defined cutoffs - if self._cutoff_type == 'dict': + if self._cutoff_type == "dict": if self._check_skip_triplet(s_index, i, p[0][2], p[1][2]): continue else: @@ -260,13 +277,14 @@ def get_bond_angle_distribution(self): def plot_bond_angle_distribution(self): import matplotlib.pyplot as plt + if not self.bond_angle_distribution: self.get_bond_angle_distribution() plt.figure() triplets = self.bond_angle_distribution.keys() legend = [] for trip in triplets: - legend.append('-'.join(trip)) + legend.append("-".join(trip)) for triplet in triplets: plt.plot(range(180 + 1), self.bond_angle_distribution[triplet]) @@ -277,6 +295,7 @@ def plot_bond_angle_distribution(self): def get_binary_angle_dist_plot(self, title=None): import matplotlib.pyplot as plt + fig = plt.figure(figsize=(12, 6)) c = 0 maxes = [] @@ -286,17 +305,19 @@ def get_binary_angle_dist_plot(self, title=None): ax = fig.add_subplot(2, 3, c) ax.plot(range(len(p)), p) maxes.append(max(p)) - ax.annotate('-'.join(triplet), (0.75, 0.88), xycoords='axes fraction', size=16) + ax.annotate( + "-".join(triplet), (0.75, 0.88), xycoords="axes fraction", size=16 + ) ax.set_yticklabels([]) ax.xaxis.set_ticks(np.arange(0, 181, 30)) plt.gca().set_ylim([0, 0.1]) if c in [1, 2, 3]: ax.set_xticklabels([]) else: - plt.xlabel('Angle (degrees)', fontsize=16) + plt.xlabel("Angle (degrees)", fontsize=16) if c in [1, 4]: - plt.ylabel('Intensity (a.u.)', fontsize=16) - ax.tick_params(axis='both', which='major', labelsize=16) + plt.ylabel("Intensity (a.u.)", fontsize=16) + ax.tick_params(axis="both", which="major", labelsize=16) if c == 2: if title: plt.title(title) @@ -308,7 +329,7 @@ def get_binary_angle_dist_plot(self, title=None): def compute_mean_coord(structures, freq=100): - ''' + """ NOTE: This function will be removed as it has been migrated to pymatgen. Calculate average coordination numbers @@ -318,7 +339,7 @@ def compute_mean_coord(structures, freq=100): - freq: sampling frequency of coord number [every freq steps] returns: - a dictionary of elements and corresponding mean coord numbers - ''' + """ cn_dict = {} for el in structures[0].composition.elements: cn_dict[el.name] = 0.0 @@ -385,7 +406,9 @@ def voronoi_analysis(structure, n=0, cutoff=5.0, qhull_options="Qbb Qc Qz"): pass return vor_index - def from_structures(self, structures, cutoff=4.0, step_freq=10, qhull_options="Qbb Qc Qz"): + def from_structures( + self, structures, cutoff=4.0, step_freq=10, qhull_options="Qbb Qc Qz" + ): """ A constructor to perform Voronoi analysis on a list of pymatgen structrue objects @@ -407,30 +430,38 @@ def from_structures(self, structures, cutoff=4.0, step_freq=10, qhull_options="Q v = [] for n in range(len(structure)): - v.append(str(self.voronoi_analysis(structure, n=n, cutoff=cutoff, - qhull_options=qhull_options).view())) + v.append( + str( + self.voronoi_analysis( + structure, n=n, cutoff=cutoff, qhull_options=qhull_options + ).view() + ) + ) for voro in v: if voro in voro_dict: voro_dict[voro] += 1 else: voro_dict[voro] = 1 - self.vor_ensemble = sorted(voro_dict.items(), key=lambda x: (x[1], x[0]), reverse=True)[:15] + self.vor_ensemble = sorted( + voro_dict.items(), key=lambda x: (x[1], x[0]), reverse=True + )[:15] return self.vor_ensemble @property def plot_vor_analysis(self): import matplotlib.pyplot as plt + t = zip(*self.vor_ensemble) labels = t[0] val = list(t[1]) tot = np.sum(val) val = [float(j) / tot for j in val] - pos = np.arange(len(val)) + .5 # the bar centers on the y axis + pos = np.arange(len(val)) + 0.5 # the bar centers on the y axis plt.figure(figsize=(4, 4)) - plt.barh(pos, val, align='center', alpha=0.5) + plt.barh(pos, val, align="center", alpha=0.5) plt.yticks(pos, labels) - plt.xlabel('Fraction') - plt.title('Voronoi Spectra') + plt.xlabel("Fraction") + plt.title("Voronoi Spectra") plt.grid(True) return plt @@ -456,8 +487,15 @@ class RadialDistributionFunction(object): RDFs themselves are arrays of length cutoff/bin_size. """ - def __init__(self, structures, cutoff=5.0, bin_size=0.1, step_freq=2, smooth=1, - title="Radial distribution functions"): + def __init__( + self, + structures, + cutoff=5.0, + bin_size=0.1, + step_freq=2, + smooth=1, + title="Radial distribution functions", + ): self.structures = structures self.cutoff = cutoff self.bin_size = bin_size @@ -490,8 +528,16 @@ def get_radial_distribution_functions(self, nproc=1): Each RDF arrays of length cutoff/bin_size. """ - frames = [(self.structures[i * self.step_freq], self.pairs, self.n_bins, self.cutoff, self.bin_size) for i in - range(int(self.n_frames / self.step_freq))] + frames = [ + ( + self.structures[i * self.step_freq], + self.pairs, + self.n_bins, + self.cutoff, + self.bin_size, + ) + for i in range(int(self.n_frames / self.step_freq)) + ] self.counter = len(frames) pool = Pool(nproc) results = pool.map(_process_frame, frames) @@ -508,14 +554,23 @@ def get_radial_distribution_functions(self, nproc=1): self.get_pair_order = [] for i in self.RDFs.keys(): - self.get_pair_order.append('-'.join(list(i))) + self.get_pair_order.append("-".join(list(i))) density_of_atom2 = self.n_species[i[1]] / self.structures[0].volume for j in range(self.n_bins): r = j * self.bin_size if r == 0: continue - self.RDFs[i][j] = self.RDFs[i][j] / self.n_species[ - i[0]] / 4.0 / np.pi / r / r / self.bin_size / density_of_atom2 / self.counter + self.RDFs[i][j] = ( + self.RDFs[i][j] + / self.n_species[i[0]] + / 4.0 + / np.pi + / r + / r + / self.bin_size + / density_of_atom2 + / self.counter + ) if self.smooth: self.RDFs = get_smooth_rdfs(self.RDFs, passes=self.smooth) @@ -526,6 +581,7 @@ def plot_radial_distribution_functions(self): :return: a plot of RDFs """ import matplotlib.pyplot as plt + x = [] for j in range(self.n_bins): r = j * self.bin_size @@ -537,8 +593,13 @@ def plot_radial_distribution_functions(self): plt.xlabel("$r$, distance (Angstrom)") plt.ylabel("g($r$)") - plt.legend(self.get_pair_order, bbox_to_anchor=(0.975, 0.975), loc=0, - borderaxespad=0., prop={'family': 'sans-serif', 'size': 13}) + plt.legend( + self.get_pair_order, + bbox_to_anchor=(0.975, 0.975), + loc=0, + borderaxespad=0.0, + prop={"family": "sans-serif", "size": 13}, + ) plt.title(self.title) return plt @@ -547,8 +608,13 @@ def _process_frame(data): """ Helper function for parallel rdf computation """ - coord_frame, pairs, n_bins, cutoff, bin_size = \ - data[0], data[1], data[2], data[3], data[4] + coord_frame, pairs, n_bins, cutoff, bin_size = ( + data[0], + data[1], + data[2], + data[3], + data[4], + ) process_RDFs = {} n_atoms = len(coord_frame) @@ -591,8 +657,13 @@ def get_smooth_rdfs(RDFs, passes=1): for rdf in RDFs: smooth_RDF = deepcopy(RDFs[rdf]) for j in range(2, len(RDFs[rdf]) - 2): - smooth_RDF[j] = (-3 * RDFs[rdf][j - 2] + 12 * RDFs[rdf][j - 1] - + 17 * RDFs[rdf][j] + 12 * RDFs[rdf][j + 1] - 3 * RDFs[rdf][j + 2]) / 35.0 + smooth_RDF[j] = ( + -3 * RDFs[rdf][j - 2] + + 12 * RDFs[rdf][j - 1] + + 17 * RDFs[rdf][j] + + 12 * RDFs[rdf][j + 1] + - 3 * RDFs[rdf][j + 2] + ) / 35.0 RDFs[rdf] = smooth_RDF passes -= 1 return get_smooth_rdfs(RDFs, passes=passes) @@ -614,8 +685,13 @@ def get_smooth_rdfs(RDFs, passes=1): for rdf in RDFs: smooth_RDF = deepcopy(RDFs[rdf]) for j in range(2, len(RDFs[rdf]) - 2): - smooth_RDF[j] = (-3 * RDFs[rdf][j - 2] + 12 * RDFs[rdf][j - 1] - + 17 * RDFs[rdf][j] + 12 * RDFs[rdf][j + 1] - 3 * RDFs[rdf][j + 2]) / 35.0 + smooth_RDF[j] = ( + -3 * RDFs[rdf][j - 2] + + 12 * RDFs[rdf][j - 1] + + 17 * RDFs[rdf][j] + + 12 * RDFs[rdf][j + 1] + - 3 * RDFs[rdf][j + 2] + ) / 35.0 RDFs[rdf] = smooth_RDF passes -= 1 return get_smooth_rdfs(RDFs, passes=passes) diff --git a/mpmorph/database.py b/src/mpmorph/database.py similarity index 57% rename from mpmorph/database.py rename to src/mpmorph/database.py index 33cb2586..272fff15 100644 --- a/mpmorph/database.py +++ b/src/mpmorph/database.py @@ -1,21 +1,19 @@ -from __future__ import division, print_function, unicode_literals, absolute_import +from __future__ import absolute_import, division, print_function, unicode_literals import json import zlib +from collections import defaultdict import gridfs +import numpy as np from atomate.utils.utils import get_logger from atomate.vasp.database import VaspCalcDb from monty.json import MontyEncoder from pymatgen.core import Structure from pymatgen.core.trajectory import Trajectory -from collections import defaultdict -import numpy as np logger = get_logger(__name__) -__author__ = 'Eric Sivonxay ' - class VaspMDCalcDb(VaspCalcDb): """ @@ -24,11 +22,23 @@ class VaspMDCalcDb(VaspCalcDb): Class to help manage database insertions of Vasp drones """ - def __init__(self, host="localhost", port=27017, database="vasp", collection="tasks", user=None, - password=None, **kwargs): - super(VaspMDCalcDb, self).__init__(host, port, database, collection, user, password, **kwargs) - - def insert_task(self, task_doc, parse_dos=False, parse_bs=False, parse_ionic_steps=False): + def __init__( + self, + host="localhost", + port=27017, + database="vasp", + collection="tasks", + user=None, + password=None, + **kwargs, + ): + super(VaspMDCalcDb, self).__init__( + host, port, database, collection, user, password, **kwargs + ) + + def insert_task( + self, task_doc, parse_dos=False, parse_bs=False, parse_ionic_steps=False + ): """ Inserts a task document (e.g., as returned by Drone.assimilate()) into the database. Handles putting DOS, band structure, and ionic_steps into GridFS as needed. @@ -53,39 +63,45 @@ def insert_task(self, task_doc, parse_dos=False, parse_bs=False, parse_ionic_ste # insert band structure into GridFS if parse_bs and "calcs_reversed" in task_doc: if "bandstructure" in task_doc["calcs_reversed"][0]: # only store idx=0 BS - bs = json.dumps(task_doc["calcs_reversed"][0]["bandstructure"], cls=MontyEncoder) + bs = json.dumps( + task_doc["calcs_reversed"][0]["bandstructure"], cls=MontyEncoder + ) gfs_id, compression_type = self.insert_gridfs(bs, "bandstructure_fs") - task_doc["calcs_reversed"][0]["bandstructure_compression"] = compression_type + task_doc["calcs_reversed"][0][ + "bandstructure_compression" + ] = compression_type task_doc["calcs_reversed"][0]["bandstructure_fs_id"] = gfs_id del task_doc["calcs_reversed"][0]["bandstructure"] # insert structures at each ionic step into GridFS if parse_ionic_steps and "calcs_reversed" in task_doc: - # Convert from ionic steps dictionary to pymatgen.core.trajectory.Trajectory object - ionic_steps_dict = task_doc["calcs_reversed"][0]['output']['ionic_steps'] - time_step = task_doc['input']['incar']['POTIM'] - trajectory = convert_ionic_steps_to_trajectory((ionic_steps_dict), time_step) - del task_doc["calcs_reversed"][0]['output']['ionic_steps'] + ionic_steps_dict = task_doc["calcs_reversed"][0]["output"]["ionic_steps"] + time_step = task_doc["input"]["incar"]["POTIM"] + trajectory = convert_ionic_steps_to_trajectory( + (ionic_steps_dict), time_step + ) + del task_doc["calcs_reversed"][0]["output"]["ionic_steps"] traj_dict = json.dumps(trajectory, cls=MontyEncoder) gfs_id, compression_type = self.insert_gridfs(traj_dict, "trajectories_fs") - task_doc['trajectory'] = { - 'formula_pretty': trajectory[0].composition.reduced_formula, - 'formula': trajectory[0].composition.formula.replace(' ', ''), - 'temperature': int(task_doc["input"]["incar"]["TEBEG"]), - 'compression': compression_type, - 'fs_id': gfs_id, - 'fs': 'trajectories_fs', - 'dimension': list(np.shape(trajectory.frac_coords)), - 'time_step': task_doc["input"]["incar"]["POTIM"], - 'frame_properties': list(trajectory.frame_properties.keys()) + task_doc["trajectory"] = { + "formula_pretty": trajectory[0].composition.reduced_formula, + "formula": trajectory[0].composition.formula.replace(" ", ""), + "temperature": int(task_doc["input"]["incar"]["TEBEG"]), + "compression": compression_type, + "fs_id": gfs_id, + "fs": "trajectories_fs", + "dimension": list(np.shape(trajectory.frac_coords)), + "time_step": task_doc["input"]["incar"]["POTIM"], + "frame_properties": list(trajectory.frame_properties.keys()), } # insert the task document and return task_id return self.insert(task_doc) + def convert_ionic_steps_to_trajectory(ionic_steps_dict, time_step): ## Convert from a list of dictionaries to a dictionary of lists ionic_steps_defaultdict = defaultdict(list) @@ -97,32 +113,42 @@ def convert_ionic_steps_to_trajectory(ionic_steps_dict, time_step): frac_coords = [] site_properties = [] read_site_props = False - if 'properties' in ionic_steps_dict[0]['structure']['sites'][0].keys(): + if "properties" in ionic_steps_dict[0]["structure"]["sites"][0].keys(): read_site_props = True for ionic_step in ionic_steps_dict: - _frac_coords = [site['abc'] for site in ionic_step['structure']['sites']] + _frac_coords = [site["abc"] for site in ionic_step["structure"]["sites"]] frac_coords.append(_frac_coords) if read_site_props: _site_properties = {} - for key in ionic_step['structure']['sites'][0]['properties']: - _prop = [site['properties'][key] for site in ionic_step['structure']['sites']] + for key in ionic_step["structure"]["sites"][0]["properties"]: + _prop = [ + site["properties"][key] for site in ionic_step["structure"]["sites"] + ] _site_properties[key] = _prop site_properties.append(_site_properties) else: site_properties.append(None) - lattice = ionic_steps_dict[0]['structure']['lattice']['matrix'] - species = [site['species'][0]['element'] for site in ionic_step['structure']['sites']] + lattice = ionic_steps_dict[0]["structure"]["lattice"]["matrix"] + species = [ + site["species"][0]["element"] for site in ionic_step["structure"]["sites"] + ] frame_properties = {} - keys = set(ionic_steps_dict[0].keys()) - set(['structure']) + keys = set(ionic_steps_dict[0].keys()) - set(["structure"]) for key in keys: - if key in ['forces', 'stress']: + if key in ["forces", "stress"]: frame_properties[key] = np.array(ionic_steps[key]) else: frame_properties[key] = ionic_steps[key] - return Trajectory(lattice, species, frac_coords, site_properties=site_properties, - constant_lattice=True, frame_properties=frame_properties, - time_step=time_step) + return Trajectory( + lattice, + species, + frac_coords, + site_properties=site_properties, + constant_lattice=True, + frame_properties=frame_properties, + time_step=time_step, + ) diff --git a/src/mpmorph/firetasks/__init__.py b/src/mpmorph/firetasks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mpmorph/firetasks/dbtasks.py b/src/mpmorph/firetasks/dbtasks.py similarity index 69% rename from mpmorph/firetasks/dbtasks.py rename to src/mpmorph/firetasks/dbtasks.py index 7d0fe132..0f660f99 100644 --- a/mpmorph/firetasks/dbtasks.py +++ b/src/mpmorph/firetasks/dbtasks.py @@ -2,6 +2,8 @@ import os import re import zlib +from collections import defaultdict +from typing import List import gridfs import numpy as np @@ -9,16 +11,13 @@ from atomate.utils.utils import env_chk, get_logger from atomate.vasp.drones import VaspDrone from bson import ObjectId -from mpmorph.database import VaspMDCalcDb -from fireworks import explicit_serialize, FiretaskBase, FWAction +from fireworks import FiretaskBase, FWAction, explicit_serialize from fireworks.utilities.fw_serializers import DATETIME_HANDLER from monty.json import MontyEncoder from pymatgen.core import Structure from pymatgen.core.trajectory import Trajectory -from collections import defaultdict -from mpmorph.database import convert_ionic_steps_to_trajectory -__author__ = 'Eric Sivonxay and Jianli Cheng' +from mpmorph.database import VaspMDCalcDb, convert_ionic_steps_to_trajectory logger = get_logger(__name__) @@ -47,9 +46,18 @@ class VaspMDToDb(FiretaskBase): is not "successful"; i.e. both electronic and ionic convergence are reached. Defaults to True. """ - optional_params = ["calc_dir", "calc_loc", "parse_dos", "bandstructure_mode", - "additional_fields", "db_file", "fw_spec_field", - "md_structures", "defuse_unsuccessful"] + + optional_params: List[str] = [ + "calc_dir", + "calc_loc", + "parse_dos", + "bandstructure_mode", + "additional_fields", + "db_file", + "fw_spec_field", + "md_structures", + "defuse_unsuccessful", + ] def run_task(self, fw_spec): # get the directory that contains the VASP dir to parse @@ -62,11 +70,13 @@ def run_task(self, fw_spec): # parse the VASP directory logger.info("PARSING DIRECTORY: {}".format(calc_dir)) - drone = VaspDrone(additional_fields=self.get("additional_fields"), - parse_dos=self.get("parse_dos", False), - parse_bader=False, - bandstructure_mode=self.get("bandstructure_mode", False), - store_volumetric_data=[]) + drone = VaspDrone( + additional_fields=self.get("additional_fields"), + parse_dos=self.get("parse_dos", False), + parse_bader=False, + bandstructure_mode=self.get("bandstructure_mode", False), + store_volumetric_data=[], + ) # assimilate (i.e., parse) task_doc = drone.assimilate(calc_dir) @@ -76,7 +86,7 @@ def run_task(self, fw_spec): task_doc.update(fw_spec[self.get("fw_spec_field")]) # get the database connection - db_file = env_chk(self.get('db_file'), fw_spec) + db_file = env_chk(self.get("db_file"), fw_spec) # db insertion or taskdoc dump if not db_file: @@ -86,23 +96,31 @@ def run_task(self, fw_spec): mmdb = VaspMDCalcDb.from_db_file(db_file, admin=True) # prevent duplicate insertion - mmdb.db.tasks.find_one_and_delete({'formula_pretty': task_doc['formula_pretty'], - 'task_label': task_doc['task_label']}) - - t_id = mmdb.insert_task(task_doc, - parse_dos=self.get("parse_dos", False), - parse_bs=bool(self.get("bandstructure_mode", False)), - parse_ionic_steps=self.get("md_structures", True)) + mmdb.db.tasks.find_one_and_delete( + { + "formula_pretty": task_doc["formula_pretty"], + "task_label": task_doc["task_label"], + } + ) + + t_id = mmdb.insert_task( + task_doc, + parse_dos=self.get("parse_dos", False), + parse_bs=bool(self.get("bandstructure_mode", False)), + parse_ionic_steps=self.get("md_structures", True), + ) logger.info("Finished parsing with task_id: {}".format(t_id)) if self.get("defuse_unsuccessful", True): - defuse_children = (task_doc["state"] != "successful") + defuse_children = task_doc["state"] != "successful" else: defuse_children = False - return FWAction(stored_data={"task_id": task_doc.get("task_id", None)}, - defuse_children=defuse_children) + return FWAction( + stored_data={"task_id": task_doc.get("task_id", None)}, + defuse_children=defuse_children, + ) @explicit_serialize @@ -111,27 +129,33 @@ class TrajectoryDBTask(FiretaskBase): Obtain all production runs with the same uuid, stitch together the trajectory, and then insert them into the db. This is done by searching for the unique tag """ + required_params = ["tag_id", "db_file"] - optional_params = ['notes'] + optional_params = ["notes"] def run_task(self, fw_spec): - notes = self.get('notes', None) - tag_id = self['tag_id'] + notes = self.get("notes", None) + tag_id = self["tag_id"] # get the database connection - db_file = env_chk(self.get('db_file'), fw_spec) + db_file = env_chk(self.get("db_file"), fw_spec) mmdb = VaspMDCalcDb.from_db_file(db_file, admin=True) # mmdb.db.trajectories.find_one_and_delete({"runs_label": tag_id}) - runs = mmdb.db['tasks'].find( - {"task_label": re.compile(f'.*prod_run.*{tag_id}.*')}) + runs = mmdb.db["tasks"].find( + {"task_label": re.compile(f".*prod_run.*{tag_id}.*")} + ) - runs_sorted = sorted(runs, key=lambda x: int(re.findall('run[_-](\d+)', x['task_label'])[0])) + runs_sorted = sorted( + runs, key=lambda x: int(re.findall("run[_-](\d+)", x["task_label"])[0]) + ) # Remove duplicates of the same run (if they exist) - labels = [result['task_label'] for result in runs_sorted] - nums = [int(re.findall('run[_-](\d+)', label)[0]) for label in labels] + labels = [result["task_label"] for result in runs_sorted] + nums = [int(re.findall("run[_-](\d+)", label)[0]) for label in labels] duplicates = np.where((nums - np.roll(nums, 1)) == 0)[0] - runs_sorted = [runs_sorted[i] for i in range(len(runs_sorted)) if i not in duplicates] + runs_sorted = [ + runs_sorted[i] for i in range(len(runs_sorted)) if i not in duplicates + ] trajectory_doc = runs_to_trajectory_doc(runs_sorted, db_file, tag_id, notes) @@ -158,43 +182,49 @@ def runs_to_trajectory_doc(runs, db_file, runs_label, notes=None): gfs_id, compression_type = insert_gridfs(traj_dict, mmdb.db, "trajectories_fs") traj_doc = { - 'formula_pretty': trajectory[0].composition.reduced_formula, - 'formula': trajectory[0].composition.formula.replace(' ', ''), - 'temperature': int(runs[0]["input"]["incar"]["TEBEG"]), - 'runs_label': runs_label, - 'compression': compression_type, - 'fs_id': gfs_id, - 'fs': 'trajectories_fs', - 'step_fs_ids': [i["calcs_reversed"][0]["output"]['ionic_steps_fs_id'] - for i in runs], - 'structure': trajectory[0].as_dict(), - 'dimension': list(np.shape(trajectory.frac_coords)), - 'time_step': runs[0]["input"]["incar"]["POTIM"] * 1e-3, - 'frame_properties': list(trajectory[0].frame_properties.keys()), - 'notes': notes + "formula_pretty": trajectory[0].composition.reduced_formula, + "formula": trajectory[0].composition.formula.replace(" ", ""), + "temperature": int(runs[0]["input"]["incar"]["TEBEG"]), + "runs_label": runs_label, + "compression": compression_type, + "fs_id": gfs_id, + "fs": "trajectories_fs", + "step_fs_ids": [ + i["calcs_reversed"][0]["output"]["ionic_steps_fs_id"] for i in runs + ], + "structure": trajectory[0].as_dict(), + "dimension": list(np.shape(trajectory.frac_coords)), + "time_step": runs[0]["input"]["incar"]["POTIM"] * 1e-3, + "frame_properties": list(trajectory[0].frame_properties.keys()), + "notes": notes, } return traj_doc + def load_trajectories_from_gfs(runs, mmdb, gfs_keys=None): if gfs_keys is None: # Attempt to automatically determine where the trajectory is stored (for compatibility with older mpmorph) gfs_keys = [] for run in runs: # 3 cases to deal with: 1) Trajectory 2) previous_runs (old mpmorph) 3) structures_fs - if 'trajectory' in run.keys(): - gfs_keys.append((run['trajectory']['fs_id'], 'trajectories_fs')) + if "trajectory" in run.keys(): + gfs_keys.append((run["trajectory"]["fs_id"], "trajectories_fs")) elif "INCAR" in run.keys(): # for backwards compatibility with older version of mpmorph - gfs_keys.append((run["ionic_steps_fs_id"], 'previous_runs_gfs')) + gfs_keys.append((run["ionic_steps_fs_id"], "previous_runs_gfs")) elif "input" in run.keys(): - gfs_keys.append((run["calcs_reversed"][0]["output"]["ionic_steps_fs_id"], 'structures_fs')) + gfs_keys.append( + ( + run["calcs_reversed"][0]["output"]["ionic_steps_fs_id"], + "structures_fs", + ) + ) trajectory = None for i, (fs_id, fs) in enumerate(gfs_keys): - - if fs == 'trajectories_fs' or fs == 'rebuild_trajectories_fs': + if fs == "trajectories_fs" or fs == "rebuild_trajectories_fs": # Load stored Trajectory - print(fs_id, 'is stored in trajectories_fs') + print(fs_id, "is stored in trajectories_fs") _trajectory = load_trajectory(fs_id=fs_id, db=mmdb.db, fs=fs) else: # Load Ionic steps from gfs, then convert to trajectory before extending @@ -215,10 +245,10 @@ def process_traj(data): mmdb = VaspMDCalcDb.from_db_file(db_file, admin=True) ionic_steps_dict = load_ionic_steps(fs_id, mmdb.db, fs) - structure = Structure.from_dict(ionic_steps_dict[0]['structure']) + structure = Structure.from_dict(ionic_steps_dict[0]["structure"]) positions = [0] * len(ionic_steps_dict) for i, step in enumerate(ionic_steps_dict): - _step = [atom['abc'] for atom in step["structure"]["sites"]] + _step = [atom["abc"] for atom in step["structure"]["sites"]] positions[i] = _step traj = Trajectory(structure.lattice.matrix, structure.species, positions, 0.002) @@ -228,7 +258,7 @@ def process_traj(data): def load_trajectory(fs_id, db, fs=None): if not fs: # Default to trajectories_fs - fs = gridfs.GridFS(db, 'trajectories_fs') + fs = gridfs.GridFS(db, "trajectories_fs") elif not isinstance(fs, gridfs.GridFS): # Handle fs supplied as str fs = gridfs.GridFS(db, fs) @@ -275,7 +305,9 @@ def insert_gridfs(d, db, collection="fs", compress=True, oid=None, task_id=None) if task_id: # Putting task id in the metadata subdocument as per mongo specs: # https://github.com/mongodb/specifications/blob/master/source/gridfs/gridfs-spec.rst#terms - fs_id = fs.put(d, _id=oid, metadata={"task_id": task_id, "compression": compression_type}) + fs_id = fs.put( + d, _id=oid, metadata={"task_id": task_id, "compression": compression_type} + ) else: fs_id = fs.put(d, _id=oid, metadata={"compression": compression_type}) diff --git a/mpmorph/firetasks/glue_tasks.py b/src/mpmorph/firetasks/glue_tasks.py similarity index 68% rename from mpmorph/firetasks/glue_tasks.py rename to src/mpmorph/firetasks/glue_tasks.py index 65319b9b..87911a4a 100644 --- a/mpmorph/firetasks/glue_tasks.py +++ b/src/mpmorph/firetasks/glue_tasks.py @@ -1,18 +1,18 @@ import os +from typing import List import numpy as np -from fireworks import explicit_serialize, FireTaskBase, FWAction -from mpmorph.analysis import md_data +from fireworks import FireTaskBase, FWAction, explicit_serialize from pymatgen.core import Structure from pymatgen.io.vasp import Poscar -__author__ = 'Eric Sivonxay ' +from mpmorph.analysis import md_data @explicit_serialize class PreviousStructureTask(FireTaskBase): - required_params = [] - optional_params = ["rescale_volume"] + required_params: List[str] = [] + optional_params: List[str] = ["rescale_volume"] def run_task(self, fw_spec): structure_dict = fw_spec["structure"] @@ -31,8 +31,8 @@ def run_task(self, fw_spec): @explicit_serialize class SaveStructureTask(FireTaskBase): - required_params = [] - optional_params = [] + required_params: List[str] = [] + optional_params: List[str] = [] def run_task(self, fw_spec): osw = list(os.walk("."))[0] @@ -42,7 +42,9 @@ def run_task(self, fw_spec): if "CONTCAR" in file_name: files.append(file_name) - _poscar = Poscar.from_file(filename=files[-1], check_for_POTCAR=True, read_velocities=True) + _poscar = Poscar.from_file( + filename=files[-1], check_for_POTCAR=True, read_velocities=True + ) _structure = _poscar.structure.as_dict() return FWAction(update_spec={"structure": _structure}) @@ -54,7 +56,7 @@ class PassPVTask(FireTaskBase): optional_params = [] def run_task(self, fw_spec): - pressure_volume = fw_spec.get('pressure_volume', []) + pressure_volume = fw_spec.get("pressure_volume", []) # get volume osw = list(os.walk("."))[0] @@ -62,15 +64,17 @@ def run_task(self, fw_spec): for file_name in osw[2]: if "CONTCAR" in file_name: files.append(file_name) - _poscar = Poscar.from_file(filename=files[-1], check_for_POTCAR=True, read_velocities=True) + _poscar = Poscar.from_file( + filename=files[-1], check_for_POTCAR=True, read_velocities=True + ) volume = _poscar.structure.volume # get pressure - search_keys = ['external'] + search_keys = ["external"] outcar_data = md_data.get_MD_data("./OUTCAR.gz", search_keys=search_keys) _data = np.transpose(outcar_data)[0] - pressure = np.mean(_data[int(0.5 * (len(_data) - 1)):]) + pressure = np.mean(_data[int(0.5 * (len(_data) - 1)) :]) pressure_volume.append((volume, pressure)) - return FWAction(mod_spec={'_push_all': {'pressure_volume': pressure_volume}}) + return FWAction(mod_spec={"_push_all": {"pressure_volume": pressure_volume}}) diff --git a/mpmorph/firetasks/mdtasks.py b/src/mpmorph/firetasks/mdtasks.py similarity index 58% rename from mpmorph/firetasks/mdtasks.py rename to src/mpmorph/firetasks/mdtasks.py index 670563f6..133ebd99 100644 --- a/mpmorph/firetasks/mdtasks.py +++ b/src/mpmorph/firetasks/mdtasks.py @@ -1,37 +1,46 @@ import warnings +from typing import List import numpy as np -from fireworks import explicit_serialize, Workflow, FireTaskBase, FWAction -from mpmorph.analysis import md_data -from mpmorph.runners.rescale_volume import RescaleVolume, fit_BirchMurnaghanPV_EOS -from mpmorph.util import recursive_update +from fireworks import FireTaskBase, FWAction, Workflow, explicit_serialize from pymatgen.core import Structure from pymatgen.io.vasp import Poscar from pymatgen.io.vasp.outputs import Vasprun from scipy import stats -__author__ = 'Eric Sivonxay and Muratahan Aykol' -__maintainer__ = "Eric Sivonxay" -__email__ = "esivonxay@lbl.gov" +from mpmorph.analysis import md_data +from mpmorph.runners.rescale_volume import RescaleVolume, fit_BirchMurnaghanPV_EOS +from mpmorph.utils import recursive_update @explicit_serialize class DiffusionTask(FireTaskBase): - required_params = ['temperatures', 'max_steps', 'target_steps', - 'num_samples' 'trajectory_to_db', 'notes'] - optional_params = [] + required_params: List[str] = [ + "temperatures", + "max_steps", + "target_steps", + "num_samples" "trajectory_to_db", + "notes", + ] + optional_params: List[str] = [] def run_task(self, fw_spec): from mpmorph.workflows.converge import get_converge_wf - vr = Vasprun('vasprun.xml.gz') + vr = Vasprun("vasprun.xml.gz") fws = [] - for t in self['temperatures']: - fws.extend(get_converge_wf(s, int(t), max_steps=self['max_steps'], - target_steps=self['target_steps'], - trajectory_to_db=self['trajectory_to_db'], - notes=self['notes'])) + for t in self["temperatures"]: + fws.extend( + get_converge_wf( + s, + int(t), + max_steps=self["max_steps"], + target_steps=self["target_steps"], + trajectory_to_db=self["trajectory_to_db"], + notes=self["notes"], + ) + ) wf = Workflow(fws) return FWAction(detours=wf) @@ -45,7 +54,7 @@ class ConvergeTask(FireTaskBase): """ required_params = ["converge_params", "run_specs", "md_params"] - optional_params = ["rescale_params", 'tag_id', "optional_fw_params"] + optional_params = ["rescale_params", "tag_id", "optional_fw_params"] def run_task(self, fw_spec): from mpmorph.fireworks import powerups @@ -64,16 +73,20 @@ def run_task(self, fw_spec): rescale_params = self.get("rescale_params", {}) # Load Data from OUTCAR - search_keys = ['external', 'kinetic energy EKIN', '% ion-electron', 'ETOTAL'] - key_map = {'density': 'external', 'kinetic energy': 'kinetic energy EKIN', - 'ionic': '% ion-electron', 'total energy': 'ETOTAL'} + search_keys = ["external", "kinetic energy EKIN", "% ion-electron", "ETOTAL"] + key_map = { + "density": "external", + "kinetic energy": "kinetic energy EKIN", + "ionic": "% ion-electron", + "total energy": "ETOTAL", + } outcar_data = md_data.get_MD_data("./OUTCAR.gz", search_keys=search_keys) # Check for convergence converged = {} _index = search_keys.index(key_map["density"]) _data = np.transpose(outcar_data)[_index].copy() - pressure = np.mean(_data[int(avg_fraction * (len(_data) - 1)):]) + pressure = np.mean(_data[int(avg_fraction * (len(_data) - 1)) :]) if "density" in convergence_vars.keys(): if np.abs(pressure) >= convergence_vars["density"]: converged["density"] = False @@ -83,8 +96,13 @@ def run_task(self, fw_spec): if "kinetic energy" in convergence_vars.keys(): _index = search_keys.index(key_map["kinetic energy"]) energy = np.transpose(outcar_data)[_index].copy() - norm_energy = (energy / structure.num_sites) / np.mean(energy / structure.num_sites) - 1 - if np.abs(np.mean(norm_energy[-500:]) - np.mean(norm_energy)) > convergence_vars["kinetic energy"]: + norm_energy = (energy / structure.num_sites) / np.mean( + energy / structure.num_sites + ) - 1 + if ( + np.abs(np.mean(norm_energy[-500:]) - np.mean(norm_energy)) + > convergence_vars["kinetic energy"] + ): converged["kinetic energy"] = False else: converged["kinetic energy"] = True @@ -93,8 +111,8 @@ def run_task(self, fw_spec): energy = np.transpose(outcar_data)[_index].copy() norm_energies = energy / structure.num_sites mu, std = stats.norm.fit(norm_energies) - mu1, std1 = stats.norm.fit(norm_energies[0:int(len(norm_energies) / 2)]) - mu2, std2 = stats.norm.fit(norm_energies[int(len(norm_energies) / 2):]) + mu1, std1 = stats.norm.fit(norm_energies[0 : int(len(norm_energies) / 2)]) + mu2, std2 = stats.norm.fit(norm_energies[int(len(norm_energies) / 2) :]) if np.abs((mu2 - mu1) / mu) < convergence_vars["ionic"]: converged["ionic"] = True else: @@ -117,41 +135,78 @@ def run_task(self, fw_spec): return FWAction(defuse_children=True) elif energy_spawn_count >= max_energy_runs: # Too many energy rescales... Just continue with the production runs - return FWAction(stored_data={'pressure': pressure, - 'energy': mu, - 'density_calculated': True}) + return FWAction( + stored_data={ + "pressure": pressure, + "energy": mu, + "density_calculated": True, + } + ) elif not converged.get("density", True): - rescale_args = {"initial_pressure": pressure * 1000, "initial_temperature": 1, "beta": 0.0000005} + rescale_args = { + "initial_pressure": pressure * 1000, + "initial_temperature": 1, + "beta": 0.0000005, + } rescale_args = recursive_update(rescale_args, rescale_params) # Spawn fw - fw = MDFW(structure, name=f'density_run_{density_spawn_count + 1}-{tag_id}', - previous_structure=False, - **run_specs, **md_params, **optional_params) + fw = MDFW( + structure, + name=f"density_run_{density_spawn_count + 1}-{tag_id}", + previous_structure=False, + **run_specs, + **md_params, + **optional_params, + ) converge_params["density_spawn_count"] += 1 - _spawner_args = {"converge_params": converge_params, "rescale_params": rescale_params, - "run_specs": run_specs, "md_params": md_params, - "optional_fw_params": optional_params, "tag_id": tag_id} + _spawner_args = { + "converge_params": converge_params, + "rescale_params": rescale_params, + "run_specs": run_specs, + "md_params": md_params, + "optional_fw_params": optional_params, + "tag_id": tag_id, + } fw = powerups.add_rescale_volume(fw, **rescale_args) fw = powerups.add_pass_pv(fw) fw = powerups.add_converge_task(fw, **_spawner_args) wf = Workflow([fw]) - return FWAction(detours=wf, stored_data={'pressure': pressure, 'energy': mu}) + return FWAction( + detours=wf, stored_data={"pressure": pressure, "energy": mu} + ) else: - fw = MDFW(structure, name=f'energy_run_{energy_spawn_count + 1}-{tag_id}', previous_structure=False, - **run_specs, **md_params, **optional_params) + fw = MDFW( + structure, + name=f"energy_run_{energy_spawn_count + 1}-{tag_id}", + previous_structure=False, + **run_specs, + **md_params, + **optional_params, + ) converge_params["energy_spawn_count"] += 1 - _spawner_args = {"converge_params": converge_params, "rescale_params": rescale_params, - "run_specs": run_specs, "md_params": md_params, - "optional_fw_params": optional_params, "tag_id": tag_id} + _spawner_args = { + "converge_params": converge_params, + "rescale_params": rescale_params, + "run_specs": run_specs, + "md_params": md_params, + "optional_fw_params": optional_params, + "tag_id": tag_id, + } fw = powerups.add_pass_pv(fw) fw = powerups.add_converge_task(fw, **_spawner_args) wf = Workflow([fw]) - return FWAction(detours=wf, stored_data={'pressure': pressure, 'energy': mu}) + return FWAction( + detours=wf, stored_data={"pressure": pressure, "energy": mu} + ) else: - return FWAction(stored_data={'pressure': pressure, - 'energy': mu, - 'density_calculated': True}) + return FWAction( + stored_data={ + "pressure": pressure, + "energy": mu, + "density_calculated": True, + } + ) @explicit_serialize @@ -159,8 +214,15 @@ class RescaleVolumeTask(FireTaskBase): """ Volume rescaling """ + required_params = ["initial_temperature", "initial_pressure"] - optional_params = ["target_pressure", "target_temperature", "target_pressure", "alpha", "beta"] + optional_params = [ + "target_pressure", + "target_temperature", + "target_pressure", + "alpha", + "beta", + ] def run_task(self, fw_spec): # Initialize volume correction object with last structure from last_run @@ -170,15 +232,20 @@ def run_task(self, fw_spec): target_pressure = self.get("target_pressure", 0.0) alpha = self.get("alpha", 10e-6) beta = self.get("beta", 10e-7) - corr_vol = RescaleVolume.of_poscar(poscar_path="./POSCAR", initial_temperature=initial_temperature, - initial_pressure=initial_pressure, - target_pressure=target_pressure, - target_temperature=target_temperature, alpha=alpha, beta=beta) + corr_vol = RescaleVolume.of_poscar( + poscar_path="./POSCAR", + initial_temperature=initial_temperature, + initial_pressure=initial_pressure, + target_pressure=target_pressure, + target_temperature=target_temperature, + alpha=alpha, + beta=beta, + ) # Rescale volume based on temperature difference first. Const T will return no volume change: - corr_vol.by_thermo(scale='temperature') + corr_vol.by_thermo(scale="temperature") # TO DB ("Rescaled volume due to delta T: ", corr_vol.structure.volume) # Rescale volume based on pressure difference: - corr_vol.by_thermo(scale='pressure') + corr_vol.by_thermo(scale="pressure") # TO DB ("Rescaled volume due to delta P: ", corr_vol.structure.volume) corr_vol.poscar.write_file("./POSCAR") # Pass the rescaled volume to Poscar @@ -192,12 +259,12 @@ class PVRescaleTask(FireTaskBase): """ required_params = [] - optional_params = ['rescale_type'] + optional_params = ["rescale_type"] def run_task(self, fw_spec): - rescale_type = self.get('rescale_type', 'BirchMurnaghan_EOS') + rescale_type = self.get("rescale_type", "BirchMurnaghan_EOS") - if rescale_type == 'BirchMurnaghan_EOS': + if rescale_type == "BirchMurnaghan_EOS": pv_pairs = np.array(fw_spec["pressure_volume"]) pv_pairs = np.flip(pv_pairs, axis=1) pv_pairs = np.flip(pv_pairs[pv_pairs[:, 1].argsort()], axis=0) @@ -206,17 +273,21 @@ def run_task(self, fw_spec): params = fit_BirchMurnaghanPV_EOS(pv_pairs) equil_volume = params[0] except: - warnings.warn("Could not converge Birch-Murnaghan EOS fit, trying linear regression") - rescale_type = 'linear_regression' + warnings.warn( + "Could not converge Birch-Murnaghan EOS fit, trying linear regression" + ) + rescale_type = "linear_regression" pvs = fw_spec["pressure_volume"] p = [item[1] for item in pvs] v = [item[0] for item in pvs] - if rescale_type == 'linear_regression': + if rescale_type == "linear_regression": slope, intercept, r_value, p_value, std_err = stats.linregress(v, p) if slope >= 0: ## In future try building a hull with composition and volume. then getting composition volume - raise ValueError("P and V should be inversely related. Try using larger NSW in the volume variation") + raise ValueError( + "P and V should be inversely related. Try using larger NSW in the volume variation" + ) equil_volume = -intercept / slope frac_change = equil_volume / sorted(v)[int(np.floor(len(v) / 2))] diff --git a/src/mpmorph/fireworks/__init__.py b/src/mpmorph/fireworks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mpmorph/fireworks/core.py b/src/mpmorph/fireworks/core.py similarity index 67% rename from mpmorph/fireworks/core.py rename to src/mpmorph/fireworks/core.py index 7c70c373..173dd226 100644 --- a/mpmorph/fireworks/core.py +++ b/src/mpmorph/fireworks/core.py @@ -5,24 +5,36 @@ from atomate.vasp.firetasks.write_inputs import WriteVaspFromIOSet from atomate.vasp.fireworks.core import StaticFW from fireworks import Firework +from pymatgen.io.vasp.sets import MPMDSet, MPRelaxSet, MPStaticSet + from mpmorph.firetasks.dbtasks import VaspMDToDb from mpmorph.firetasks.glue_tasks import PreviousStructureTask, SaveStructureTask -from pymatgen.io.vasp.sets import MPMDSet, MPStaticSet, MPRelaxSet """ -These fireworks were adapted from atomate.vasp.fireworks.core specifically for this package. -Slight modifications were made to each. +These fireworks were adapted from atomate.vasp.fireworks.core specifically for this +package. Slight modifications were made to each. """ -__maintainer__ = "Eric Sivonxay" -__email__ = "esivonxay@lbl.gov" - class MDFW(Firework): - def __init__(self, structure, start_temp, end_temp, nsteps, name="molecular dynamics", - vasp_input_set=None, vasp_cmd="vasp", override_default_vasp_params=None, - wall_time=None, db_file=None, parents=None, copy_vasp_outputs=False, - previous_structure=False, insert_db=False, save_structure=True, **kwargs): + def __init__( + self, + structure, + start_temp, + end_temp, + nsteps, + name="molecular dynamics", + vasp_input_set=None, + vasp_cmd="vasp", + override_default_vasp_params=None, + wall_time=None, + db_file=None, + parents=None, + previous_structure=False, + insert_db=False, + save_structure=True, + **kwargs, + ): """ This Firework is modified from atomate.vasp.fireworks.core.MDFW to fit the needs of mpmorph Standard firework for a single MD run. @@ -47,36 +59,65 @@ def __init__(self, structure, start_temp, end_temp, nsteps, name="molecular dyna \*\*kwargs: Other kwargs that are passed to Firework.__init__. """ override_default_vasp_params = override_default_vasp_params or {} - vasp_input_set = vasp_input_set or MPMDSet(structure, start_temp=start_temp, - end_temp=end_temp, nsteps=nsteps, - **override_default_vasp_params) + vasp_input_set = vasp_input_set or MPMDSet( + structure, + start_temp=start_temp, + end_temp=end_temp, + nsteps=nsteps, + **override_default_vasp_params, + ) t = [] t.append(WriteVaspFromIOSet(structure=structure, vasp_input_set=vasp_input_set)) if previous_structure: t.append(PreviousStructureTask()) - t.append(RunVaspCustodian(vasp_cmd=vasp_cmd, gamma_vasp_cmd=">>gamma_vasp_cmd<<", - handler_group="md", wall_time=wall_time)) + t.append( + RunVaspCustodian( + vasp_cmd=vasp_cmd, + gamma_vasp_cmd=">>gamma_vasp_cmd<<", + handler_group="md", + wall_time=wall_time, + ) + ) t.append(PassCalcLocs(name=name)) if save_structure: t.append(SaveStructureTask()) - name = f'{structure.composition.reduced_formula}-{name}' + name = f"{structure.composition.reduced_formula}-{name}" if insert_db: - t.append(VaspMDToDb(db_file=db_file, additional_fields={"task_label": name}, - defuse_unsuccessful=False, md_structures=True)) + t.append( + VaspMDToDb( + db_file=db_file, + additional_fields={"task_label": name}, + defuse_unsuccessful=False, + md_structures=True, + ) + ) super(MDFW, self).__init__(t, parents=parents, name=name, **kwargs) class OptimizeFW(Firework): - def __init__(self, structure, name="structure optimization", vasp_input_set=None, - insert_db=True, vasp_cmd="vasp", override_default_vasp_params=None, - ediffg=None, db_file=None, force_gamma=True, - job_type="double_relaxation_run", max_force_threshold=None, - previous_structure=False, auto_npar=">>auto_npar<<", - half_kpts_first_relax=False, parents=None, - handler_group="default", - prev_calc_loc=False, **kwargs): + def __init__( + self, + structure, + name="structure optimization", + vasp_input_set=None, + insert_db=True, + vasp_cmd="vasp", + override_default_vasp_params=None, + ediffg=None, + db_file=None, + force_gamma=True, + job_type="double_relaxation_run", + max_force_threshold=None, + previous_structure=False, + auto_npar=">>auto_npar<<", + half_kpts_first_relax=False, + parents=None, + handler_group="default", + prev_calc_loc=False, + **kwargs, + ): """ Optimize the given structure. Args: @@ -99,40 +140,59 @@ def __init__(self, structure, name="structure optimization", vasp_input_set=None """ override_default_vasp_params = override_default_vasp_params or {} vasp_input_set = vasp_input_set or MPRelaxSet( - structure, force_gamma=force_gamma, **override_default_vasp_params) + structure, force_gamma=force_gamma, **override_default_vasp_params + ) t = [] if prev_calc_loc: additional_files = kwargs.get("additional_files", []) if "additional_files" in kwargs.keys(): del kwargs["additional_files"] - t.append(CopyVaspOutputs(calc_loc=prev_calc_loc, contcar_to_poscar=True, additional_files=additional_files)) - t.append(WriteVaspFromIOSet(structure=structure, - vasp_input_set=vasp_input_set)) + t.append( + CopyVaspOutputs( + calc_loc=prev_calc_loc, + contcar_to_poscar=True, + additional_files=additional_files, + ) + ) + t.append(WriteVaspFromIOSet(structure=structure, vasp_input_set=vasp_input_set)) if previous_structure: t.append(PreviousStructureTask()) - t.append(RunVaspCustodian(vasp_cmd=vasp_cmd, job_type=job_type, - max_force_threshold=max_force_threshold, - ediffg=ediffg, - auto_npar=auto_npar, - half_kpts_first_relax=half_kpts_first_relax, - handler_group=handler_group)) + t.append( + RunVaspCustodian( + vasp_cmd=vasp_cmd, + job_type=job_type, + max_force_threshold=max_force_threshold, + ediffg=ediffg, + auto_npar=auto_npar, + half_kpts_first_relax=half_kpts_first_relax, + handler_group=handler_group, + ) + ) t.append(PassCalcLocs(name=name)) t.append(SaveStructureTask()) if insert_db: t.append(VaspToDb(db_file=db_file, additional_fields={"task_label": name})) - name = f'{structure.composition.reduced_formula}-{name}' + name = f"{structure.composition.reduced_formula}-{name}" super(OptimizeFW, self).__init__(t, parents=parents, name=name, **kwargs) class StaticFW(Firework): - def __init__(self, structure, name="static", - previous_structure=False, vasp_input_set=None, - vasp_cmd="vasp", db_file=None, parents=None, - override_default_vasp_params=None, - pass_structure=True, - prev_calc_loc=False, **kwargs): + def __init__( + self, + structure, + name="static", + previous_structure=False, + vasp_input_set=None, + vasp_cmd="vasp", + db_file=None, + parents=None, + override_default_vasp_params=None, + pass_structure=True, + prev_calc_loc=False, + **kwargs, + ): """ This Firework is modified from atomate.vasp.fireworks.core.StaticFW to fit the needs of mpmorph Standard static calculation Firework - either from a previous location or from a structure. @@ -154,7 +214,9 @@ def __init__(self, structure, name="static", t = [] override_default_vasp_params = override_default_vasp_params or {} - vasp_input_set = vasp_input_set or MPStaticSet(structure, **override_default_vasp_params) + vasp_input_set = vasp_input_set or MPStaticSet( + structure, **override_default_vasp_params + ) if prev_calc_loc: t.append(CopyVaspOutputs(calc_loc=prev_calc_loc, contcar_to_poscar=True)) t.append(WriteVaspFromIOSet(structure=structure, vasp_input_set=vasp_input_set)) @@ -164,5 +226,5 @@ def __init__(self, structure, name="static", t.append(SaveStructureTask()) t.append(PassCalcLocs(name=name)) t.append(VaspToDb(db_file=db_file, additional_fields={"task_label": name})) - name = f'{structure.composition.reduced_formula}-{name}' + name = f"{structure.composition.reduced_formula}-{name}" super(StaticFW, self).__init__(t, parents=parents, name=name, **kwargs) diff --git a/mpmorph/fireworks/powerups.py b/src/mpmorph/fireworks/powerups.py similarity index 71% rename from mpmorph/fireworks/powerups.py rename to src/mpmorph/fireworks/powerups.py index f207e7ca..4d244c33 100644 --- a/mpmorph/fireworks/powerups.py +++ b/src/mpmorph/fireworks/powerups.py @@ -1,12 +1,15 @@ -from mpmorph.firetasks.dbtasks import VaspMDToDb, TrajectoryDBTask -from mpmorph.firetasks.glue_tasks import PreviousStructureTask, SaveStructureTask, \ - PassPVTask -from mpmorph.firetasks.mdtasks import RescaleVolumeTask, ConvergeTask, PVRescaleTask, \ - DiffusionTask - -__author__ = 'Eric Sivonxay and Jianli Cheng' -__maintainer__ = "Eric Sivonxay" -__email__ = "esivonxay@lbl.gov" +from mpmorph.firetasks.dbtasks import TrajectoryDBTask, VaspMDToDb +from mpmorph.firetasks.glue_tasks import ( + PassPVTask, + PreviousStructureTask, + SaveStructureTask, +) +from mpmorph.firetasks.mdtasks import ( + ConvergeTask, + DiffusionTask, + PVRescaleTask, + RescaleVolumeTask, +) def add_diffusion_task(fw, **kwargs): @@ -43,7 +46,7 @@ def aggregate_trajectory(fw, **kwargs): def add_cont_structure(fw): prev_struct_task = PreviousStructureTask() insert_i = 2 - for (i, task) in enumerate(fw.tasks): + for i, task in enumerate(fw.tasks): if task.fw_name == "{{atomate.vasp.firetasks.run_calc.RunVaspCustodian}}": insert_i = i break @@ -65,7 +68,7 @@ def add_pass_pv(fw, **kwargs): def add_pv_volume_rescale(fw): insert_i = 2 - for (i, task) in enumerate(fw.tasks): + for i, task in enumerate(fw.tasks): if task.fw_name == "{{atomate.vasp.firetasks.run_calc.RunVaspCustodian}}": insert_i = i break @@ -77,7 +80,7 @@ def add_pv_volume_rescale(fw): def add_rescale_volume(fw, **kwargs): rsv_task = RescaleVolumeTask(**kwargs) insert_i = 2 - for (i, task) in enumerate(fw.tasks): + for i, task in enumerate(fw.tasks): if task.fw_name == "{{atomate.vasp.firetasks.run_calc.RunVaspCustodian}}": insert_i = i break @@ -90,9 +93,12 @@ def replace_pass_structure(fw, **kwargs): # look for rescale_volume task replaced = False fw_dict = fw.to_dict() - for i in range(len(fw_dict['spec']['_tasks'])): - if fw_dict['spec']['_tasks'][i]["_fw_name"] == '{{mpmorph.firetasks.glue_tasks.SaveStructureTask}}': - del fw_dict['spec']['_tasks'][i]["_fw_name"] + for i in range(len(fw_dict["spec"]["_tasks"])): + if ( + fw_dict["spec"]["_tasks"][i]["_fw_name"] + == "{{mpmorph.firetasks.glue_tasks.SaveStructureTask}}" + ): + del fw_dict["spec"]["_tasks"][i]["_fw_name"] fw.tasks[i] = SaveStructureTask(**kwargs) replaced = True break @@ -108,10 +114,13 @@ def replace_vaspmdtodb(fw): # look for vaspdb task replaced = False fw_dict = fw.to_dict() - for i in range(len(fw_dict['spec']['_tasks'])): - if fw_dict['spec']['_tasks'][i]["_fw_name"] == '{{atomate.vasp.firetasks.parse_outputs.VaspToDb}}': - del fw_dict['spec']['_tasks'][i]["_fw_name"] - fw.tasks[i] = VaspMDToDb(**fw_dict['spec']['_tasks'][i]) + for i in range(len(fw_dict["spec"]["_tasks"])): + if ( + fw_dict["spec"]["_tasks"][i]["_fw_name"] + == "{{atomate.vasp.firetasks.parse_outputs.VaspToDb}}" + ): + del fw_dict["spec"]["_tasks"][i]["_fw_name"] + fw.tasks[i] = VaspMDToDb(**fw_dict["spec"]["_tasks"][i]) replaced = True break # TODO: Replace with real error handling diff --git a/src/mpmorph/flows/__init__.py b/src/mpmorph/flows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mpmorph/flows/chgnet_md_flow.py b/src/mpmorph/flows/chgnet_md_flow.py new file mode 100644 index 00000000..50534fcd --- /dev/null +++ b/src/mpmorph/flows/chgnet_md_flow.py @@ -0,0 +1,70 @@ +from atomate2.vasp.jobs.core import MDMaker +from jobflow import Flow, Maker +from mpmorph.jobs.core import CHGNetMDMaker + +from mpmorph.jobs.equilibrate_volume import EquilibriumVolumeSearchMaker +from pymatgen.core.structure import Structure + +from mpmorph.jobs.pv_from_calc import PVFromCalc, PVFromCHGNet, PVFromVasp +from mpmorph.jobs.tasks.chgnet_input import CHGNetMDInputs + +EQUILIBRATE_VOLUME_FLOW = "EQUILIBRATE_VOLUME_FLOW" +CHGNET_MD = "CHGNET_MD" + +def get_md_flow_chgnet(structure, temp, steps_prod,steps_pv, converge_first = True, initial_vol_scale = 1, use_device='cpu'): + inputs_prod = CHGNetMDInputs( + temperature=temp, + steps=steps_prod, + use_device=use_device + ) + inputs_pv = CHGNetMDInputs( + temperature=temp, + steps=steps_pv, + use_device=use_device + ) + pv_md_maker = PVFromCHGNet(parameters=inputs_pv) + chgnet_maker = CHGNetMDMaker(parameters = inputs_prod) + return _get_md_flow( + pv_md_maker=pv_md_maker, + production_md_maker=chgnet_maker, + structure=structure, + converge_first=converge_first, + initial_vol_scale=initial_vol_scale, + ) + + +def get_equil_vol_flow_chgnet(structure, temp, steps): + inputs = CHGNetMDInputs( + temperature=temp, + steps=steps + ) + + pv_md_maker = PVFromCHGNet(parameters=inputs) + eq_vol_maker = EquilibriumVolumeSearchMaker(pv_md_maker=pv_md_maker) + equil_vol_job = eq_vol_maker.make(structure) + flow = Flow([equil_vol_job], output=equil_vol_job.output, name=EQUILIBRATE_VOLUME_FLOW) + return flow + + +def _get_md_flow(pv_md_maker, production_md_maker, structure, converge_first, +initial_vol_scale): + + struct = structure.copy() + if initial_vol_scale is not None: + struct.scale_lattice(struct.volume * initial_vol_scale) + + if converge_first: + return _get_converge_flow(struct, pv_md_maker, production_md_maker) + else: + return Flow([production_md_maker.make(struct)], name='flow') + +def _get_converge_flow(structure: Structure, pv_md_maker: PVFromCalc, production_run_maker: Maker): + eq_vol_maker = EquilibriumVolumeSearchMaker(pv_md_maker=pv_md_maker) + + equil_vol_job = eq_vol_maker.make(structure) + + final_md_job = production_run_maker.make(equil_vol_job.output) + + flow = Flow([equil_vol_job, final_md_job], output=final_md_job.output, name=CHGNET_MD) + + return flow diff --git a/src/mpmorph/flows/core.py b/src/mpmorph/flows/core.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mpmorph/flows/md_flow.py b/src/mpmorph/flows/md_flow.py new file mode 100644 index 00000000..8d8a3fb5 --- /dev/null +++ b/src/mpmorph/flows/md_flow.py @@ -0,0 +1,126 @@ +from atomate2.vasp.jobs.core import MDMaker +from atomate2.vasp.sets.core import MDSetGenerator +from jobflow import Flow, Maker +from mpmorph.jobs.core import M3GNetMDMaker + +from mpmorph.jobs.equilibrate_volume import EquilibriumVolumeSearchMaker +from mpmorph.jobs.lammps.lammps_basic_const_temp import BasicLammpsConstantTempMaker +from pymatgen.core.structure import Structure + +from mpmorph.jobs.pv_from_calc import PVFromCalc, PVFromM3GNet, PVFromVasp +from mpmorph.jobs.tasks.m3gnet_input import M3GNetMDInputs + +EQUILIBRATE_VOLUME_FLOW = "EQUILIBRATE_VOLUME_FLOW" +M3GNET_MD_FLOW = "M3GNET_MD_FLOW" +M3GNET_MD_CONVERGED_VOL_FLOW = "M3GNET_MD_CONVERGED_VOL_FLOW" +LAMMPS_VOL_FLOW = "LAMMPS_VOL_FLOW" + +def get_md_flow_m3gnet(structure, temp, steps_prod, steps_pv, converge_first = True, initial_vol_scale = 1, **input_kwargs): + inputs_prod = M3GNetMDInputs(temperature=temp, steps=steps_prod, **input_kwargs) + inputs_pv = M3GNetMDInputs(temperature=temp, steps=steps_pv, **input_kwargs) + + pv_md_maker = PVFromM3GNet(parameters=inputs_pv) + m3gnet_maker = M3GNetMDMaker(parameters=inputs_prod) + return _get_md_flow( + pv_md_maker=pv_md_maker, + production_md_maker=m3gnet_maker, + structure=structure, + converge_first=converge_first, + initial_vol_scale=initial_vol_scale, + ) + + +def get_equil_vol_flow(structure, temp, steps): + inputs = M3GNetMDInputs(temperature=temp, steps=steps) + + pv_md_maker = PVFromM3GNet(parameters=inputs) + eq_vol_maker = EquilibriumVolumeSearchMaker(pv_md_maker=pv_md_maker) + equil_vol_job = eq_vol_maker.make(structure) + flow = Flow( + [equil_vol_job], output=equil_vol_job.output, name=EQUILIBRATE_VOLUME_FLOW + ) + return flow + +def get_equil_vol_flow_lammps(structure, + temp, + steps): + vol_maker = BasicLammpsConstantTempMaker() + vol_job = vol_maker.make( + temp, + steps, + structure + ) + flow = Flow([vol_job], output=vol_job, name=LAMMPS_VOL_FLOW) + return flow + + +def get_md_flow_vasp( + structure, + temperature, + steps_prod, + steps_pv, + converge_first=True, + initial_vol_scale=1, +): + production_vasp_maker = MDMaker( + input_set_generator=MDSetGenerator( + ensemble="nvt", + start_temp=temperature, + end_temp=temperature, + nsteps=steps_prod, + time_step=2, + ) + ) + + pv_vasp_maker = MDMaker( + input_set_generator=MDSetGenerator( + ensemble="nvt", + start_temp=temperature, + end_temp=temperature, + nsteps=steps_pv, + time_step=2, + ) + ) + + pv_md_maker = PVFromVasp(md_maker=pv_vasp_maker) + + return _get_md_flow( + pv_md_maker=pv_md_maker, + production_md_maker=production_vasp_maker, + structure=structure, + converge_first=converge_first, + initial_vol_scale=initial_vol_scale, + ) + + +def _get_md_flow( + pv_md_maker, production_md_maker, structure, converge_first, initial_vol_scale +): + struct = structure.copy() + if initial_vol_scale is not None: + struct.scale_lattice(struct.volume * initial_vol_scale) + + if converge_first: + return _get_converge_flow(struct, pv_md_maker, production_md_maker) + else: + return Flow([production_md_maker.make(struct)], name="flow") + + +def _get_converge_flow( + structure: Structure, pv_md_maker: PVFromCalc, production_run_maker: Maker +): + eq_vol_maker = EquilibriumVolumeSearchMaker(pv_md_maker=pv_md_maker) + + equil_vol_job = eq_vol_maker.make(structure) + + final_md_job = production_run_maker.make(equil_vol_job.output) + + flow = Flow( + [equil_vol_job, final_md_job], + output=final_md_job.output, + name=M3GNET_MD_CONVERGED_VOL_FLOW, + ) + + return flow + + diff --git a/src/mpmorph/flows/scale_volume.py b/src/mpmorph/flows/scale_volume.py new file mode 100644 index 00000000..3e868fe6 --- /dev/null +++ b/src/mpmorph/flows/scale_volume.py @@ -0,0 +1,27 @@ +from jobflow import Flow, Maker +from pymatgen.core.structure import Structure + + +def md_to_volume_flow( + structure: Structure, scale_factor: float, md_maker: Maker, pv_maker: Maker +): + struct = structure.copy() + struct.scale_lattice(struct.volume * scale_factor) + md_job = md_maker.make(struct) + pv_job = pv_maker.make(md_job.output) + + return Flow([md_job, pv_job], output=pv_job.output) + + +def md_flow( + structure: Structure, + scale_factor: float, + md_maker: Maker, +): + struct = structure.copy() + struct.scale_lattice(struct.volume * scale_factor) + md_job = md_maker.make(struct) + md_updates = {"tags": ["MD", "NVT", scale_factor, struct.formula]} + md_job.metadata.update(md_updates) + + return Flow([md_job]) diff --git a/src/mpmorph/flows/vt_flow.py b/src/mpmorph/flows/vt_flow.py new file mode 100644 index 00000000..7c1ee123 --- /dev/null +++ b/src/mpmorph/flows/vt_flow.py @@ -0,0 +1,93 @@ +from jobflow import Flow, job +import json +import uuid + +from .md_flow import get_equil_vol_flow, get_equil_vol_flow_lammps +from ..jobs.pv_from_calc import m3gnet_calc_to_vol +import pandas as pd + +VOLUME_TEMPERATURE_SWEEP = "VOLUME_TEMPERATURE_SWEEP" + + +def get_vt_sweep_flow( + structure, + lower_bound=100, + upper_bound=1100, + temp_step=100, + output_name="vt.out", + steps=2000, +): + vs = [] + volume_jobs = [] + temps = list(range(lower_bound, upper_bound, temp_step)) + + for temp in temps: + job = get_equil_vol_flow(structure=structure, temp=temp, steps=steps) + volume_jobs.append(job) + vs.append(job.output.volume) + + collect_job = _collect_vt_results(vs, temps, structure, output_name) + + new_flow = Flow( + [*volume_jobs, collect_job], + output=collect_job.output, + name=VOLUME_TEMPERATURE_SWEEP, + ) + return new_flow + + +def get_vt_sweep_flow_lammps( + structure, + lower_bound=100, + upper_bound=1100, + temp_step=100, + output_name="vt.out", + steps=2000, + mp_id=None, +): + v_outputs = [] + volume_jobs = [] + temps = list(range(lower_bound, upper_bound, temp_step)) + + for temp in temps: + job = get_equil_vol_flow_lammps( + structure=structure, + temp=temp, + steps=steps, + ) + volume_jobs.append(job) + v_outputs.append(job.output.output) + + collect_job = _collect_vt_results(v_outputs, temps, structure, output_name, mp_id) + + flow_name = f"{structure.composition.reduced_formula}-Melting Point" + new_flow = Flow( + [*volume_jobs, collect_job], output=collect_job.output, name=flow_name + ) + return new_flow + + +@job +def _collect_vt_results(v_outputs, ts, structure, output_fn, mp_id): + result = { + "structure": structure.as_dict(), + "volumes": [get_converged_vol(v) for v in v_outputs], + "temps": ts, + "mp_id": mp_id, + "reduced_formula": structure.composition.reduced_formula, + "formula": structure.composition.formula, + "uuid": str(uuid.uuid4()), + } + + with open(output_fn, "+w") as f: + f.write(json.dumps(result)) + return result + + +def get_converged_vol(v_output): + df = pd.DataFrame.from_dict(v_output) + total_steps = (len(df) - 1) * 10 + avging_window = int(total_steps / 30) + vols = df.iloc[-avging_window::]["vol"] + eq_vol = vols.values.mean() + return float(eq_vol) diff --git a/mpmorph/io.py b/src/mpmorph/io.py similarity index 85% rename from mpmorph/io.py rename to src/mpmorph/io.py index 3ce24038..8a43529c 100644 --- a/mpmorph/io.py +++ b/src/mpmorph/io.py @@ -2,10 +2,8 @@ from monty.io import zopen -__author__ = 'Eric Sivonxay' - -class Xdatcar_Writer(): +class Xdatcar_Writer: def write_xdatcar(self, filename, **kwargs): """ Writes Xdatcar to a file. The supported kwargs are the same as those for @@ -14,16 +12,18 @@ def write_xdatcar(self, filename, **kwargs): with zopen(filename, "wt") as f: f.write(self.get_string_from_struct(**kwargs)) - def get_string_from_struct(self, structures, system="unknown system", significant_figures=6): + def get_string_from_struct( + self, structures, system="unknown system", significant_figures=6 + ): format_str = "{{:.{0}f}}".format(significant_figures) - for (si, structure) in enumerate(structures): + for si, structure in enumerate(structures): lines = [system, "1.0", str(structure.lattice)] lines.append(" ".join(self.get_site_symbols(structure))) lines.append(" ".join([str(x) for x in self.get_natoms(structure)])) lines.append("Direct configuration= " + str(si + 1)) - for (i, site) in enumerate(structure): + for i, site in enumerate(structure): coords = site.frac_coords line = " ".join([format_str.format(c) for c in coords]) line += " " + site.species_string @@ -48,7 +48,7 @@ def get_natoms(self, structure): return [len(tuple(a[1])) for a in itertools.groupby(syms)] -class Xdatcar_Writer_Trajectory(): +class Xdatcar_Writer_Trajectory: def __init__(self, trajectory): self.trajectory = trajectory @@ -63,18 +63,18 @@ def write_xdatcar(self, filename, **kwargs): def get_string(self, system="unknown system", significant_figures=6): lines = [system, "1.0"] for direction in self.trajectory.lattice: - lines.append(' '.join([str(i) for i in direction])) + lines.append(" ".join([str(i) for i in direction])) lines.append(" ".join(self.get_site_symbols())) lines.append(" ".join([str(x) for x in self.get_natoms()])) format_str = "{{:.{0}f}}".format(significant_figures) positions = self.trajectory.frac_coords - # positions = np.add(self.trajectory[0].frac_coords, self.trajectory.displacements) + # positions = np.add(self.trajectory[0].frac_coords, self.trajectory.displacements) atoms = [site.specie.symbol for site in self.trajectory[0]] - for (si, position_array) in enumerate(positions): + for si, position_array in enumerate(positions): lines.append("Direct configuration= " + str(si + 1)) - for (i, coords) in enumerate(position_array): + for i, coords in enumerate(position_array): line = " ".join([format_str.format(c) for c in coords]) line += " " + atoms[i] lines.append(line) diff --git a/src/mpmorph/jobs/__init__.py b/src/mpmorph/jobs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mpmorph/jobs/core.py b/src/mpmorph/jobs/core.py new file mode 100644 index 00000000..bd73f039 --- /dev/null +++ b/src/mpmorph/jobs/core.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass, field +from jobflow import Maker, job +from pymatgen.core import Structure + +from .tasks.m3gnet_input import M3GNetMDInputs +from .tasks.chgnet_input import CHGNetMDInputs +from .tasks.m3gnet_md_task import run_m3gnet +from .tasks.chgnet_md_task import run_chgnet +from ..schemas.m3gnet_md_calc import M3GNetMDCalculation +from ..schemas.chgnet_md_calc import CHGNetMDCalculation + + +def empty_inputs(): + return M3GNetMDInputs() + +def empty_inputs_chgnet(): + return CHGNetMDInputs() + + +@dataclass +class M3GNetMDMaker(Maker): + """ + Maker to create M3GNet MD calculation jobs. + + Args: + name: The name of the job. Defaults to "m3gnet_run". + parameters: An M3GNetMDInputs object containing the parameters for + the MD run. + """ + + name: str = "m3gnet_run" + parameters: M3GNetMDInputs = field( + default_factory=empty_inputs, + ) + + @job(trajectory="trajectory", output_schema=M3GNetMDCalculation) + def make(self, structure: Structure, **kwargs): + """ + Run MD using the M3GNet Molecular Dynamics interface. This runs molecular + dynamics through the ASE interface with the default M3GNet potential. + + Args: + structure: the input structure + """ + + calc_doc = run_m3gnet(structure, self.parameters, self.name, **kwargs) + + + return calc_doc + + +@dataclass +class CHGNetMDMaker(Maker): + """ + Maker to create CHGNet MD calculation jobs. + + Args: + name: The name of the job. Defaults to "chgnet_run". + parameters: An CHGNetMDInputs object containing the parameters for + the MD run. + """ + + name: str = "chgnet_run" + parameters: CHGNetMDInputs = field( + default_factory=empty_inputs_chgnet, + ) + + @job(trajectory="trajectory", output_schema=CHGNetMDCalculation) + def make(self, structure: Structure, **kwargs): + """ + Run MD using the M3GNet Molecular Dynamics interface. This runs molecular + dynamics through the ASE interface with the default M3GNet potential. + + Args: + structure: the input structure + """ + + calc_doc = run_chgnet(structure, self.parameters, self.name, **kwargs) + + return calc_doc \ No newline at end of file diff --git a/src/mpmorph/jobs/equilibrate_volume.py b/src/mpmorph/jobs/equilibrate_volume.py new file mode 100644 index 00000000..74533c35 --- /dev/null +++ b/src/mpmorph/jobs/equilibrate_volume.py @@ -0,0 +1,89 @@ +from dataclasses import dataclass +from typing import List, Tuple +import numpy as np + +from pymatgen.core.structure import Structure +from jobflow import Maker, job, Flow, Response + +from .pv_from_calc import PVFromVasp +from ..schemas.pv_data_doc import MDPVDataDoc +from ..runners import rescale_volume + + +MAX_MD_JOBS = 5 # if you can't converge with five additional calcs you're doing something wrong... +OFFSET = 0.1 # gives it enough room to slosh back + + +@dataclass +class EquilibriumVolumeSearchMaker(Maker): + """Iteratively identifies the equilibrium volume + of a structure at a particular temperature by fitting a growing + P-V dataset using the Birch Murnaghan equation of state method. + """ + + name: str = "EQUIL_VOL_SEARCH" + pv_md_maker: Maker = PVFromVasp() + initial_scale_factors: Tuple[float] = (0.8, 1, 1.2) + + @job + def make( + self, original_structure: Structure, md_pv_data_docs: List[MDPVDataDoc] = None + ): + if md_pv_data_docs is not None and len(md_pv_data_docs) > MAX_MD_JOBS: + raise RuntimeError( + "Maximum number of jobs for equilibrium volume search exceeded" + ) + + if md_pv_data_docs is None: + new_jobs = [ + self.pv_md_maker.make(original_structure, scale_factor=factor) + for factor in self.initial_scale_factors + ] + md_pv_data_docs = [job.output for job in new_jobs] + else: + volumes = [doc.volume for doc in md_pv_data_docs] + pressures = [doc.pressure for doc in md_pv_data_docs] + pv_pairs = np.array(list(zip(pressures, volumes))) + + max_explored_volume = max(volumes) + min_explored_volume = min(volumes) + + try: + params = rescale_volume.fit_BirchMurnaghanPV_EOS(pv_pairs) + equil_volume = params[0] + except ValueError: + return MDPVDataDoc() + if ( + equil_volume < max_explored_volume + and equil_volume > min_explored_volume + ): + final_structure = original_structure.copy() + final_structure.scale_lattice(equil_volume) + return final_structure + + elif equil_volume > max_explored_volume: + new_vol_scale = get_new_max_volume(equil_volume, original_structure) + + elif equil_volume < min_explored_volume: + new_vol_scale = get_new_min_volume(equil_volume, original_structure) + + # This is specific to the type of MD run you're doing + new_job = self.pv_md_maker.make(original_structure, new_vol_scale) + new_jobs = [new_job] + md_pv_data_docs.append(new_job.output) + + expanded_search_job = EquilibriumVolumeSearchMaker( + pv_md_maker=self.pv_md_maker, + ).make(original_structure, md_pv_data_docs) + + flow = Flow([*new_jobs, expanded_search_job]) + + return Response(replace=flow, output=expanded_search_job.output) + + +def get_new_max_volume(equil_guess, original_structure): + return equil_guess / original_structure.volume + OFFSET + + +def get_new_min_volume(equil_guess, original_structure): + return equil_guess / original_structure.volume - OFFSET diff --git a/src/mpmorph/jobs/lammps/helpers.py b/src/mpmorph/jobs/lammps/helpers.py new file mode 100644 index 00000000..57f91845 --- /dev/null +++ b/src/mpmorph/jobs/lammps/helpers.py @@ -0,0 +1,37 @@ +from ase.io.lammpsrun import read_lammps_dump_text +from pymatgen.io.ase import AseAtomsAdaptor +from pymatgen.core.trajectory import Trajectory +from pymatgen.io.lammps.inputs import LammpsTemplateGen +from pymatgen.io.lammps.data import LammpsData +from subprocess import PIPE, Popen + +def trajectory_from_lammps_dump(dump_path): + with open(dump_path, "r+") as f: + atoms = read_lammps_dump_text(f, index=slice(0, None)) + + structs = [] + + for a in atoms: + structs.append(AseAtomsAdaptor().get_structure(a)) + + return Trajectory.from_structures(structs, constant_lattice=False) + +def run_lammps(structure, template_path, template_opts, lammps_bin): + data_filename: str = "data.dump" + data = LammpsData.from_structure(structure, atom_style='atomic') + # Write the input files + linp = LammpsTemplateGen().get_input_set(script_template=template_path, + settings=template_opts, + data=data, + data_filename=data_filename) + + linp.write_input(directory=".") + input_name = "in.lammps" + # Run LAMMPS + + lammps_cmd = [lammps_bin, "-in", input_name] + print(f"Running: {' '.join(lammps_cmd)}") + with Popen(lammps_cmd, stdout=PIPE, stderr=PIPE) as p: + (stdout, stderr) = p.communicate() + + print(f"LAMMPS finished running: {stdout} \n {stderr}") \ No newline at end of file diff --git a/src/mpmorph/jobs/lammps/lammps_basic_const_temp.py b/src/mpmorph/jobs/lammps/lammps_basic_const_temp.py new file mode 100644 index 00000000..566fc189 --- /dev/null +++ b/src/mpmorph/jobs/lammps/lammps_basic_const_temp.py @@ -0,0 +1,72 @@ +import os +from jobflow import Maker, job + +import pandas as pd +from pymatgen.core.structure import Structure +from .helpers import run_lammps, trajectory_from_lammps_dump + +from mpmorph.schemas.lammps_calc import LammpsCalc + +from pkg_resources import resource_filename + +class BasicLammpsConstantTempMaker(Maker): + """ + Run LAMMPS directly using m3gnet at a constant temperature. + Required params: + lammsps_cmd (str): lammps command to run sans the input file name. + e.g. 'mpirun -n 4 lmp_mpi' + """ + + name = "LAMMPS_CALCULATION" + + @job(trajectory="trajectory", output_schema=LammpsCalc) + def make(self, temperature: int, ensemble:str, total_steps: int, structure: Structure = None, + barostat:str =None, Pstart:str = None, Pstop:str = None, Pdamp:str = None): + lammps_bin = os.environ.get("LAMMPS_CMD") + m3gnet_path = os.environ.get("M3GNET_PATH") + + chem_sys_str = " ".join(el.symbol for el in structure.composition.elements) + script_options ={"temperature": temperature, + "m3gnet_path": m3gnet_path, + "species": chem_sys_str, + "total_steps": total_steps, + "print_every_n_step": 10} + + if ensemble =='nvt': + script_options.update({ + "ensemble": ensemble + }) + elif ensemble =='npt': + script_options.update({ + "ensemble": ensemble, + "barostat":barostat, + "Pstart":Pstart, + "Pstop":Pstop, + "Pdamp":Pdamp, + } ) + + template_path = resource_filename('mpmorph', 'jobs/lammps/templates/basic_constant_temp.lammps') + + run_lammps(structure, template_path, script_options, lammps_bin) + + trajectory = trajectory_from_lammps_dump("trajectory.lammpstrj") + + df = pd.read_csv( + "step_temp_vol_density.txt", + delimiter=" ", + index_col="step", + skiprows=1, + names=["step", "temp", "vol", "density"], + ) + + metadata = {"temperature": temperature, "total_steps": total_steps} + + output = LammpsCalc( + dir_name=os.getcwd(), + trajectory=trajectory, + composition=structure.composition, + reduced_formula=structure.composition.reduced_formula, + metadata=metadata, + dump_data=df.to_dict(), + ) + return output diff --git a/src/mpmorph/jobs/lammps/lammps_basic_temp_sweep.py b/src/mpmorph/jobs/lammps/lammps_basic_temp_sweep.py new file mode 100644 index 00000000..943fd6a8 --- /dev/null +++ b/src/mpmorph/jobs/lammps/lammps_basic_temp_sweep.py @@ -0,0 +1,65 @@ +import os +from jobflow import Maker, job + +import pandas as pd +from pymatgen.core.structure import Structure + +from mpmorph.schemas.lammps_calc import LammpsCalc +from .helpers import run_lammps, trajectory_from_lammps_dump + +from pkg_resources import resource_filename + +class BasicLammpsTempSweepMaker(Maker): + """ + Run LAMMPS directly using m3gnet sweeping over a range of temperatures. + Required params: + lammsps_cmd (str): lammps command to run sans the input file name. + e.g. 'mpirun -n 4 lmp_mpi' + """ + + name = "LAMMPS_CALCULATION" + + @job(trajectory="trajectory", output_schema=LammpsCalc) + def make(self, temp_initial: int, + temp_final: int, + total_steps: int, + structure: Structure = None): + + lammps_bin = os.environ.get("LAMMPS_CMD") + m3gnet_path = os.environ.get("M3GNET_PATH") + + chem_sys_str = " ".join(el.symbol for el in structure.composition.elements) + + script_options = { + "tempstart": temp_initial, + "tempstop": temp_final, + "m3gnet_path": m3gnet_path, + "species": chem_sys_str, + "total_steps": total_steps, + "print_every_n_step": 10 + } + + + template_path = resource_filename('mpmorph', 'jobs/lammps/templates/basic_temp_sweep.lammps') + + run_lammps(structure, template_path, script_options, lammps_bin) + + trajectory = trajectory_from_lammps_dump("trajectory.lammpstrj") + + # df = pd.read_csv("step_temp_vol_density.txt", delimiter=" ", index_col="step", skiprows=1, names=["step", "temp", "vol", "density"]) + + metadata = { + "temp_initial": temp_initial, + "temp_final": temp_final, + "total_steps": total_steps + } + + output = LammpsCalc( + dir_name=os.getcwd(), + trajectory=trajectory, + composition=structure.composition, + reduced_formula=structure.composition.reduced_formula, + metadata=metadata, + dump_data={} + ) + return output \ No newline at end of file diff --git a/src/mpmorph/jobs/lammps/templates/basic_constant_temp.lammps b/src/mpmorph/jobs/lammps/templates/basic_constant_temp.lammps new file mode 100644 index 00000000..e59fc3d5 --- /dev/null +++ b/src/mpmorph/jobs/lammps/templates/basic_constant_temp.lammps @@ -0,0 +1,39 @@ +# +# This is an example of the driver of `M3GNet' , +# which contains a state-of-the-art Graph Neural Network Potential trained with data of Materials Projects. +# This driver is developed by AdvanceSoft Corp . +# Before you use this driver, you have to install python3 and m3gnet (pip install m3gnet). +# +# NOTE: +# 1) the units must be metal +# 2) the 3D periodic boundary condition must be used +# 3) cannot use MPI parallelization, only OpenMP or GPU +# + +units metal +boundary p p p +atom_style atomic + +read_data data.dump + +pair_style m3gnet $m3gnet_path +pair_coeff * * MP-2021.2.8-EFS $species + +thermo_style custom step time cpu pe ke etotal temp press vol density +thermo $print_every_n_step + +# Record trajectory +dump myDump all custom 10 trajectory.lammpstrj id element x y z +dump_modify myDump sort id element $species + +variable p1 equal "step" +variable p2 equal "temp" +variable p3 equal "vol" +variable p4 equal "density" +fix def1 all print $print_every_n_step "${p1} ${p2} ${p3} ${p4}" file step_temp_vol_density.txt + + +velocity all create $temperature 12345 +fix myEnse all $ensemble temp $temperature $temperature 0.1 $barostat $Pstart $Pstop $Pdamp +timestep 0.002 +run $total_steps diff --git a/src/mpmorph/jobs/lammps/templates/basic_temp_sweep.lammps b/src/mpmorph/jobs/lammps/templates/basic_temp_sweep.lammps new file mode 100644 index 00000000..0ad3b8a2 --- /dev/null +++ b/src/mpmorph/jobs/lammps/templates/basic_temp_sweep.lammps @@ -0,0 +1,164 @@ +# Script originally made by Oscar Guerrero +# Reference: https://orca.cardiff.ac.uk/id/eprint/101322/1/MRSPaper2.pdf + +##################################################################### +# READ THIS BEFORE MAKING CHANGES +# +# NOTICE: ANY VARIABLES BEGINNING WITH _ ARE INTERNAL TO THE LAMMPS SCRIPT +# VARIABLES FOR USE REPLACEMENT BY TEMPLATE HAVE NO _ IN NAME +# +# Parameters for this template: +# tempstart - The starting temp for the simulation +# tempstop - The ending temp for the simulation +# species - A space-separated list of elements present in the system, e.g. "Y Mn O" +# m3gnet_path - The path to the m3gnet potential installation +# print_every_n_step - The frequency with which info should be printed to stdout +# total_steps - The total number of simulation steps +########################################################################## + + + +units metal +atom_style atomic +boundary p p p + +atom_modify map array + +#define variables + +variable tempstart equal $tempstart +variable tempstop equal $tempstop +variable myseed equal 12345 +variable atomrate equal 1000 +variable time_step equal 0.002 +variable time_eq equal 1000 +#variable tdamp equal 1. + +variable tamp equal "v_time_step*1000" # DO NOT CHANGE +variable pdamp equal "v_time_step*1000" # DO NOT CHANGE +timestep ${time_step} # DO NOT CHANGE + + +#Create structure +read_data data.dump + + +#Define Interatomic Potential + +pair_style m3gnet $m3gnet_path +pair_coeff * * MP-2021.2.8-EFS $species + +# Equilibration +reset_timestep 0 +velocity all create ${tempstart} ${myseed} mom yes rot no dist gaussian +fix equilibration all npt temp ${tempstart} ${tempstart} $(100.0*dt) iso 1 1 ${pdamp} drag 0.2 + + +variable eq1 equal "step" +variable eq2 equal "pxx" +variable eq3 equal "pyy" +variable eq4 equal "pzz" +variable eq5 equal "lx" +variable eq6 equal "ly" +variable eq7 equal "lz" +variable eq8 equal "vol" +variable eq9 equal "temp" +variable eq10 equal "etotal" + +fix data_equilibration all print 10 "${eq1} ${eq2} ${eq3} ${eq4} ${eq5} ${eq6} ${eq7} ${eq8} ${eq9} ${eq10}" file ${tempstart}K.data +thermo 1000 +thermo_style custom step pxx pyy pzz lx ly lz temp etotal + +# RUN +run 1000 + +# store final volume Vo to calculate V/Vo (reduce units) +variable tmp equal "vol" +variable Vo equal ${tmp} +print "Volume initial is , Vo: ${Vo}" + +#reset +unfix equilibration +unfix data_equilibration + +#----------------------------- Increase temperature------------------------------------ +reset_timestep 0 +fix melting all npt temp ${tempstart} ${tempstop} $(100.0*dt) iso 1 1 ${pdamp} drag 0.2 +# fix melting all nvt temp ${tempstart} ${tempstop} ${tdamp} drag 0.2 + +variable eq1 equal "step" +variable eq2 equal "pxx" +variable eq3 equal "pyy" +variable eq4 equal "pzz" +variable eq5 equal "lx" +variable eq6 equal "ly" +variable eq7 equal "lz" +variable eq8 equal "temp" +variable eq9 equal "vol/v_Vo" +variable eq10 equal "etotal" +run 0 +fix data_melting all print $print_every_n_step "${eq8} ${eq9}" file temp_vs_ref_vol.txt screen no + +dump 1 all cfg 100 step*.cfg mass type xs ys zs id +# What does this do? so the element str can be written to the cfg file +dump_modify 1 element $species + +dump 2 all custom 100 trajectory.lammpstrj id element x y z +dump_modify 2 sort id element $species +# dump all the dump files into one file trajectory.lammpstrj + +# wildcard * is used to dump all the snapshots into invividual files +dump 3 all custom 100 dump.* id type x y z + +# Compute msd command and dump every 10 steps +compute msd all msd com yes +fix msd all ave/time 1 1 10 c_msd[4] file msd.txt + +group lithium type 1 +# group lanthanum type 2 +# group zirconium type 3 +# group oxygen type 4 + +compute mymsd1 lithium msd com yes +# compute ID(mymsd) group-ID(lithium) msd keyword(com)values(yes: If the com option is set to yes +# then the effect of any drift in the center-of-mass of the group of atoms is subtracted out +# before the displacement of each atom is calculated.) +variable msdxLi equal "c_mymsd1[1]" +variable msdyLi equal "c_mymsd1[2]" +variable msdzLi equal "c_mymsd1[3]" +variable msdtotLi equal "c_mymsd1[4]" +fix msdT1 lithium ave/time 1 1 1000 v_msdxLi v_msdyLi v_msdzLi v_msdtotLi file msd_Li.dat +#fix ID group-ID ave/time Nevery Nrepeat Nfreq value1 value2 ... keyword args ... +# For example, if Nevery=2, Nrepeat=6, and Nfreq=100, then values on timesteps 90,92,94,96,98,100 will be used to compute the final average on timestep 100. +# Similarly for timesteps 190,192,194,196,198,200 on timestep 200, etc. +# If Nrepeat=1 and Nfreq = 100, then no time averaging is done; values are simply generated on timesteps 100,200,etc. (v_msdx v_msdy v_msdz v_msdtot) + + +# use velocity auto-correlation function (VACF) to calculate diffusion coefficient +compute 2 all vacf +fix 5 all vector 1 c_2[4] +variable diff equal dt*trap(f_5) +fix vacf all print 1 "${eq1} ${eq8} ${eq9} ${diff}" + +run $total_steps +thermo $print_every_n_step +thermo_style custom step v_diff + + +# print to screen out + +#run 10000 +#reset +unfix melting +unfix data_melting +undump 1 +undump 2 +undump 3 +write_restart restart.equil +write_data data.* +# SAVE THE DATA OF THE CALCULATION OR ELSE YOU NEED TO START OVER = ( OUCH ! +# SIMULATION DONE +clear +print "Simulation done! You have done great job!" + + diff --git a/src/mpmorph/jobs/pv_from_calc.py b/src/mpmorph/jobs/pv_from_calc.py new file mode 100644 index 00000000..2a327621 --- /dev/null +++ b/src/mpmorph/jobs/pv_from_calc.py @@ -0,0 +1,139 @@ +from dataclasses import dataclass +from jobflow import Maker, job, Response +from pymatgen.core.structure import Structure + +from atomate2.vasp.schemas.task import TaskDocument +from atomate2.vasp.jobs.core import MDMaker +from .tasks.m3gnet_input import M3GNetMDInputs +from .tasks.chgnet_input import CHGNetMDInputs +from .tasks.m3gnet_md_task import run_m3gnet +from .tasks.chgnet_md_task import run_chgnet + +from ..schemas.m3gnet_md_calc import M3GNetMDCalculation +from ..schemas.chgnet_md_calc import CHGNetMDCalculation + +from mpmorph.schemas.pv_data_doc import MDPVDataDoc +import numpy as np + + +@dataclass +class PVFromCalc(Maker): + @job + def make(self, structure, scale_factor=None): + struct = structure.copy() + + if scale_factor is not None: + struct.scale_lattice(struct.volume * scale_factor) + + calc_doc = self.run_md(struct) + pv_doc = self.build_doc(calc_doc) + + return Response(output=pv_doc) + + def run_md(self, structure: Structure, **kwargs): + pass + + def build_doc(self, calc_doc): + pass + + +@dataclass +class PVFromM3GNet(PVFromCalc): + name: str = "PV_FROM_M3GNET" + parameters: M3GNetMDInputs = None + + def run_md(self, structure: Structure, **kwargs): + calc_doc = run_m3gnet(structure, self.parameters, self.name, **kwargs) + + return calc_doc + + def build_doc(self, m3gnet_calc: M3GNetMDCalculation): + v_data = m3gnet_calc_to_vol(m3gnet_calc) + p_data = m3gnet_calc_to_pressure(m3gnet_calc) + return MDPVDataDoc(volume=v_data, pressure=p_data) + + +@dataclass +class PVFromM3GNetLammps(PVFromCalc): + """Generates a MDPVDataDoc using Lammps run with M3gnet and a npt ensemble.""" + + name: str = "PV_FROM_M3GNET_LAMMPS" + parameters: M3GNetMDInputs = None + + def run_md(self, structure: Structure, **kwargs): + calc_doc = run_m3gnet(structure, self.parameters, self.name, **kwargs) + + return calc_doc + + def build_doc(self, pvdoc: MDPVDataDoc): + return pvdoc + + +@dataclass +class PVFromCHGNet(PVFromCalc): + name: str = "PV_FROM_CHGNET" + parameters: CHGNetMDInputs = None + + def run_md(self, structure: Structure, **kwargs): + calc_doc = run_chgnet(structure, self.parameters, self.name, **kwargs) + + return calc_doc + + def build_doc(self, chgnet_calc: CHGNetMDCalculation): + v_data = chgnet_calc_to_vol(chgnet_calc) + p_data = chgnet_calc_to_pressure(chgnet_calc) + return MDPVDataDoc(volume=v_data, pressure=p_data) + +def m3gnet_calc_to_vol(m3gnet_calc: M3GNetMDCalculation): + volume = m3gnet_calc.trajectory[-1].lattice.volume + return volume + +def chgnet_calc_to_vol(chgnet_calc: CHGNetMDCalculation): + volume = chgnet_calc.trajectory[-1].lattice.volume + return volume + +def m3gnet_calc_to_pressure(m3gnet_calc: M3GNetMDCalculation): + # retrieve stresses in Voigt order (i.e. xx, yy, zz, yz, xz, xy) + stresses = m3gnet_calc.trajectory.frame_properties[-1]["stress"] + trace = sum(stresses[0:3]) + pressure = 1 / 3 * trace + return pressure + + +def chgnet_calc_to_pressure(chgnet_calc: CHGNetMDCalculation): + # retrieve stresses in Voigt order (i.e. xx, yy, zz, yz, xz, xy) + stresses = chgnet_calc.trajectory.frame_properties[-1]["stress"] + trace = sum(stresses[0:3]) + pressure = 1 / 3 * trace + return pressure + +@dataclass +class PVFromVasp(PVFromCalc): + name: str = "PV_FROM_VASP" + md_maker: Maker = MDMaker() + + def run_md(self, structure: Structure, **kwargs): + # TODO: Not sure about this - I think .original accesses the + # undecorated make function definition. To me this is a big + # code smell - if you have to take the code out of the @job to reuse it, + # it never should have been in the @job, but this is taken from examples + # in atomate2 (e.g. TransmuterMaker), so hopefully it will do for now + return self.md_maker.make.original(self, structure) + + def build_doc( + self, task_document: TaskDocument + ): # Hui is confused that the class name is VASP while task_document is M3GNetMDCalculation + v_data = task_doc_to_volume(task_document) + p_data = task_doc_to_pressure(task_document) + return MDPVDataDoc(volume=v_data, pressure=p_data) + + +def task_doc_to_volume(task_doc: TaskDocument) -> float: + volume = task_doc.calcs_reversed[-1].output.ionic_steps[-1].structure.lattice.volume + return volume + + +def task_doc_to_pressure(task_doc: TaskDocument) -> float: # TODO + stress_tensor = task_doc.calcs_reversed[-1].output.ionic_steps[-1].stress + pressure = 1 / 3 * np.trace(stress_tensor) + return pressure diff --git a/src/mpmorph/jobs/tasks/__init__.py b/src/mpmorph/jobs/tasks/__init__.py new file mode 100644 index 00000000..84c2a415 --- /dev/null +++ b/src/mpmorph/jobs/tasks/__init__.py @@ -0,0 +1 @@ +from .m3gnet_md_task import run_m3gnet diff --git a/src/mpmorph/jobs/tasks/chgnet_input.py b/src/mpmorph/jobs/tasks/chgnet_input.py new file mode 100644 index 00000000..5b950178 --- /dev/null +++ b/src/mpmorph/jobs/tasks/chgnet_input.py @@ -0,0 +1,31 @@ +from typing import Optional + +from dataclasses import dataclass + +from ase import units + + +def one_atmosphere(): + return 1.01325 * units.bar + + +@dataclass +class CHGNetMDInputs: + + ensemble: str = "nvt" + temperature: float = 2000.0 + pressure: float = 1.01325 * units.bar + timestep: float = 2.0 + potential: str = None + taut: Optional[float] = None + taup: Optional[float] = None + compressibility_au: Optional[float] = None + trajectory_fn: str = "out.traj" + logfile_fn: str = "out.log" + loginterval: int = 1 + append_trajectory: bool = False + steps: int = 2000 + save_files: bool = True + use_device: str = 'cpu' # use 'cuda' for faster MD + + diff --git a/src/mpmorph/jobs/tasks/chgnet_md_task.py b/src/mpmorph/jobs/tasks/chgnet_md_task.py new file mode 100644 index 00000000..07640002 --- /dev/null +++ b/src/mpmorph/jobs/tasks/chgnet_md_task.py @@ -0,0 +1,65 @@ +from pathlib import Path + +import os + +from ase import units +from chgnet.model.dynamics import MolecularDynamics +from pymatgen.core import Structure +import dataclasses + +from mpmorph.jobs.tasks.chgnet_input import CHGNetMDInputs +from ...schemas.chgnet_md_calc import CHGNetMDCalculation + +def run_chgnet( + structure: Structure, inputs: CHGNetMDInputs, name: str = "chgnet_run", **kwargs +): + """ + Run MD using the CHGNet Molecular Dynamics interface. This runs molecular + dynamics through the ASE interface with the default CHGNet potential. + + Args: + structure: the input structure + inputs: The parameters for the MD run + name: The name of the calculation document returned by this simulation + """ + + outfile_name = f"{structure.composition.to_pretty_string()}-{inputs.temperature}K-{round(structure.volume, 3)}" + traj_fn = f"{outfile_name}.traj" + log_fn = f"{outfile_name}.log" + + taut = 10 * units.fs + + if inputs.potential is not None: + kwargs["potential"] = inputs.potential + + md = MolecularDynamics( + atoms=structure, + ensemble=inputs.ensemble, + temperature=inputs.temperature, + timestep=inputs.timestep, + pressure=inputs.pressure, + use_device=inputs.use_device, + taut=taut, + taup=inputs.taup, + compressibility_au=inputs.compressibility_au, + trajectory=traj_fn, + logfile=log_fn, + loginterval=inputs.loginterval, + append_trajectory=inputs.append_trajectory, + **kwargs, + ) + + md.run( + steps=inputs.steps, + ) + + d = CHGNetMDCalculation.from_directory(Path.cwd(), trajectory_fn=traj_fn) + + if not inputs.save_files: + os.remove(traj_fn) + os.remove(log_fn) + + d.task_label = name + d.metadata = dataclasses.asdict(inputs) + + return d diff --git a/src/mpmorph/jobs/tasks/m3gnet_input.py b/src/mpmorph/jobs/tasks/m3gnet_input.py new file mode 100644 index 00000000..84f68520 --- /dev/null +++ b/src/mpmorph/jobs/tasks/m3gnet_input.py @@ -0,0 +1,27 @@ +from typing import Optional + +from dataclasses import dataclass + +from ase import units + + +def one_atmosphere(): + return 1.01325 * units.bar + + +@dataclass +class M3GNetMDInputs: + ensemble: str = "nvt" + temperature: float = 2000.0 + pressure: float = 1.01325 * units.bar + timestep: float = 2.0 + potential: str = None + taut: Optional[float] = None + taup: Optional[float] = None + compressibility_au: Optional[float] = None + trajectory_fn: str = "out.traj" + logfile_fn: str = "out.log" + loginterval: int = 1 + append_trajectory: bool = False + steps: int = 1000 + save_files: bool = True diff --git a/src/mpmorph/jobs/tasks/m3gnet_md_task.py b/src/mpmorph/jobs/tasks/m3gnet_md_task.py new file mode 100644 index 00000000..154007ce --- /dev/null +++ b/src/mpmorph/jobs/tasks/m3gnet_md_task.py @@ -0,0 +1,66 @@ +from pathlib import Path + +import os + +from ase import units +from m3gnet.models import MolecularDynamics +from pymatgen.core import Structure +import dataclasses + +from mpmorph.jobs.tasks.m3gnet_input import M3GNetMDInputs + +from ...schemas.m3gnet_md_calc import M3GNetMDCalculation + + +def run_m3gnet( + structure: Structure, inputs: M3GNetMDInputs, name: str = "m3gnet_run", **kwargs +): + """ + Run MD using the M3GNet Molecular Dynamics interface. This runs molecular + dynamics through the ASE interface with the default M3GNet potential. + + Args: + structure: the input structure + inputs: The parameters for the MD run + name: The name of the calculation document returned by this simulation + """ + + outfile_name = f"{structure.composition.to_pretty_string()}-{inputs.temperature}K-{round(structure.volume, 3)}" + traj_fn = f"{outfile_name}.traj" + log_fn = f"{outfile_name}.log" + + taut = 10 * units.fs + + if inputs.potential is not None: + kwargs["potential"] = inputs.potential + + md = MolecularDynamics( + atoms=structure, + ensemble=inputs.ensemble, + temperature=inputs.temperature, + timestep=inputs.timestep, + pressure=inputs.pressure, + taut=taut, + taup=inputs.taup, + compressibility_au=inputs.compressibility_au, + trajectory=traj_fn, + logfile=log_fn, + loginterval=inputs.loginterval, + append_trajectory=inputs.append_trajectory, + **kwargs, + ) + + md.run( + steps=inputs.steps, + ) + + d = M3GNetMDCalculation.from_directory(Path.cwd(), trajectory_fn=traj_fn) + + if not inputs.save_files: + os.remove(traj_fn) + os.remove(log_fn) + + d.task_label = name + d.metadata = dataclasses.asdict(inputs) + + return d diff --git a/src/mpmorph/jobs/volume_temperature_sweep.py b/src/mpmorph/jobs/volume_temperature_sweep.py new file mode 100644 index 00000000..83c66693 --- /dev/null +++ b/src/mpmorph/jobs/volume_temperature_sweep.py @@ -0,0 +1,37 @@ +from jobflow import Flow, Maker, Response, job +from mpmorph.jobs.tasks.m3gnet_input import M3GNetMDInputs +import dataclasses + + +class VolumeTemperatureSweepMaker(Maker): + name: str = "VOLUME_TEMPERATURE_SWEEP" + md_parameters: M3GNetMDInputs = None + + @job + def make( + self, + structure, + lower_bound=100, + upper_bound=1100, + temp_step=100, + output_name="vt.out", + steps=2000, + ): + if self.md_parameters is None: + self.md_parameters = M3GNetMDInputs() + + vs = [] + volume_jobs = [] + temps = list(range(lower_bound, upper_bound, temp_step)) + + for temp in temps: + params = dataclasses.replace(self.md_parameters) + params.temperature = temp + params.steps = steps(structure, params) + volume_jobs.append(job) + vs.append(job.output.volume) + + collect_job = _collect_vt_results(vs, temps, structure, output_name) + + new_flow = Flow([*volume_jobs, collect_job], output=collect_job.output) + return Response(replace=new_flow) diff --git a/src/mpmorph/py.typed b/src/mpmorph/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/mpmorph/runners/__init__.py b/src/mpmorph/runners/__init__.py new file mode 100644 index 00000000..db6a0457 --- /dev/null +++ b/src/mpmorph/runners/__init__.py @@ -0,0 +1 @@ +__author__ = "Muratahan Aykol " diff --git a/mpmorph/runners/amorphous_maker.py b/src/mpmorph/runners/amorphous_maker.py similarity index 75% rename from mpmorph/runners/amorphous_maker.py rename to src/mpmorph/runners/amorphous_maker.py index ae5aae9c..5ceffbe3 100755 --- a/mpmorph/runners/amorphous_maker.py +++ b/src/mpmorph/runners/amorphous_maker.py @@ -3,46 +3,46 @@ import os import shutil from collections import OrderedDict +from typing import List, Optional, Union import numpy as np -from pymatgen.core import Structure, Composition +from pymatgen.core import Composition, Structure from pymatgen.ext.matproj import MPRester from pymatgen.io.vasp.inputs import Poscar -from typing import Union, Optional, List - -__author__ = 'Eric Sivonxay, Jianli Cheng, and Muratahan Aykol' -__maintainer__ = 'Eric Sivonxay' -__email__ = 'esivonxay@lbl.gov' - class AmorphousMaker(object): - def __init__(self, - el_num_dict:dict, - box_scale:Union[float, List[float]], - tol:float=2.0, - packmol_path:str="packmol", - clean:bool=True, - xyz_paths:List=None, - time_seed:bool=True): + def __init__( + self, + el_num_dict: dict, + box_scale: Union[float, List[float]], + tol: float = 2.0, + packmol_path: str = "packmol", + clean: bool = True, + xyz_paths: List = None, + time_seed: bool = True, + ): """ Class for generating initial constrained-random packed structures for the simulation of amorphous or liquid structures. This is a wrapper for "packmol" package. Only works for cubic boxes for now. Args: el_num_dict (dict): dictionary of number of atoms of each species. If - number of molecules is specified, an xyz file with the same name needs to be provided as xyz_paths. + number of molecules is specified, an xyz file with the same name + needs to be provided as xyz_paths. e.g. {"V":22, "Li":10, "O":75, "B":10} e.g. {"H2O": 20} box_scale (float) or (numpy array): all lattice vectors are multiplied with this. - e.g. if one scalar value is given, it is the edge length of a cubic simulation box - e.g. if np.array([1.2, 0.9, 1.0]) is given, the unit lattice vectors will be multiplied with this. + e.g. if one scalar value is given, it is the edge length of a cubic + simulation box (e.g. if np.array([1.2, 0.9, 1.0]) is given, the unit + lattice vectors will be multiplied with this. tol (float): tolerance factor for how close the atoms can get (angstroms). e.g. tol = 2.0 angstroms packmol_path (str): path to the packmol executable clean (bool): whether the intermedite files generated are deleted. - xyz_paths (list): list of paths (str) to xyz files correpsonding to molecules, if given so in el_num_dict. - file names must match the molecule formula. + xyz_paths (list): list of paths (str) to xyz files correpsonding to + molecules, if given so in el_num_dict. File names must match the + molecule formula. time_seed (bool): whether to generate a random seed based on system time """ self.el_num_dict = el_num_dict @@ -60,7 +60,10 @@ def __init__(self, self.clean = False def __repr__(self): - return "AmorphousMaker: generates constrained-random packed initial structure for MD using packmol." + return ( + "AmorphousMaker: generates constrained-random packed initial structure for" + " MD using packmol." + ) @property def box(self): @@ -95,12 +98,23 @@ def call_packmol(self): pm_h = [pm_h for i in range(3)] with open("packmol.input", "w") as f: - f.write("tolerance " + str(self.tol) + "\nfiletype xyz\noutput mixture.xyz\n") + f.write( + "tolerance " + str(self.tol) + "\nfiletype xyz\noutput mixture.xyz\n" + ) for el in self.el_num_dict: - f.write("structure " + el + ".xyz\n" + " number " + str(self.el_num_dict[el]) - + "\n inside box" + 3 * (" " + str(pm_l)) - + (" " + str(pm_h[0])) + (" " + str(pm_h[1])) + (" " + str(pm_h[2])) - + "\nend structure\n\n") + f.write( + "structure " + + el + + ".xyz\n" + + " number " + + str(self.el_num_dict[el]) + + "\n inside box" + + 3 * (" " + str(pm_l)) + + (" " + str(pm_h[0])) + + (" " + str(pm_h[1])) + + (" " + str(pm_h[2])) + + "\nend structure\n\n" + ) if self.time_seed: f.write("seed -1\n") @@ -108,7 +122,7 @@ def call_packmol(self): if self.xyz_paths: for path in self.xyz_paths: try: - shutil.copy2(path, './') + shutil.copy2(path, "./") except: pass else: @@ -126,18 +140,17 @@ def call_packmol(self): os.system("rm packmol.input") return self.xyz_to_dict("mixture.xyz") - def xyz_to_dict(self, - filename:str): + def xyz_to_dict(self, filename: str): """ This is a generic xyz to dictionary convertor. Used to get the structure from packmol output. """ - with open(filename, 'r') as f: + with open(filename, "r") as f: lines = f.readlines() - N = int(lines[0].rstrip('\n')) + N = int(lines[0].rstrip("\n")) el_dict = {} for line in lines[2:]: - l = line.rstrip('\n').split() + l = line.rstrip("\n").split() if l[0] in el_dict: el_dict[l[0]].append([float(i) for i in l[1:]]) else: @@ -150,8 +163,7 @@ def xyz_to_dict(self, return self._el_dict @staticmethod - def get_structure(el_dict:dict, - lattice:List[List]): + def get_structure(el_dict: dict, lattice: List[List]): """ Args: el_dict (dict): coordinates of atoms for each element type @@ -172,9 +184,7 @@ def get_poscar(self): return Poscar(self.random_packed_structure) @staticmethod - def xyzdict_to_poscar(el_dict:dict, - lattice:List[List], - filepath:str="POSCAR"): + def xyzdict_to_poscar(el_dict: dict, lattice: List[List], filepath: str = "POSCAR"): """ Generates XYZ file from element coordinate dictionary and lattice Args: @@ -203,16 +213,16 @@ def xyzdict_to_poscar(el_dict:dict, f.write(" ".join([str(i) for i in atom]) + "\n") - - -def get_random_packed(composition: Union[Composition, str], - add_specie=None, - target_atoms: int = 100, - vol_per_atom:float=None, - vol_exp:float=1.0, - modify_species: dict = None, - use_time_seed: bool = True, - mpr: Optional[MPRester] = None): +def get_random_packed( + composition: Union[Composition, str], + add_specie=None, + target_atoms: int = 100, + vol_per_atom: float = None, + vol_exp: float = 1.0, + modify_species: dict = None, + use_time_seed: bool = True, + mpr: Optional[MPRester] = None, +): """ Helper method to use the AmorphousMaker to generate a randomly packed unit cell. If the volume (per atom) of the unit cell is not provided, the volume per atom will be predicted using structures from the materials project. @@ -239,31 +249,40 @@ def get_random_packed(composition: Union[Composition, str], if mpr is None: mpr = MPRester() - comp_entries = mpr.get_entries(composition.reduced_formula, - inc_structure=True) + comp_entries = mpr.get_entries(composition.reduced_formula, inc_structure=True) if len(comp_entries) > 0: - vols = np.min([entry.structure.volume / entry.structure.num_sites - for entry in comp_entries]) + vols = np.min( + [ + entry.structure.volume / entry.structure.num_sites + for entry in comp_entries + ] + ) else: # Find all Materials project entries containing the elements in the # desired composition to estimate starting volume. _entries = mpr.get_entries_in_chemsys( - [str(el) for el in composition.elements], inc_structure=True) + [str(el) for el in composition.elements], inc_structure=True + ) entries = [] for entry in _entries: - if set(entry.structure.composition.elements) == set(composition.elements): + if set(entry.structure.composition.elements) == set( + composition.elements + ): entries.append(entry) if len(entry.structure.composition.elements) >= 2: entries.append(entry) - vols = [entry.structure.volume / entry.structure.num_sites - for entry in entries] + vols = [ + entry.structure.volume / entry.structure.num_sites for entry in entries + ] vol_per_atom = np.mean(vols) # Find total composition of atoms in the unit cell formula, factor = composition.get_integer_formula_and_factor() integer_composition = Composition(formula) - full_cell_composition = integer_composition * np.ceil(target_atoms / integer_composition.num_atoms) + full_cell_composition = integer_composition * np.ceil( + target_atoms / integer_composition.num_atoms + ) if add_specie is not None: full_cell_composition += add_specie @@ -276,9 +295,14 @@ def get_random_packed(composition: Union[Composition, str], for i, v in modify_species.items(): structure[i] += v # use packmol to get a random configured structure - packmol_path = os.environ['PACKMOL_PATH'] - amorphous_maker_params = {'box_scale': (vol_per_atom * full_cell_composition.num_atoms * vol_exp) ** (1 / 3), - 'packmol_path': packmol_path, 'xyz_paths': None, 'time_seed': use_time_seed} + packmol_path = os.environ["PACKMOL_PATH"] + amorphous_maker_params = { + "box_scale": (vol_per_atom * full_cell_composition.num_atoms * vol_exp) + ** (1 / 3), + "packmol_path": packmol_path, + "xyz_paths": None, + "time_seed": use_time_seed, + } glass = AmorphousMaker(structure, **amorphous_maker_params) structure = glass.random_packed_structure diff --git a/mpmorph/runners/rescale_volume.py b/src/mpmorph/runners/rescale_volume.py similarity index 73% rename from mpmorph/runners/rescale_volume.py rename to src/mpmorph/runners/rescale_volume.py index 2dca7a6a..88dc19fe 100644 --- a/mpmorph/runners/rescale_volume.py +++ b/src/mpmorph/runners/rescale_volume.py @@ -1,9 +1,11 @@ import numpy as np from pymatgen.io.vasp import Poscar -__author__ = 'Eric Sivonxay and Muratahan Aykol' -__maintainer__ = 'Eric Sivonxay' -__email__ = 'esivonxay@lbl.gov' +from scipy.optimize import leastsq + +__author__ = "Eric Sivonxay and Muratahan Aykol" +__maintainer__ = "Eric Sivonxay" +__email__ = "esivonxay@lbl.gov" class RescaleVolume(object): @@ -11,9 +13,17 @@ class RescaleVolume(object): Class for adjusting the volume of an input simulation box based on conditions. """ - def __init__(self, structure, initial_pressure=0.0, initial_temperature=1, - target_pressure=0.0, target_temperature=1, - alpha=10e-6, beta=10e-7, poscar=None): + def __init__( + self, + structure, + initial_pressure=0.0, + initial_temperature=1, + target_pressure=0.0, + target_temperature=1, + alpha=10e-6, + beta=10e-7, + poscar=None, + ): """ Args: structure: @@ -50,7 +60,7 @@ def rescale_structure_volume(self, v2_v1, tol=1): else: return self.structure.scale_lattice(self.structure.volume * v2_v1) - def by_thermo(self, scale='pressure'): + def by_thermo(self, scale="pressure"): """ Scales the volume of structure using thermodynamic functions, which basically give linear Equations of State. For more advanced EOS, one should use the by_EOS method. @@ -60,20 +70,24 @@ def by_thermo(self, scale='pressure'): rescaled structure """ - if scale == 'pressure': + if scale == "pressure": v2_v1 = np.exp(-self.beta * (self.target_pressure - self.initial_pressure)) self.rescale_structure_volume(v2_v1) self.initial_pressure = self.target_pressure - elif scale == 'temperature': - v2_v1 = np.exp(self.alpha * (self.target_temperature - self.initial_temperature)) + elif scale == "temperature": + v2_v1 = np.exp( + self.alpha * (self.target_temperature - self.initial_temperature) + ) self.rescale_structure_volume(v2_v1) self.initial_temperature = self.target_temperature else: - raise ValueError("scale function must be specified as temperature or pressure.") + raise ValueError( + "scale function must be specified as temperature or pressure." + ) return self.structure - def by_EOS(self, p_v, eos='polynomial'): + def by_EOS(self, p_v, eos="polynomial"): """ Args: p_v (numpy array): an array of pressure-volume pairs; e.g. p_v = [[p1,v1],[p2,v2],...] @@ -85,14 +99,16 @@ def by_EOS(self, p_v, eos='polynomial'): self.structure """ v1 = self.structure.volume - if eos == 'polynomial': + if eos == "polynomial": v2_v1 = poly_rescale(p_v, target_pressure=self.target_pressure) / v1 self.rescale_structure_volume(v2_v1) self.initial_pressure = self.target_pressure - elif eos == 'Murnaghan': + elif eos == "Murnaghan": raise ValueError("not implemented yet") - elif eos == 'BirchMurnaghan': - v2_v1 = BirchMurnaghan_rescale(p_v, target_pressure=self.target_pressure) / v1 + elif eos == "BirchMurnaghan": + v2_v1 = ( + BirchMurnaghan_rescale(p_v, target_pressure=self.target_pressure) / v1 + ) self.rescale_structure_volume(v2_v1) self.initial_pressure = self.target_pressure else: @@ -100,17 +116,31 @@ def by_EOS(self, p_v, eos='polynomial'): return self.structure @classmethod - def of_poscar(cls, poscar_path, initial_pressure=0.0, initial_temperature=1000.0, - target_pressure=0.0, target_temperature=1000.0, - alpha=10e-5, beta=10e-7): + def of_poscar( + cls, + poscar_path, + initial_pressure=0.0, + initial_temperature=1000.0, + target_pressure=0.0, + target_temperature=1000.0, + alpha=10e-5, + beta=10e-7, + ): """ Convenience constructor that accepts a poscar file as input """ poscar = Poscar.from_file(poscar_path) - return cls(poscar.structure, initial_pressure=initial_pressure, initial_temperature=initial_temperature, - target_pressure=target_pressure, target_temperature=target_temperature, - alpha=alpha, beta=beta, poscar=poscar) + return cls( + poscar.structure, + initial_pressure=initial_pressure, + initial_temperature=initial_temperature, + target_pressure=target_pressure, + target_temperature=target_temperature, + alpha=alpha, + beta=beta, + poscar=poscar, + ) def poly_rescale(p_v, target_pressure=0.0): @@ -138,18 +168,23 @@ def BirchMurnaghanPV_EOS(V, params): Pressure of Birch-Murnaghan EOS at V with given parameters E0, B0, V0 and B0p """ V0, B0, B0p = params[0], params[1], params[2] - n = (V0 / V) ** (1. / 3) # Note this definition is different from the Energy EOS - p = 3.0 / 2.0 * B0 * (n ** 7 - n ** 5) * (1. + 3. / 4 * (B0p - 4) * (n ** 2 - 1.0)) + n = (V0 / V) ** (1.0 / 3) # Note this definition is different from the Energy EOS + p = ( + 3.0 + / 2.0 + * B0 + * (n**7 - n**5) + * (1.0 + 3.0 / 4 * (B0p - 4) * (n**2 - 1.0)) + ) return p -def fit_BirchMurnaghanPV_EOS(p_v): +def fit_BirchMurnaghanPV_EOS(p_v: np.ndarray): # Borrows somewhat from pymatgen/io/abinitio/EOS # Initial guesses for the parameters - from scipy.optimize import leastsq eqs = np.polyfit(p_v[:, 1], p_v[:, 0], 2) V0 = np.mean(p_v[:, 1]) # still use mean to ensure we are at reasonable volumes - B0 = -1 * (2 * eqs[0] * V0 ** 2 + eqs[1] * V0) + B0 = -1 * (2 * eqs[0] * V0**2 + eqs[1] * V0) B0p = 4.0 initial_params = (V0, B0, B0p) Error = lambda params, x, y: BirchMurnaghanPV_EOS(x, params) - y diff --git a/src/mpmorph/schemas/__init__.py b/src/mpmorph/schemas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mpmorph/schemas/chgnet_md_calc.py b/src/mpmorph/schemas/chgnet_md_calc.py new file mode 100644 index 00000000..09d8b6e6 --- /dev/null +++ b/src/mpmorph/schemas/chgnet_md_calc.py @@ -0,0 +1,111 @@ +from pathlib import Path +from typing import Any, Dict, Iterable, List, Union + +from ase.io.trajectory import Trajectory as AseTrajectory +from pydantic import BaseModel, Field +from pymatgen.core.composition import Composition +from pymatgen.core.trajectory import Trajectory as PmgTrajectory +from pymatgen.io.ase import AseAtomsAdaptor + +from mpmorph.utils import datetime_str + + +class CHGNetMDCalculation(BaseModel): + task_label: str = Field(None, description="The name of the task.") + dir_name: str = Field( + None, description="The directory where the CHGNet calculation was run" + ) + last_updated: str = Field( + default_factory=datetime_str, + description="Timestamp of when the document was last updated.", + ) + trajectory: PmgTrajectory = Field( + None, description="The pymatgen Trajectory object stored ad dictionary" + ) + composition: Composition = Field(description="The composition of the structure.") + reduced_formula: str = Field( + description="The reduced formula of the structure's composition." + ) + metadata: dict = Field( + None, + description=( + "Important info about the calculation, including ensemble type," + " temperature, etc." + ), + ) + + @classmethod + def from_directory( + cls, + dir_name: Union[Path, str], + trajectory_fn: str = "out.traj", + frame_properties: Iterable[str] = ( + "total_energy", + "potential_energy", + "kinetic_energy", + "stress", + ), + **kwargs, + ): + + """ + Create a CHGNetCalculation document from a directory containing output files of + a CHGNET MD run. + """ + dir_name = Path(dir_name) + + trajectory = AseTrajectory(filename=str(dir_name / trajectory_fn)) + + return cls.from_trajectory( + trajectory, + frame_properties=frame_properties, + dir_name=str(dir_name), + **kwargs, + ) + + @classmethod + def from_trajectory( + cls, + trajectory: AseTrajectory, + frame_properties: Iterable[str] = ( + "total_energy", + "potential_energy", + "kinetic_energy", + "stress", + "temperature", + ), + **kwargs, + ): + """ + Create a CHGNetCalculation document from an ASE trajectory object. + + Args: + trajectory: the ASE trajectory file loaded from the out.traj file + **kwargs: Additional keyword arguments to pass to the CHGNetCalculation + constructor. + """ + structures = [] + frame_properties_list: List[Dict[str, Any]] = [] + + initial_structure = AseAtomsAdaptor.get_structure(trajectory[0]) + + for atoms in trajectory: + struct = AseAtomsAdaptor.get_structure(atoms) + frame_props = {k: getattr(atoms, f"get_{k}")() for k in frame_properties} + + structures.append(struct) + frame_properties_list.append(frame_props) + + traj_pmg = PmgTrajectory.from_structures( + structures, + frame_properties=frame_properties_list, + time_step=trajectory.description["timestep"], + ) + + d = { + "trajectory": traj_pmg, + "composition": initial_structure.composition, + "reduced_formula": initial_structure.composition.reduced_formula, + } + + return cls(**d, **kwargs) diff --git a/src/mpmorph/schemas/lammps_calc.py b/src/mpmorph/schemas/lammps_calc.py new file mode 100644 index 00000000..c76c17d1 --- /dev/null +++ b/src/mpmorph/schemas/lammps_calc.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, Field +from pymatgen.core.composition import Composition +from pymatgen.core.trajectory import Trajectory as PmgTrajectory + +from mpmorph.utils import datetime_str + + +class LammpsCalc(BaseModel): + task_label: str = Field(None, description="The name of the task.") + dir_name: str = Field( + None, description="The directory where the LAMMPS calculation was run" + ) + last_updated: str = Field( + default_factory=datetime_str, + description="Timestamp of when the document was last updated.", + ) + trajectory: PmgTrajectory = Field( + None, description="The pymatgen Trajectory object stored ad dictionary" + ) + composition: Composition = Field(description="The composition of the structure.") + reduced_formula: str = Field( + description="The reduced formula of the structure's composition." + ) + dump_data: dict = Field( + None, description="Any additional data collected via LAMMPS dump files" + ) + metadata: dict = Field( + None, + description=( + "Important info about the calculation, including ensemble type," + " temperature, etc." + ), + ) diff --git a/src/mpmorph/schemas/m3gnet_md_calc.py b/src/mpmorph/schemas/m3gnet_md_calc.py new file mode 100644 index 00000000..586e23c6 --- /dev/null +++ b/src/mpmorph/schemas/m3gnet_md_calc.py @@ -0,0 +1,110 @@ +from pathlib import Path +from typing import Any, Dict, Iterable, List, Union + +from ase.io.trajectory import Trajectory as AseTrajectory +from pydantic import BaseModel, Field +from pymatgen.core.composition import Composition +from pymatgen.core.trajectory import Trajectory as PmgTrajectory +from pymatgen.io.ase import AseAtomsAdaptor + +from mpmorph.utils import datetime_str + + +class M3GNetMDCalculation(BaseModel): + task_label: str = Field(None, description="The name of the task.") + dir_name: str = Field( + None, description="The directory where the M3GNet calculation was run" + ) + last_updated: str = Field( + default_factory=datetime_str, + description="Timestamp of when the document was last updated.", + ) + trajectory: PmgTrajectory = Field( + None, description="The pymatgen Trajectory object stored ad dictionary" + ) + composition: Composition = Field(description="The composition of the structure.") + reduced_formula: str = Field( + description="The reduced formula of the structure's composition." + ) + metadata: dict = Field( + None, + description=( + "Important info about the calculation, including ensemble type," + " temperature, etc." + ), + ) + + @classmethod + def from_directory( + cls, + dir_name: Union[Path, str], + trajectory_fn: str = "out.traj", + frame_properties: Iterable[str] = ( + "total_energy", + "potential_energy", + "kinetic_energy", + "stress", + ), + **kwargs, + ): + """ + Create a M3GnetCalculation document from a directory containing output files of + a M3GNet MD run. + """ + dir_name = Path(dir_name) + + trajectory = AseTrajectory(filename=str(dir_name / trajectory_fn)) + + return cls.from_trajectory( + trajectory, + frame_properties=frame_properties, + dir_name=str(dir_name), + **kwargs, + ) + + @classmethod + def from_trajectory( + cls, + trajectory: AseTrajectory, + frame_properties: Iterable[str] = ( + "total_energy", + "potential_energy", + "kinetic_energy", + "stress", + "temperature", + ), + **kwargs, + ): + """ + Create a M3GnetCalculation document from an ASE trajectory object. + + Args: + trajectory: the ASE trajectory file loaded from the out.traj file + **kwargs: Additional keyword arguments to pass to the M3GNetCalculation + constructor. + """ + structures = [] + frame_properties_list: List[Dict[str, Any]] = [] + + initial_structure = AseAtomsAdaptor.get_structure(trajectory[0]) + + for atoms in trajectory: + struct = AseAtomsAdaptor.get_structure(atoms) + frame_props = {k: getattr(atoms, f"get_{k}")() for k in frame_properties} + + structures.append(struct) + frame_properties_list.append(frame_props) + + traj_pmg = PmgTrajectory.from_structures( + structures, + frame_properties=frame_properties_list, + time_step=trajectory.description["timestep"], + ) + + d = { + "trajectory": traj_pmg, + "composition": initial_structure.composition, + "reduced_formula": initial_structure.composition.reduced_formula, + } + + return cls(**d, **kwargs) diff --git a/src/mpmorph/schemas/pv_data_doc.py b/src/mpmorph/schemas/pv_data_doc.py new file mode 100644 index 00000000..b836d8fd --- /dev/null +++ b/src/mpmorph/schemas/pv_data_doc.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, Field + + +class MDPVDataDoc(BaseModel): + task_label: str = Field(None, description="The name of the task.") + volume: float = Field(None, description="The volume data from the MD run") + pressure: float = Field(None, description="The pressure of the MD run") diff --git a/src/mpmorph/schemas/vt_sweep_doc.py b/src/mpmorph/schemas/vt_sweep_doc.py new file mode 100644 index 00000000..c3b7d61f --- /dev/null +++ b/src/mpmorph/schemas/vt_sweep_doc.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field +from pymatgen.core.structure import Structure + + +class VTSweepDoc(BaseModel): + task_label: str = Field(None, description="The name of the task.") + volumes: float = Field(description="The volume at each temperature") + temps: float = Field( + description="The temperatures at which the volume was equilibrated" + ) + structure: Structure = Field( + description="The original structure for which this sweep was performed" + ) diff --git a/mpmorph/util.py b/src/mpmorph/utils.py similarity index 59% rename from mpmorph/util.py rename to src/mpmorph/utils.py index a234c1cf..8262717b 100644 --- a/mpmorph/util.py +++ b/src/mpmorph/utils.py @@ -1,6 +1,5 @@ import collections - -__author__ = 'Eric Sivonxay' +from datetime import datetime def recursive_update(orig_dict, new_dict): @@ -9,7 +8,14 @@ def recursive_update(orig_dict, new_dict): tmp = recursive_update(orig_dict.get(key, {}), val) orig_dict[key] = tmp elif isinstance(val, list): - orig_dict[key] = (orig_dict.get(key, []) + val) + orig_dict[key] = orig_dict.get(key, []) + val else: orig_dict[key] = new_dict[key] return orig_dict + + +def datetime_str() -> str: + """ + Get a string representation of the current time. Borrowed from atomate2. + """ + return str(datetime.utcnow()) diff --git a/src/mpmorph/workflows/__init__.py b/src/mpmorph/workflows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/mpmorph/workflows/converge.py b/src/mpmorph/workflows/converge.py new file mode 100644 index 00000000..004dfdb5 --- /dev/null +++ b/src/mpmorph/workflows/converge.py @@ -0,0 +1,214 @@ +import uuid +from copy import deepcopy + +from fireworks import Workflow + +from mpmorph.fireworks import powerups +from mpmorph.fireworks.core import MDFW +from mpmorph.utils import recursive_update + + +def get_converge_wf( + structure, + temperature, + converge_scheme="EOS", + ml_ff=False, + priority=None, + max_steps=5000, + target_steps=10000, + preconverged=False, + notes=None, + save_data="all", + **kwargs, +): + """ + + Args: + structure: Starting structure for the run + temperature: Temperature for the MD runs + converge_scheme: Equation of state is normally faster and preferred + priority: Priority of all fireworks in the workflows + max_steps: Maximum number of steps per chunk of production run MD simulation + target_steps: Target number of steps for production MD run + preconverged: Whether the structure already converged (i.e. Pressure 0bar) + or volume rescaling not desired + notes: Any additional comments to propagate with this run + save_data: Level to save job outputs. Options are "all", 'production', and None + **kwargs: Arguments such as spawner_args, converge_args, convergence_criteria, + tag_id, prod_count, etc. + + Returns: Workflow object + + """ + # Generate a unique identifier for the fireworks belonging to this workflows + tag_id = kwargs.get("tag_id", uuid.uuid4()) + prod_count = kwargs.get("prod_count", 0) + wf_name = kwargs.get( + "wf_name", f"{structure.composition.reduced_formula}_{temperature}_diffusion" + ) + + fw_list = [] + + # Setup initial Run and convergence of structure + run_args = { + "md_params": { + "start_temp": temperature, + "end_temp": temperature, + "nsteps": 2000, + }, + "run_specs": { + "vasp_input_set": None, + "vasp_cmd": ">>vasp_cmd<<", + "db_file": ">>db_file<<", + }, + "optional_fw_params": { + "override_default_vasp_params": { + "user_incar_settings": {"ISIF": 1, "LWAVE": False, "PREC": "Normal"} + }, + "spec": {"_priority": priority}, + }, + } + if ml_ff: + run_args["optional_fw_params"] = { + "override_default_vasp_params": { + "user_incar_settings": { + "NPAR": 4, + "ML_LMLFF": True, + "ML_ISTART": 0, + "ML_MB": 5000, + } + }, + "spec": {"_priority": priority}, + } + + run_args = recursive_update(run_args, kwargs.get("converge_args", {})) + + # Setup Dictionary specifying parameters of the spawner for convergence tasks + _spawner_args = { + "converge_params": { + "max_rescales": 15, + "density_spawn_count": 1, + "energy_spawn_count": 0, + "converge_type": kwargs.get( + "convergence_criteria", [("density", 5), ("ionic", 0.001)] + ), + }, + "rescale_params": {"beta": 5e-7}, + "run_specs": run_args["run_specs"], + "md_params": run_args["md_params"], + "optional_fw_params": run_args["optional_fw_params"], + "tag_id": tag_id, + } + _spawner_args["md_params"].update({"start_temp": run_args["md_params"]["end_temp"]}) + _spawner_args = recursive_update(_spawner_args, kwargs.get("spawner_args", {})) + + # Converge the pressure (volume) of the system + if not preconverged: + insert_converge_data = True if save_data == "all" else False + + if converge_scheme == "EOS": + # Create structures for varying volumes + images = kwargs.get("image_scale", [0.8, 1, 1.2]) + structures = [structure.copy() for i in images] + for i, factor in enumerate(images): + structures[i].scale_lattice(structure.volume * factor) + + # Create firework for each structure + EOS_run_args = deepcopy(run_args) + EOS_run_args = recursive_update( + EOS_run_args, kwargs.get("converge_args", {}) + ) + volume_fws = [] + for n, (i, vol_structure) in enumerate(zip(images, structures)): + save_structure = True if n == len(images) - 1 else False + _fw = MDFW( + structure=vol_structure, + name=f"volume_{i}-{tag_id}", + previous_structure=False, + insert_db=insert_converge_data, + save_structure=save_structure, + **EOS_run_args["md_params"], + **EOS_run_args["run_specs"], + **EOS_run_args["optional_fw_params"], + ) + + _fw = powerups.add_pass_pv(_fw) + volume_fws.append(_fw) + fw_list.extend(volume_fws) + + # Create firework to converge pressure/volume + spawner_fw = MDFW( + structure=structure, + name=f"run1-{tag_id}", + previous_structure=True, + insert_db=insert_converge_data, + parents=volume_fws, + **run_args["md_params"], + **run_args["run_specs"], + **run_args["optional_fw_params"], + ) + + spawner_fw = powerups.add_pv_volume_rescale(spawner_fw) + spawner_fw = powerups.add_pass_pv(spawner_fw) + _spawner_args["run_specs"]["insert_db"] = insert_converge_data + spawner_fw = powerups.add_converge_task(spawner_fw, **_spawner_args) + fw_list.append(spawner_fw) + else: + fw1 = MDFW( + structure=structure, + name="run0" + "-" + str(tag_id), + previous_structure=False, + insert_db=insert_converge_data, + **run_args["md_params"], + **run_args["run_specs"], + **run_args["optional_fw_params"], + ) + fw1 = powerups.add_converge_task(fw1, **_spawner_args) + fw_list.append(fw1) + + # Production length MD runs + insert_prod_data = ( + True if save_data == "all" or save_data == "production" else False + ) + prod_steps = 0 + while prod_steps <= target_steps - max_steps: + # Create Dictionary with production run parameters + run_args = { + "md_params": { + "start_temp": run_args["md_params"]["end_temp"], + "end_temp": run_args["md_params"]["end_temp"], + "nsteps": max_steps, + }, + "run_specs": { + "vasp_input_set": None, + "vasp_cmd": ">>vasp_cmd<<", + "db_file": ">>db_file<<", + }, + "optional_fw_params": { + "override_default_vasp_params": { + "user_incar_settings": {"ISIF": 1, "LWAVE": False, "PREC": "Normal"} + }, + "spec": {"_priority": priority}, + }, + } + run_args = recursive_update(run_args, kwargs.get("prod_args", {})) + + parents = fw_list[-1] if len(fw_list) > 0 else [] + previous_structure = False if preconverged and prod_steps == 0 else True + fw = MDFW( + structure=structure, + name=f"{temperature}_prod_run_{prod_count}-{tag_id}", + previous_structure=previous_structure, + insert_db=insert_prod_data, + **run_args["md_params"], + **run_args["run_specs"], + **run_args["optional_fw_params"], + parents=parents, + ) + fw_list.append(fw) + + prod_steps += max_steps + prod_count += 1 + + wf = Workflow(fireworks=fw_list, name=wf_name) + return wf diff --git a/src/mpmorph/workflows/diffusion.py b/src/mpmorph/workflows/diffusion.py new file mode 100644 index 00000000..3ad65258 --- /dev/null +++ b/src/mpmorph/workflows/diffusion.py @@ -0,0 +1,3 @@ +def get_diffusion(structure, temperatures=[500, 1000, 1500]): + # TODO + return diff --git a/mpmorph/workflows/examples/H2O.xyz b/src/mpmorph/workflows/examples/H2O.xyz similarity index 100% rename from mpmorph/workflows/examples/H2O.xyz rename to src/mpmorph/workflows/examples/H2O.xyz diff --git a/mpmorph/workflows/examples/water1.py b/src/mpmorph/workflows/examples/water1.py similarity index 55% rename from mpmorph/workflows/examples/water1.py rename to src/mpmorph/workflows/examples/water1.py index d4a42378..d55aa463 100644 --- a/mpmorph/workflows/examples/water1.py +++ b/src/mpmorph/workflows/examples/water1.py @@ -5,7 +5,9 @@ # Parameters: box_scale = 8.9 # edge length of MD box in Angstroms, can also be a numpy array that scales the lattice packmol_path = "~/packmol/packmol/packmol" # Revise as appropriate -structure = {'H2O': 20} # "structure" in this context can be a dict of number of atoms or molecules. +structure = { + "H2O": 20 +} # "structure" in this context can be a dict of number of atoms or molecules. temperature = 320 # Note one can use a pymatgen Structure object also @@ -13,22 +15,35 @@ # structure = p.structure copy_calcs = True # MD runs can be backed up in a desired location -calc_home = '~/test_H2O_wflows' # This is the location to copy the calculations if copy_calcs=True +calc_home = "~/test_H2O_wflows" # This is the location to copy the calculations if copy_calcs=True # Since we specified a molecule, we must also give the path to xyz # file of a single sample molecule. -xyz_paths = ['H2O.xyz'] -name = 'H2O_df_' + str(temperature) +xyz_paths = ["H2O.xyz"] +name = "H2O_df_" + str(temperature) from fireworks import LaunchPad from mpmorph.workflows.old_workflows import get_wf_density -amorphous_maker_params = {'box_scale': box_scale, 'packmol_path': packmol_path, 'xyz_paths': xyz_paths, 'tol': 2.0} - -wf = get_wf_density(structure, temperature=temperature, pressure_threshold=0.5, nsteps=1000, wall_time=19200, - max_rescales=5, - amorphous_maker_params=amorphous_maker_params, copy_calcs=copy_calcs, calc_home=calc_home, - name=name) +amorphous_maker_params = { + "box_scale": box_scale, + "packmol_path": packmol_path, + "xyz_paths": xyz_paths, + "tol": 2.0, +} + +wf = get_wf_density( + structure, + temperature=temperature, + pressure_threshold=0.5, + nsteps=1000, + wall_time=19200, + max_rescales=5, + amorphous_maker_params=amorphous_maker_params, + copy_calcs=copy_calcs, + calc_home=calc_home, + name=name, +) lp = LaunchPad.auto_load() lp.add_wf(wf) diff --git a/src/mpmorph/workflows/quench.py b/src/mpmorph/workflows/quench.py new file mode 100644 index 00000000..6e8066af --- /dev/null +++ b/src/mpmorph/workflows/quench.py @@ -0,0 +1,177 @@ +import numpy as np +from fireworks import Workflow + +from mpmorph.fireworks import powerups +from mpmorph.fireworks.core import MDFW, OptimizeFW, StaticFW +from mpmorph.utils import recursive_update + + +def get_quench_wf( + structures, priority=None, quench_type="slow_quench", descriptor="", **kwargs +): + """ + + Args: + structure: Starting structure for the run + priority: Priority of all fireworks in the workflows + quench_type: use "slow_quench" for a gradual decrease in temperature or + "mp_quench" for a instantaneous DFT relaxation + target_steps: Target number of steps for production MD run + descriptor: Extra description to add to the name of the firework + **kwargs: Arguments such as cool_args, hold_args, quench_args, etc. Cool_args and hold args are only applicable + when using "slow_quench" + + Returns: Workflow object + + """ + + fw_list = [] + temperatures = kwargs.get( + "temperatures", {"start_temp": 3000, "end_temp": 500, "temp_step": 500} + ) + cool_args = kwargs.get("cool_args", {"md_params": {"nsteps": 200}}) + hold_args = kwargs.get("hold_args", {"md_params": {"nsteps": 500}}) + quench_args = kwargs.get("quench_args", {}) + + for i, structure in enumerate(structures): + _fw_list = [] + if quench_type == "slow_quench": + for temp in np.arange( + temperatures["start_temp"], + temperatures["end_temp"], + -temperatures["temp_step"], + ): + # get fw for cool step + use_prev_structure = False + if len(_fw_list) > 0: + use_prev_structure = True + _fw = get_MDFW( + structure, + temp, + temp - temperatures["temp_step"], + name="snap_" + + str(i) + + "_cool_" + + str(temp - temperatures["temp_step"]), + args=cool_args, + parents=[_fw_list[-1]] if len(_fw_list) > 0 else [], + priority=priority, + previous_structure=use_prev_structure, + insert_db=True, + **kwargs, + ) + _fw_list.append(_fw) + # get fw for hold step + _fw = get_MDFW( + structure, + temp - temperatures["temp_step"], + temp - temperatures["temp_step"], + name="snap_" + + str(i) + + "_hold_" + + str(temp - temperatures["temp_step"]), + args=hold_args, + parents=[_fw_list[-1]], + priority=priority, + previous_structure=True, + insert_db=True, + **kwargs, + ) + _fw_list.append(_fw) + + if quench_type in ["slow_quench", "mp_quench"]: + # Relax OptimizeFW and StaticFW + run_args = { + "run_specs": { + "vasp_input_set": None, + "vasp_cmd": ">>vasp_cmd<<", + "db_file": ">>db_file<<", + "spec": {"_priority": priority}, + }, + "optional_fw_params": {"override_default_vasp_params": {}}, + } + run_args = recursive_update(run_args, quench_args) + _name = "snap_" + str(i) + + use_prev_structure = True if len(_fw_list) > 0 else False + fw1 = OptimizeFW( + structure=structure, + name=f"{_name}{descriptor}_optimize", + parents=[_fw_list[-1]] if len(_fw_list) > 0 else [], + previous_structure=use_prev_structure, + **run_args["run_specs"], + **run_args["optional_fw_params"], + max_force_threshold=None, + ) + + fw2 = StaticFW( + structure=structure, + name=f"{_name}{descriptor}_static", + parents=[fw1], + previous_structure=True, + **run_args["run_specs"], + **run_args["optional_fw_params"], + ) + + _fw_list.extend([fw1, fw2]) + + fw_list.extend(_fw_list) + + name = structure.composition.reduced_formula + descriptor + "_quench" + wf = Workflow(fw_list, name=name) + return wf + + +def get_MDFW( + structure, + start_temp, + end_temp, + name="molecular dynamics", + priority=None, + args={}, + **kwargs, +): + """ + + Helper function to get molecular dynamics firework for quench workflow + + Args: + structure: Initial structure for molecular dynamics run + start_temp: Starting Temperature + end_temp: Ending Temperature + name: name of firework + priority: priority of job in database + args: custom arguments dictionary for molecular dynamics run + kwargs: kwargs for MDFW + + Returns: Molecular Dynamics Firework + + """ + # Get customized firework + run_args = { + "md_params": {"nsteps": 500, "start_temp": start_temp, "end_temp": end_temp}, + "run_specs": { + "vasp_input_set": None, + "vasp_cmd": ">>vasp_cmd<<", + "db_file": ">>db_file<<", + "wall_time": 40000, + }, + "optional_fw_params": { + "override_default_vasp_params": {}, + "spec": {"_priority": priority}, + }, + } + + run_args["optional_fw_params"]["override_default_vasp_params"].update( + {"user_incar_settings": {"ISIF": 1, "LWAVE": False, "PREC": "Low"}} + ) + run_args = recursive_update(run_args, args) + _mdfw = MDFW( + structure=structure, + name=name, + **run_args["md_params"], + **run_args["run_specs"], + **run_args["optional_fw_params"], + **kwargs, + ) + return _mdfw diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/analysis/__init__.py b/tests/analysis/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/analysis/test_diffusion.py b/tests/analysis/test_diffusion.py new file mode 100644 index 00000000..e78204f1 --- /dev/null +++ b/tests/analysis/test_diffusion.py @@ -0,0 +1,16 @@ + +import pytest +from pymatgen.io.vasp.outputs import Xdatcar + +from mpmorph.analysis.diffusion import Diffusion + + +@pytest.fixture +def diffusion(md_run): + structures = Xdatcar(md_run[2]).structures + d = Diffusion(structures, 300, 2, skip_first=250) + return d + + +def test_diffusion(diffusion): + assert diffusion.getD("Na") is not None diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..7bd60d3b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +import glob +from pathlib import Path + +import pytest +from jobflow.core.store import JobStore +from maggma.stores import MemoryStore +from monty.serialization import loadfn + +TEST_FILES_PATH = Path(__file__).parent / "test_files" +AL_STRUCTURE = loadfn(TEST_FILES_PATH / "al_structure.json.gz") + + +@pytest.fixture(scope="session") +def al_structure(): + return AL_STRUCTURE + + +@pytest.fixture(scope="session") +def md_run(): + p = glob.glob(str(TEST_FILES_PATH / "liquid_Na/Na_df*/run0/XDATCAR.gz")) + return p + + +@pytest.fixture(scope="session") +def job_store(): + additional_stores = {"trajectory": MemoryStore()} + return JobStore(MemoryStore(), additional_stores=additional_stores) diff --git a/tests/jobs/__init__.py b/tests/jobs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/jobs/test_core.py b/tests/jobs/test_core.py new file mode 100644 index 00000000..60d46f18 --- /dev/null +++ b/tests/jobs/test_core.py @@ -0,0 +1,21 @@ +import pytest +from jobflow.managers.local import run_locally + +from mpmorph.jobs.tasks.m3gnet_input import M3GNetMDInputs +from mpmorph.jobs.core import M3GNetMDMaker + + +@pytest.fixture +def m3gnet_job(al_structure): + params = M3GNetMDInputs( + steps = 10 + ) + print(params) + return M3GNetMDMaker(parameters=params).make(al_structure) + + +def test_m3gnet_job(m3gnet_job, job_store): + output = run_locally(m3gnet_job, store=job_store, ensure_success=True) + + d = output[m3gnet_job.uuid][1].output + assert d.__class__.__name__ == "M3GNetMDCalculation" diff --git a/tests/test_files/al_structure.json.gz b/tests/test_files/al_structure.json.gz new file mode 100644 index 00000000..f106dbbc Binary files /dev/null and b/tests/test_files/al_structure.json.gz differ diff --git a/tests/test_files/liquid_Na/Na_df_671.0/run0/XDATCAR.gz b/tests/test_files/liquid_Na/Na_df_671.0/run0/XDATCAR.gz new file mode 100644 index 00000000..633327d7 Binary files /dev/null and b/tests/test_files/liquid_Na/Na_df_671.0/run0/XDATCAR.gz differ diff --git a/tests/test_files/liquid_Na/Na_df_763.5/run0/XDATCAR.gz b/tests/test_files/liquid_Na/Na_df_763.5/run0/XDATCAR.gz new file mode 100644 index 00000000..7ac545c6 Binary files /dev/null and b/tests/test_files/liquid_Na/Na_df_763.5/run0/XDATCAR.gz differ diff --git a/tests/test_files/liquid_Na/Na_df_856.0/run0/XDATCAR.gz b/tests/test_files/liquid_Na/Na_df_856.0/run0/XDATCAR.gz new file mode 100644 index 00000000..6cd54f95 Binary files /dev/null and b/tests/test_files/liquid_Na/Na_df_856.0/run0/XDATCAR.gz differ