├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ └── python-package.yml ├── .gitignore ├── .gitmodules ├── .python-version ├── CITATION.bib ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── index.html ├── overreact.html └── search.js ├── gendocs.sh ├── overreact ├── __init__.py ├── _cli.py ├── _constants.py ├── _datasets.py ├── _misc.py ├── api.py ├── coords.py ├── core.py ├── io.py ├── rates.py ├── simulate.py ├── thermo │ ├── __init__.py │ ├── _gas.py │ └── _solv.py └── tunnel.py ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── test_api.py ├── test_cli.py ├── test_constants.py ├── test_coords.py ├── test_core.py ├── test_datasets.py ├── test_io.py ├── test_misc.py ├── test_rates.py ├── test_regressions.py ├── test_simulate.py ├── test_thermo_gas.py ├── test_thermo_solv.py └── test_tunnel.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug report 3 | about: Create a report to help us improve. 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Please complete the following tasks** 10 | 11 | - [ ] I have searched the documentation and help resources before reporting. 12 | - [ ] I have searched the [discussions](https://github.com/geem-lab/overreact/discussions) to avoid duplicates. 13 | - [ ] I have searched the [open](https://github.com/geem-lab/overreact/issues) and [closed](https://github.com/geem-lab/overreact/issues?q=is%3Aissue+is%3Aclosed) issues to avoid duplicates. 14 | 15 | **Describe the bug** 16 | A clear and concise description of what the bug is. 17 | 18 | **To Reproduce** 19 | Steps to reproduce the behavior: 20 | 21 | 1. Open the command-line application 22 | 2. Enter command '...' 23 | 3. Option flags applied '...' 24 | 4. Output/error '...' 25 | 26 | **Expected behavior** 27 | A clear and concise description of what you expected to happen. 28 | 29 | **Screenshots** 30 | If applicable, add screenshots to help explain your problem. 31 | 32 | **Version** 33 | 34 | - Application version: [e.g. v1.1.0] 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: 🗫 Community support 3 | url: https://github.com/geem-lab/overreact/discussions 4 | about: Please ask and answer questions here. 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature request 3 | about: Suggest an idea for this project. 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Please complete the following tasks** 10 | 11 | - [ ] I have searched the documentation and help resources before reporting. 12 | - [ ] I have searched the [discussions](https://github.com/geem-lab/overreact/discussions) to avoid duplicates. 13 | - [ ] I have searched the [open](https://github.com/geem-lab/overreact/issues) and [closed](https://github.com/geem-lab/overreact/issues?q=is%3Aissue+is%3Aclosed) issues to avoid duplicates. 14 | 15 | **Is your feature request related to a problem? Please describe.** 16 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 17 | 18 | **Describe the solution you'd like** 19 | A clear and concise description of what you want to happen. 20 | 21 | **Describe alternatives you've considered** 22 | A clear and concise description of any alternative solutions or features you've considered. 23 | 24 | **Additional context** 25 | Add any other context or screenshots about the feature request here. 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | # Maintain dependencies for GitHub Actions 9 | - package-ecosystem: "github-actions" 10 | directory: "/" # Location of package manifests 11 | schedule: 12 | interval: "daily" 13 | # Raise pull requests for version updates 14 | # against the `main` branch 15 | target-branch: "main" 16 | 17 | # Maintain dependencies for pip 18 | - package-ecosystem: "pip" # See documentation for possible values 19 | directory: "/" # Location of package manifests 20 | schedule: 21 | interval: "daily" 22 | # Raise pull requests for version updates 23 | # against the `main` branch 24 | target-branch: "main" 25 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: "44 22 * * 4" 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ["python"] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v4 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v3 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v3 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v3 71 | -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: build 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | python-version: ["3.8", "3.10"] 19 | poetry-version: ["1.3"] 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | submodules: true 25 | 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v5 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | 31 | - name: Python Poetry Action 32 | uses: abatilo/actions-poetry@v4 33 | with: 34 | poetry-version: ${{ matrix.poetry-version }} 35 | 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 40 | poetry install --extras "cli fast solvents" 41 | 42 | - name: Lint with Ruff 43 | - run: | 44 | poetry run ruff check --output-format=github . 45 | 46 | - name: Lint with Black 47 | uses: psf/black@stable 48 | 49 | - name: Test with pytest 50 | run: | 51 | poetry run pytest --cov=. --cov-report=xml 52 | 53 | - name: Upload coverage to Codecov 54 | uses: codecov/codecov-action@v5 55 | with: 56 | token: ${{ secrets.CODECOV_TOKEN }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # poetry 102 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 103 | # This is especially recommended for binary packages to ensure reproducibility, and is more 104 | # commonly ignored for libraries. 105 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 106 | #poetry.lock 107 | 108 | # pdm 109 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 110 | #pdm.lock 111 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 112 | # in version control. 113 | # https://pdm.fming.dev/#use-with-ide 114 | .pdm.toml 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | 166 | ### Python Patch ### 167 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 168 | poetry.toml 169 | 170 | # ruff 171 | .ruff_cache/ 172 | 173 | # LSP config files 174 | pyrightconfig.json 175 | 176 | # End of https://www.toptal.com/developers/gitignore/api/python 177 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "overreact-data"] 2 | path = data 3 | url = https://github.com/geem-lab/overreact-data.git 4 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.10.13 2 | -------------------------------------------------------------------------------- /CITATION.bib: -------------------------------------------------------------------------------- 1 | @article{overreact_paper2022, 2 | title = {Overreact, an in silico lab: Automative quantum chemical microkinetic simulations for complex chemical reactions}, 3 | author = {Schneider, Felipe S. S. and Caramori, Giovanni F.}, 4 | year = {2022}, 5 | month = {Apr}, 6 | journal = {Journal of Computational Chemistry}, 7 | publisher = {Wiley}, 8 | volume = {44}, 9 | number = {3}, 10 | pages = {209–217}, 11 | doi = {10.1002/jcc.26861}, 12 | issn = {1096-987x}, 13 | url = {http://dx.doi.org/10.1002/jcc.26861}, 14 | } 15 | @software{overreact_software2021, 16 | title = {geem-lab/overreact: v1.2.0 \vert{} Zenodo}, 17 | author = {Felipe S. S. Schneider and Let\'{\i}cia M. P. Madureira and Giovanni F. Caramori}, 18 | year = {2023}, 19 | month = {Jan}, 20 | publisher = {Zenodo}, 21 | doi = {10.5281/zenodo.7865357}, 22 | url = {https://doi.org/10.5281/zenodo.7865357}, 23 | version = {v1.2.0}, 24 | howpublished = {\url{https://github.com/geem-lab/overreact}}, 25 | } 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | If you want to contribute to the development of **overreact**, you can find the 4 | source code on [GitHub](https://github.com/geem-lab/overreact). The recommended 5 | way of contributing is by forking the repository and pushing your changes to the 6 | forked repository. 7 | 8 | > **💡** If you're interested in contributing to open-source projects, make sure 9 | > to read 10 | > [“How to Contribute to Open Source”](https://opensource.guide/how-to-contribute/) 11 | > from [Open Source Guides](https://opensource.guide/) They may even have a 12 | > translation for your native language! 13 | 14 | After cloning your fork, we recommend using [Poetry](https://python-poetry.org/) 15 | for managing your contributions: 16 | 17 | ```console 18 | $ git clone git@github.com:your-username/overreact.git # your-username is your GitHub username 19 | $ cd overreact 20 | $ poetry install -E cli -E fast -E solvents # all optional features 21 | ``` 22 | 23 | ## Recommended practices 24 | 25 | ### Reporting issues 26 | 27 | The easiest way to report a bug or request a feature is to 28 | [create an issue on GitHub](http://github.com/geem-lab/overreact/issues). The 29 | following greatly enhances our ability to solve the issue you are experiencing: 30 | 31 | - Before anything, check if we haven't fixed your issue already in the repository 32 | by searching for similar issues in the 33 | [issue tracker](http://github.com/geem-lab/overreact/issues). 34 | - Describe what you were doing when the error occurred, what 35 | happened, and what you expected to see. 36 | Also, include the full [traceback](https://realpython.com/python-traceback/) if 37 | there was an exception. 38 | - Tell us the Python version you're using, as well as the versions of 39 | overreact (and other packages you might be using with it). 40 | - **Please consider including a 41 | [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) 42 | to help us identify the issue.** This also helps check that the issue is not 43 | with your own code. 44 | 45 | ### Asking questions 46 | 47 | Use the [discussions](https://github.com/geem-lab/overreact/discussions) for 48 | questions about your own code or on the use of overreact. (Please don't use the 49 | [issue tracker](https://github.com/geem-lab/overreact/issues) for asking 50 | questions, the discussions are a better place to ask questions 😄.) 51 | 52 | ### Submitting patches 53 | 54 | - Include tests if your patch solves a bug, and explain clearly 55 | under which circumstances the bug happens. Make sure the test fails without 56 | your patch. 57 | - Use [Black](https://black.readthedocs.io/) to auto-format your code. 58 | - Use 59 | [Numpydoc documentation strings](https://numpydoc.readthedocs.io/en/latest/format.html) 60 | to document your code. 61 | - Include a string like “fixes #123” in your commit message (where 123 is the 62 | issue you fixed). See 63 | [Closing issues using keywords](https://help.github.com/articles/creating-a-pull-request/). 64 | - Bump version according to [semantic versioning](https://semver.org/). 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Felipe Silveira de Souza Schneider 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 |
4 |

5 | 6 | PyPI 7 | 8 | 9 | Python Versions 10 | 11 | 12 | CI 13 | 14 | 15 | Coverage 16 | 17 | 18 | License 19 | 20 |

21 |

22 | 23 | User guide 24 | 25 | 26 | GitHub Discussions 27 | 28 | 29 | GitHub issues 30 | 31 |

32 |

33 | 34 | downloads/month 35 | 36 | 37 | total downloads 38 | 39 |

40 |

41 | 42 | DOI 43 | 44 | 45 | DOI 46 | 47 |

48 |

49 | 50 | Made in Brazil 🇧🇷 51 | 52 |

53 |
54 | 55 |
56 | overreact 57 |
58 | 59 | --- 60 | 61 | **overreact** is a **library** and a **command-line tool** for building and 62 | analyzing homogeneous **microkinetic models** from **first-principles 63 | calculations**: 64 | 65 | ```python 66 | In [1]: from overreact import api # the api 67 | 68 | In [2]: api.get_k("S -> E‡ -> S", # your model 69 | ...: {"S": "data/ethane/B97-3c/staggered.out", # your data 70 | ...: "E‡": "data/ethane/B97-3c/eclipsed.out"}) 71 | Out[2]: array([8.16880917e+10]) # your results 72 | ``` 73 | 74 | The user specifies a set of 75 | elementary reactions that are believed to be relevant for the overall chemical 76 | phenomena. **overreact** offers a hopefully complete but simple environment for 77 | hypothesis testing in first-principles chemical kinetics. 78 | 79 |
80 | 81 | 🤔 What is microkinetic modeling? 82 | 83 |

84 | Microkinetic modeling is a technique used to predict the outcome 85 | of complex chemical reactions. 86 | It can be used 87 | to investigate the catalytic transformations 88 | of molecules. 89 | overreact makes it easy to create 90 | and analyze microkinetic models built 91 | from computational chemistry data. 92 |

93 |
94 | 95 |
96 | 97 |
98 | 99 | 🧐 What do you mean by first-principles calculations? 100 | 101 |

102 | We use the term first-principles calculations to refer to 103 | calculations performed using quantum chemical modern methods such as 104 | Wavefunction 105 | and 106 | Density Functional 107 | theories. 108 | For instance, the three-line example code above calculates the rate of methyl rotation in ethane (at 109 | B97-3c). 110 | (Rather surprisingly, the error found is less than 2% 111 | when compared to available experimental results.) 112 |

113 |
114 | 115 |
116 | 117 | **overreact** uses **precise thermochemical partition funtions**, **tunneling 118 | corrections** and data is **parsed directly** from computational chemistry 119 | output files thanks to [`cclib`](https://cclib.github.io/) (see the 120 | [list of its supported programs](https://cclib.github.io/#summary)). 121 | 122 | ## Installation 123 | 124 | **overreact** is a Python package, so you can easily install it with 125 | [`pip`](https://pypi.org/project/pip/): 126 | 127 | ```console 128 | $ pip install "overreact[cli,fast]" 129 | ``` 130 | 131 | See the 132 | [installation guide](https://geem-lab.github.io/overreact-guide/install.html) 133 | for more details. 134 | 135 | > **🚀** **Where to go from here?** Take a look at the 136 | > [short introduction](https://geem-lab.github.io/overreact-guide/tutorial.html). 137 | > Or see 138 | > [below](https://geem-lab.github.io/overreact-guide/intro.html#where-to-go-next) 139 | > for more guidance. 140 | 141 | ## Citing **overreact** 142 | 143 | If you use **overreact** in your research, please cite: 144 | 145 | > Schneider, F. S. S.; Caramori, G. F. 146 | > _**Overreact**, an in Silico Lab: Automative Quantum Chemical Microkinetic Simulations for Complex Chemical Reactions_. 147 | > Journal of Computational Chemistry **2022**, 44 (3), 209–217. 148 | > [doi:10.1002/jcc.26861](https://doi.org/10.1002/jcc.26861). 149 | 150 | Here's the reference in [BibTeX](http://www.bibtex.org/) format: 151 | 152 | ```bibtex 153 | @article{overreact_paper2022, 154 | title = {Overreact, an in silico lab: Automative quantum chemical microkinetic simulations for complex chemical reactions}, 155 | author = {Schneider, Felipe S. S. and Caramori, Giovanni F.}, 156 | year = {2022}, 157 | month = {Apr}, 158 | journal = {Journal of Computational Chemistry}, 159 | publisher = {Wiley}, 160 | volume = {44}, 161 | number = {3}, 162 | pages = {209–217}, 163 | doi = {10.1002/jcc.26861}, 164 | issn = {1096-987x}, 165 | url = {http://dx.doi.org/10.1002/jcc.26861}, 166 | } 167 | @software{overreact_software2021, 168 | title = {geem-lab/overreact: v1.2.0 \vert{} Zenodo}, 169 | author = {Felipe S. S. Schneider and Let\'{\i}cia M. P. Madureira and Giovanni F. Caramori}, 170 | year = {2023}, 171 | month = {Jan}, 172 | publisher = {Zenodo}, 173 | doi = {10.5281/zenodo.7865357}, 174 | url = {https://doi.org/10.5281/zenodo.7865357}, 175 | version = {v1.2.0}, 176 | howpublished = {\url{https://github.com/geem-lab/overreact}}, 177 | } 178 | ``` 179 | 180 | ## License 181 | 182 | **overreact** is open-source, released under the permissive **MIT license**. See 183 | [the LICENSE agreement](https://github.com/geem-lab/overreact/blob/main/LICENSE). 184 | 185 | ## Funding 186 | 187 | This project was developed at the [GEEM lab](https://geem-ufsc.org/) 188 | ([Federal University of Santa Catarina](https://en.ufsc.br/), Brazil), and was 189 | partially funded by the 190 | [Brazilian National Council for Scientific and Technological Development (CNPq)](https://cnpq.br/), 191 | grant number 140485/2017-1. 192 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /gendocs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | pdoc overreact \ 4 | --docformat "numpy" \ 5 | --edit-url "overreact=https://github.com/geem-lab/overreact/blob/main/overreact/" \ 6 | --footer-text "overreact" \ 7 | --logo "https://raw.githubusercontent.com/geem-lab/overreact-guide/master/logo.png" \ 8 | --logo-link "/overreact" \ 9 | --math \ 10 | --search \ 11 | --show-source \ 12 | "$@" 13 | -------------------------------------------------------------------------------- /overreact/__init__.py: -------------------------------------------------------------------------------- 1 | """.. include:: ../README.md""" # noqa: D400 2 | 3 | from __future__ import annotations 4 | 5 | __docformat__ = "restructuredtext" 6 | 7 | import pkg_resources as _pkg_resources 8 | 9 | from overreact.api import ( 10 | get_enthalpies, 11 | get_entropies, 12 | get_freeenergies, 13 | get_internal_energies, 14 | get_k, 15 | get_kappa, 16 | ) 17 | from overreact.core import ( 18 | Scheme, 19 | get_transition_states, 20 | is_transition_state, 21 | parse_reactions, 22 | unparse_reactions, 23 | ) 24 | from overreact.io import parse_compounds, parse_model 25 | from overreact.simulate import get_bias, get_dydt, get_fixed_scheme, get_y 26 | from overreact.thermo import change_reference_state, get_delta, get_reaction_entropies 27 | 28 | __all__ = [ 29 | "Scheme", 30 | "change_reference_state", 31 | "get_bias", 32 | "get_delta", 33 | "get_dydt", 34 | "get_enthalpies", 35 | "get_entropies", 36 | "get_fixed_scheme", 37 | "get_freeenergies", 38 | "get_internal_energies", 39 | "get_k", 40 | "get_kappa", 41 | "get_reaction_entropies", 42 | "get_transition_states", 43 | "get_y", 44 | "is_transition_state", 45 | "parse_compounds", 46 | "parse_model", 47 | "parse_reactions", 48 | "unparse_reactions", 49 | ] 50 | 51 | __version__ = _pkg_resources.get_distribution(__name__).version 52 | __license__ = "MIT" # I'm too lazy to get it from setup.py... 53 | 54 | __headline__ = "📈 Create and analyze chemical microkinetic models built from computational chemistry data." 55 | 56 | __url_repo__ = "https://github.com/geem-lab/overreact" 57 | __url_issues__ = f"{__url_repo__}/issues" 58 | __url_discussions__ = f"{__url_repo__}/discussions" 59 | __url_pypi__ = "https://pypi.org/project/overreact/" 60 | __url_guide__ = "https://geem-lab.github.io/overreact-guide/" 61 | 62 | __doi__ = "10.1002/jcc.26861" 63 | __zenodo_doi__ = "10.5281/zenodo.7865357" 64 | __citations__ = ( # TODO(schneiderfelipe): read from CITATION.bib 65 | r""" 66 | @article{overreact_paper2022, 67 | title = {Overreact, an in silico lab: Automative quantum chemical microkinetic simulations for complex chemical reactions}, 68 | author = {Schneider, Felipe S. S. and Caramori, Giovanni F.}, 69 | year = {2022}, 70 | month = {Apr}, 71 | journal = {Journal of Computational Chemistry}, 72 | publisher = {Wiley}, 73 | volume = {44}, 74 | number = {3}, 75 | pages = {209-217}, 76 | doi = {DOI_PLACEHOLDER}, 77 | issn = {1096-987x}, 78 | url = {http://dx.doi.org/DOI_PLACEHOLDER}, 79 | } 80 | @software{overreact_software2021, 81 | title = {geem-lab/overreact: vVERSION_PLACEHOLDER \vert{} Zenodo}, 82 | author = {Felipe S. S. Schneider and Let\'{\i}cia M. P. Madureira and Giovanni F. Caramori}, 83 | year = {2023}, 84 | month = {Jan}, 85 | publisher = {Zenodo}, 86 | doi = {ZENODO_DOI_PLACEHOLDER}, 87 | url = {https://doi.org/ZENODO_DOI_PLACEHOLDER}, 88 | version = {vVERSION_PLACEHOLDER}, 89 | howpublished = {\url{URL_REPO_PLACEHOLDER}}, 90 | } 91 | """.replace( 92 | "ZENODO_DOI_PLACEHOLDER", 93 | __zenodo_doi__, 94 | ) 95 | .replace("DOI_PLACEHOLDER", __doi__) 96 | .replace("URL_REPO_PLACEHOLDER", __url_repo__) 97 | .replace("VERSION_PLACEHOLDER", __version__) 98 | ) 99 | -------------------------------------------------------------------------------- /overreact/_constants.py: -------------------------------------------------------------------------------- 1 | """Module for storing constant data such as physical fundamental constants. 2 | 3 | Most of this data comes from `scipy.constants`. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import numpy as np 9 | from scipy.constants import ( 10 | N_A, 11 | R, 12 | angstrom, 13 | atm, 14 | atomic_mass, 15 | bar, 16 | c, 17 | calorie, 18 | centi, 19 | eV, 20 | giga, 21 | h, 22 | hbar, 23 | k, 24 | kilo, 25 | liter, 26 | physical_constants, 27 | torr, 28 | ) 29 | 30 | __all__ = [ 31 | "N_A", 32 | "R", 33 | "angstrom", 34 | "atm", 35 | "atomic_mass", 36 | "bar", 37 | "c", 38 | "eV", 39 | "giga", 40 | "h", 41 | "hbar", 42 | "k", 43 | "liter", 44 | "torr", 45 | ] 46 | 47 | hartree, _, _ = physical_constants["Hartree energy"] 48 | bohr, _, _ = physical_constants["Bohr radius"] 49 | kcal = kilo * calorie 50 | 51 | # W. Haynes. CRC Handbook of Chemistry and Physics. 100 Key Points. 52 | # CRC Press, London, 95th edition, 2014. ISBN 9781482208689. 53 | _vdw_radius_crc = [ 54 | 110.0, 55 | 140.0, 56 | 182.0, 57 | 153.0, 58 | 192.0, 59 | 170.0, 60 | 155.0, 61 | 152.0, 62 | 147.0, 63 | 154.0, 64 | 227.0, 65 | 173.0, 66 | 184.0, 67 | 210.0, 68 | 180.0, 69 | 180.0, 70 | 175.0, 71 | 188.0, 72 | 275.0, 73 | 231.0, 74 | 215.0, 75 | 211.0, 76 | 207.0, 77 | 206.0, 78 | 205.0, 79 | 204.0, 80 | 200.0, 81 | 197.0, 82 | 196.0, 83 | 201.0, 84 | 187.0, 85 | 211.0, 86 | 185.0, 87 | 190.0, 88 | 185.0, 89 | 202.0, 90 | 303.0, 91 | 249.0, 92 | 232.0, 93 | 223.0, 94 | 218.0, 95 | 217.0, 96 | 216.0, 97 | 213.0, 98 | 210.0, 99 | 210.0, 100 | 211.0, 101 | 218.0, 102 | 193.0, 103 | 217.0, 104 | 206.0, 105 | 206.0, 106 | 198.0, 107 | 216.0, 108 | 343.0, 109 | 268.0, 110 | 243.0, 111 | 242.0, 112 | 240.0, 113 | 239.0, 114 | 238.0, 115 | 236.0, 116 | 235.0, 117 | 234.0, 118 | 233.0, 119 | 231.0, 120 | 230.0, 121 | 229.0, 122 | 227.0, 123 | 226.0, 124 | 224.0, 125 | 223.0, 126 | 222.0, 127 | 218.0, 128 | 216.0, 129 | 216.0, 130 | 213.0, 131 | 213.0, 132 | 214.0, 133 | 223.0, 134 | 196.0, 135 | 202.0, 136 | 207.0, 137 | 197.0, 138 | 202.0, 139 | 220.0, 140 | 348.0, 141 | 283.0, 142 | 247.0, 143 | 245.0, 144 | 243.0, 145 | 241.0, 146 | 239.0, 147 | 243.0, 148 | 244.0, 149 | 245.0, 150 | 244.0, 151 | 245.0, 152 | 245.0, 153 | 245.0, 154 | 246.0, 155 | 246.0, 156 | 246.0, 157 | None, 158 | None, 159 | None, 160 | None, 161 | None, 162 | None, 163 | None, 164 | None, 165 | None, 166 | None, 167 | None, 168 | None, 169 | None, 170 | None, 171 | None, 172 | ] 173 | 174 | 175 | @np.vectorize 176 | def _vdw_radius(atomno): 177 | """Select reasonable estimates for the van der Waals radii. 178 | 179 | This is a helper for `vdw_radius`. 180 | """ 181 | radius = _vdw_radius_crc[atomno - 1] 182 | if radius is None: 183 | return 2.0 184 | return radius * centi 185 | 186 | 187 | def vdw_radius(atomno): 188 | """Select reasonable estimates for the van der Waals radii. 189 | 190 | This function returns van der Waals radii as recommended in the 95th 191 | edition of the "CRC Handbook of Chemistry and Physics" (2014). This 192 | consists of Bondi radii (A. Bondi. "van der Waals Volumes and Radii". J. 193 | Phys. Chem. 1964, 68, 3, 441-451. doi:10.1021/j100785a001) together with 194 | the values recommended by Truhlar (M. Mantina et al. "Consistent van der 195 | Waals Radii for the Whole Main Group". J. Phys. Chem. A 2009, 113, 19, 196 | 5806-5812. doi:10.1021/jp8111556). For hydrogen, the value recommended by 197 | Taylor is employed (R. Rowland et al. "Intermolecular Nonbonded Contact 198 | Distances in Organic Crystal Structures: Comparison with Distances Expected 199 | from van der Waals Radii". J. Phys. Chem. 1996, 100, 18, 7384-7391. 200 | doi:10.1021/jp953141+). Other elements receive values recommended by either 201 | Hu (S.-Z., Hu. Kristallogr. 224, 375, 2009) or Guzei (Guzei, I. A. and 202 | Wendt, M., Dalton Trans., 2006, 3991, 2006). If neither are defined, we use 203 | 2.0 Å as default. 204 | 205 | Parameters 206 | ---------- 207 | atomno : array-like 208 | 209 | Returns 210 | ------- 211 | array-like 212 | 213 | Examples 214 | -------- 215 | >>> vdw_radius(1) # H 216 | array(1.1) 217 | >>> vdw_radius(35) # Br 218 | array(1.85) 219 | >>> vdw_radius([1, 2, 3, 4, 5]) # H, He, Li, Be, B 220 | array([1.1 , 1.4 , 1.82, 1.53, 1.92]) 221 | >>> vdw_radius(range(1, 11)) # H, He, Li, Be, B, C, N, O, F, Ne 222 | array([1.1 , 1.4 , 1.82, 1.53, 1.92, 1.7 , 1.55, 1.52, 1.47, 1.54]) 223 | >>> vdw_radius(range(21, 31)) # Sc, Ti, V, ..., Ni, Cu, Zn 224 | array([2.15, 2.11, 2.07, 2.06, 2.05, 2.04, 2. , 1.97, 1.96, 2.01]) 225 | """ 226 | return _vdw_radius(atomno) 227 | -------------------------------------------------------------------------------- /overreact/_datasets.py: -------------------------------------------------------------------------------- 1 | """Small toy datasets for tests and benchmark.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | 7 | import overreact as rx 8 | 9 | data_path = os.path.normpath( 10 | os.path.join(os.path.dirname(__file__), "../data/"), 11 | ) 12 | 13 | 14 | logfiles = {} 15 | for name in os.listdir(data_path): 16 | walk_dir = os.path.join(data_path, name) 17 | if os.path.isdir(walk_dir): 18 | logfiles[name] = rx.io._LazyDict() 19 | logfiles[name]._function = rx.io.read_logfile 20 | for root, _, files in os.walk(walk_dir): 21 | for filename in files: 22 | if filename.endswith(".out"): 23 | logfiles[name][ 24 | f"{filename[:-4]}@{os.path.relpath(root, walk_dir)}".replace( 25 | "@.", 26 | "", 27 | ) 28 | ] = os.path.join( 29 | root, 30 | filename, 31 | ) 32 | 33 | 34 | if __name__ == "__main__": 35 | for name in logfiles: 36 | for compound in logfiles[name]: 37 | print(name, compound, logfiles[name][compound].logfile) 38 | -------------------------------------------------------------------------------- /overreact/_misc.py: -------------------------------------------------------------------------------- 1 | """Miscellaneous functions that do not currently fit in other modules. 2 | 3 | Ideally, the functions here will be transferred to other modules in the future. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import contextlib 9 | from functools import lru_cache as cache 10 | 11 | import numpy as np 12 | from scipy.stats import cauchy, norm 13 | 14 | import overreact as rx 15 | from overreact import _constants as constants 16 | 17 | 18 | def _find_package(package): 19 | """Check if a package exists without importing it. 20 | 21 | Inspired by 22 | . 23 | 24 | Parameters 25 | ---------- 26 | package : str 27 | 28 | Returns 29 | ------- 30 | bool 31 | 32 | Examples 33 | -------- 34 | >>> _find_package("overreact") 35 | True 36 | >>> _find_package("a_package_that_does_not_exist") 37 | False 38 | """ 39 | import importlib 40 | 41 | module_spec = importlib.util.find_spec(package) 42 | return module_spec is not None and module_spec.loader is not None 43 | 44 | 45 | _found_jax = _find_package("jax") 46 | _found_rich = _find_package("rich") 47 | _found_seaborn = _find_package("seaborn") 48 | _found_thermo = _find_package("thermo") 49 | 50 | 51 | if _found_thermo: 52 | from thermo.chemical import Chemical 53 | 54 | 55 | # Inspired by 56 | # https://github.com/cclib/cclib/blob/master/cclib/parser/utils.py#L159 57 | element = [ 58 | None, 59 | "H", 60 | "He", 61 | "Li", 62 | "Be", 63 | "B", 64 | "C", 65 | "N", 66 | "O", 67 | "F", 68 | "Ne", 69 | "Na", 70 | "Mg", 71 | "Al", 72 | "Si", 73 | "P", 74 | "S", 75 | "Cl", 76 | "Ar", 77 | "K", 78 | "Ca", 79 | "Sc", 80 | "Ti", 81 | "V", 82 | "Cr", 83 | "Mn", 84 | "Fe", 85 | "Co", 86 | "Ni", 87 | "Cu", 88 | "Zn", 89 | "Ga", 90 | "Ge", 91 | "As", 92 | "Se", 93 | "Br", 94 | "Kr", 95 | "Rb", 96 | "Sr", 97 | "Y", 98 | "Zr", 99 | "Nb", 100 | "Mo", 101 | "Tc", 102 | "Ru", 103 | "Rh", 104 | "Pd", 105 | "Ag", 106 | "Cd", 107 | "In", 108 | "Sn", 109 | "Sb", 110 | "Te", 111 | "I", 112 | "Xe", 113 | "Cs", 114 | "Ba", 115 | "La", 116 | "Ce", 117 | "Pr", 118 | "Nd", 119 | "Pm", 120 | "Sm", 121 | "Eu", 122 | "Gd", 123 | "Tb", 124 | "Dy", 125 | "Ho", 126 | "Er", 127 | "Tm", 128 | "Yb", 129 | "Lu", 130 | "Hf", 131 | "Ta", 132 | "W", 133 | "Re", 134 | "Os", 135 | "Ir", 136 | "Pt", 137 | "Au", 138 | "Hg", 139 | "Tl", 140 | "Pb", 141 | "Bi", 142 | "Po", 143 | "At", 144 | "Rn", 145 | "Fr", 146 | "Ra", 147 | "Ac", 148 | "Th", 149 | "Pa", 150 | "U", 151 | "Np", 152 | "Pu", 153 | "Am", 154 | "Cm", 155 | "Bk", 156 | "Cf", 157 | "Es", 158 | "Fm", 159 | "Md", 160 | "No", 161 | "Lr", 162 | "Rf", 163 | "Db", 164 | "Sg", 165 | "Bh", 166 | "Hs", 167 | "Mt", 168 | "Ds", 169 | "Rg", 170 | "Cn", 171 | "Nh", 172 | "Fl", 173 | "Mc", 174 | "Lv", 175 | "Ts", 176 | "Og", 177 | ] 178 | 179 | # Inspired by 180 | # https://github.com/cclib/cclib/blob/master/cclib/parser/utils.py#L159 181 | atomic_mass = [ 182 | None, 183 | 1.008, 184 | 4.002602, 185 | 6.94, 186 | 9.0121831, 187 | 10.81, 188 | 12.011, 189 | 14.007, 190 | 15.999, 191 | 18.998403163, 192 | 20.1797, 193 | 22.98976928, 194 | 24.305, 195 | 26.9815385, 196 | 28.085, 197 | 30.973761998, 198 | 32.06, 199 | 35.45, 200 | 39.948, 201 | 39.0983, 202 | 40.078, 203 | 44.955908, 204 | 47.867, 205 | 50.9415, 206 | 51.9961, 207 | 54.938044, 208 | 55.845, 209 | 58.933194, 210 | 58.6934, 211 | 63.546, 212 | 65.38, 213 | 69.723, 214 | 72.63, 215 | 74.921595, 216 | 78.971, 217 | 79.904, 218 | 83.798, 219 | 85.4678, 220 | 87.62, 221 | 88.90584, 222 | 91.224, 223 | 92.90637, 224 | 95.95, 225 | 97.90721, 226 | 101.07, 227 | 102.9055, 228 | 106.42, 229 | 107.8682, 230 | 112.414, 231 | 114.818, 232 | 118.71, 233 | 121.76, 234 | 127.6, 235 | 126.90447, 236 | 131.293, 237 | 132.90545196, 238 | 137.327, 239 | 138.90547, 240 | 140.116, 241 | 140.90766, 242 | 144.242, 243 | 144.91276, 244 | 150.36, 245 | 151.964, 246 | 157.25, 247 | 158.92535, 248 | 162.5, 249 | 164.93033, 250 | 167.259, 251 | 168.93422, 252 | 173.045, 253 | 174.9668, 254 | 178.49, 255 | 180.94788, 256 | 183.84, 257 | 186.207, 258 | 190.23, 259 | 192.217, 260 | 195.084, 261 | 196.966569, 262 | 200.592, 263 | 204.38, 264 | 207.2, 265 | 208.9804, 266 | 209.0, 267 | 210.0, 268 | 222.0, 269 | 223.0, 270 | 226.0, 271 | 227.0, 272 | 232.0377, 273 | 231.03588, 274 | 238.02891, 275 | 237.0, 276 | 244.0, 277 | 243.0, 278 | 247.0, 279 | 247.0, 280 | 251.0, 281 | 252.0, 282 | 257.0, 283 | 258.0, 284 | 259.0, 285 | 262.0, 286 | 267.0, 287 | 268.0, 288 | 271.0, 289 | 274.0, 290 | 269.0, 291 | 276.0, 292 | 281.0, 293 | 281.0, 294 | 285.0, 295 | 286.0, 296 | 289.0, 297 | 288.0, 298 | 293.0, 299 | 294.0, 300 | 294.0, 301 | ] 302 | 303 | # Inspired by 304 | # https://github.com/cclib/cclib/blob/master/cclib/parser/utils.py#L159 305 | atomic_number = { 306 | "H": 1, 307 | "He": 2, 308 | "Li": 3, 309 | "Be": 4, 310 | "B": 5, 311 | "C": 6, 312 | "N": 7, 313 | "O": 8, 314 | "F": 9, 315 | "Ne": 10, 316 | "Na": 11, 317 | "Mg": 12, 318 | "Al": 13, 319 | "Si": 14, 320 | "P": 15, 321 | "S": 16, 322 | "Cl": 17, 323 | "Ar": 18, 324 | "K": 19, 325 | "Ca": 20, 326 | "Sc": 21, 327 | "Ti": 22, 328 | "V": 23, 329 | "Cr": 24, 330 | "Mn": 25, 331 | "Fe": 26, 332 | "Co": 27, 333 | "Ni": 28, 334 | "Cu": 29, 335 | "Zn": 30, 336 | "Ga": 31, 337 | "Ge": 32, 338 | "As": 33, 339 | "Se": 34, 340 | "Br": 35, 341 | "Kr": 36, 342 | "Rb": 37, 343 | "Sr": 38, 344 | "Y": 39, 345 | "Zr": 40, 346 | "Nb": 41, 347 | "Mo": 42, 348 | "Tc": 43, 349 | "Ru": 44, 350 | "Rh": 45, 351 | "Pd": 46, 352 | "Ag": 47, 353 | "Cd": 48, 354 | "In": 49, 355 | "Sn": 50, 356 | "Sb": 51, 357 | "Te": 52, 358 | "I": 53, 359 | "Xe": 54, 360 | "Cs": 55, 361 | "Ba": 56, 362 | "La": 57, 363 | "Ce": 58, 364 | "Pr": 59, 365 | "Nd": 60, 366 | "Pm": 61, 367 | "Sm": 62, 368 | "Eu": 63, 369 | "Gd": 64, 370 | "Tb": 65, 371 | "Dy": 66, 372 | "Ho": 67, 373 | "Er": 68, 374 | "Tm": 69, 375 | "Yb": 70, 376 | "Lu": 71, 377 | "Hf": 72, 378 | "Ta": 73, 379 | "W": 74, 380 | "Re": 75, 381 | "Os": 76, 382 | "Ir": 77, 383 | "Pt": 78, 384 | "Au": 79, 385 | "Hg": 80, 386 | "Tl": 81, 387 | "Pb": 82, 388 | "Bi": 83, 389 | "Po": 84, 390 | "At": 85, 391 | "Rn": 86, 392 | "Fr": 87, 393 | "Ra": 88, 394 | "Ac": 89, 395 | "Th": 90, 396 | "Pa": 91, 397 | "U": 92, 398 | "Np": 93, 399 | "Pu": 94, 400 | "Am": 95, 401 | "Cm": 96, 402 | "Bk": 97, 403 | "Cf": 98, 404 | "Es": 99, 405 | "Fm": 100, 406 | "Md": 101, 407 | "No": 102, 408 | "Lr": 103, 409 | "Rf": 104, 410 | "Db": 105, 411 | "Sg": 106, 412 | "Bh": 107, 413 | "Hs": 108, 414 | "Mt": 109, 415 | "Ds": 110, 416 | "Rg": 111, 417 | "Cn": 112, 418 | "Nh": 113, 419 | "Fl": 114, 420 | "Mc": 115, 421 | "Lv": 116, 422 | "Ts": 117, 423 | "Og": 118, 424 | } 425 | 426 | 427 | def _check_package( 428 | package: str, 429 | found_package: bool, 430 | extra_flag: str | None = None, 431 | ) -> None: 432 | """Raise an issue if a package was not found. 433 | 434 | Parameters 435 | ---------- 436 | package : str 437 | Package name. 438 | found_package : bool 439 | Whether the package was found or not. 440 | extra_flag : Optional[str] 441 | Extra flag of overreact that also installs the package. 442 | 443 | Raises 444 | ------ 445 | ImportError 446 | If the package was not found. 447 | 448 | Examples 449 | -------- 450 | >>> _check_package("i_do_exist", True) 451 | >>> _check_package("i_dont_exist", False) 452 | Traceback (most recent call last): 453 | ... 454 | ImportError: You must install `i_dont_exist` to use this functionality: `pip install i_dont_exist` 455 | >>> _check_package("rich", False, "cli") 456 | Traceback (most recent call last): 457 | ... 458 | ImportError: You must install `rich` to use this functionality: `pip install rich` (or `pip install "overreact[cli]"`) 459 | """ 460 | if not found_package: 461 | message = f"You must install `{package}` to use this functionality: `pip install {package}`" 462 | if extra_flag: 463 | message += f' (or `pip install "overreact[{extra_flag}]"`)' 464 | raise ImportError(message) 465 | 466 | 467 | # TODO(schneiderfelipe): what does this function returns for identifier="gas" 468 | # or identifier="solvent"? 469 | def _get_chemical( 470 | identifier, 471 | temperature=298.15, 472 | pressure=constants.atm, 473 | *args, 474 | **kwargs, 475 | ): 476 | """Wrap `thermo.Chemical`. 477 | 478 | This function is used for obtaining property values and requires the 479 | `thermo` package. 480 | 481 | All parameters are passed to `thermo.Chemical` and the returned object is 482 | returned. 483 | 484 | Parameters 485 | ---------- 486 | identifier : str 487 | temperature : array-like, optional 488 | Absolute temperature in Kelvin. 489 | pressure : array-like, optional 490 | Reference gas pressure. 491 | 492 | Examples 493 | -------- 494 | >>> from overreact import _constants as constants 495 | >>> water = _get_chemical("water", pressure=constants.atm) 496 | >>> water.name 497 | 'water' 498 | >>> water.Van_der_Waals_volume 499 | 0.0 500 | >>> water.Vm 501 | 1.807e-5 502 | >>> water.permittivity 503 | 78.4 504 | >>> water.omega 505 | 0.344 506 | >>> water.mul 507 | 0.0009 508 | """ 509 | _check_package("thermo", _found_thermo, "solvents") 510 | # TODO(schneiderfelipe): return a named tuple with only the required data. 511 | # TODO(schneiderfelipe): support logging the retrieval of data. 512 | # TODO(schneiderfelipe): test returned parameters. 513 | return Chemical(identifier, temperature, pressure, *args, **kwargs) 514 | 515 | 516 | def broaden_spectrum( 517 | x, 518 | x0, 519 | y0, 520 | distribution="gaussian", 521 | scale=1.0, 522 | fit_points=True, 523 | *args, 524 | **kwargs, 525 | ): 526 | """Broaden a point spectrum. 527 | 528 | Parameters 529 | ---------- 530 | x : array-like 531 | Points where to return the spectrum. 532 | x0, y0 : array-like 533 | Spectrum to broaden as x, y points. Must have same shape. 534 | distribution : scipy.stats continuous distribution or `str`, optional 535 | An object from scipy stats. Strings "gaussian"/"norm" (default) and 536 | "cauchy"/"lorentzian" are also accepted. 537 | scale : float 538 | Scale parameter of distribution. 539 | fit_points : bool, optional 540 | Whether to fit the point spectrum, i.e., match maxima of y. 541 | 542 | Returns 543 | ------- 544 | array-like 545 | Discretized continuum spectrum. 546 | 547 | Notes 548 | ----- 549 | All other values are passed to the pdf method of the distribution. 550 | 551 | Examples 552 | -------- 553 | >>> vibfreqs = np.array([81.44, 448.3, 573.57, 610.86, 700.53, 905.17, 554 | ... 1048.41, 1114.78, 1266.59, 1400.68, 1483.76, 555 | ... 1523.79, 1532.97, 1947.39, 3135.34, 3209.8, 556 | ... 3259.29, 3863.13]) # infrared for acetic acid 557 | >>> vibirs = np.array([0.636676, 5.216484, 43.002425, 45.491292, 107.5175, 558 | ... 3.292874, 41.673025, 13.081044, 213.36621, 559 | ... 41.210458, 107.200119, 14.974489, 11.980532, 560 | ... 342.170308, 0.532659, 1.875945, 2.625792, 561 | ... 79.794631]) # associated intensities 562 | >>> x = np.linspace(vibfreqs.min() - 100., 563 | ... vibfreqs.max() + 100., num=1000) 564 | >>> broaden_spectrum(x, vibfreqs, vibirs, scale=20.) # broadened spectrum 565 | array([2.37570938e-006, 6.30824800e-006, 1.60981742e-005, 3.94817964e-005, 566 | 9.30614047e-005, ..., 1.10015814e+002, ..., 3.42170308e+002, ..., 567 | 4.94825527e-003, 2.01758488e-003, 7.90612998e-004, 2.97747760e-004]) 568 | >>> broaden_spectrum(x, vibfreqs, vibirs, scale=20., fit_points=False) 569 | array([4.73279317e-008, 1.25670393e-007, 3.20701386e-007, 7.86540552e-007, 570 | 1.85393207e-006, ..., 1.14581680e+000, ..., 6.81657998e+000, ..., 571 | 9.85771618e-005, 4.01935188e-005, 1.57502758e-005, 5.93161175e-006]) 572 | 573 | """ 574 | if distribution in {"gaussian", "norm"}: 575 | distribution = norm 576 | elif distribution in {"lorentzian", "cauchy"}: 577 | distribution = cauchy 578 | 579 | s = np.sum( 580 | [ 581 | yp 582 | * distribution.pdf( 583 | x, 584 | xp, 585 | scale=scale, 586 | *args, 587 | **kwargs, 588 | ) 589 | for xp, yp in zip(x0, y0) 590 | ], 591 | axis=0, 592 | ) 593 | 594 | if fit_points: 595 | s_max = np.max(s) 596 | if s_max == 0.0: 597 | s_max = 1.0 598 | return s * np.max(y0) / s_max 599 | return s 600 | 601 | 602 | # https://stackoverflow.com/a/10016613 603 | def totuple(a): 604 | """Convert a numpy.array into nested tuples. 605 | 606 | Parameters 607 | ---------- 608 | a : array-like 609 | 610 | Returns 611 | ------- 612 | tuple 613 | 614 | Examples 615 | -------- 616 | >>> array = np.array(((2,2),(2,-2))) 617 | >>> totuple(array) 618 | ((2, 2), (2, -2)) 619 | """ 620 | # we don't touch some types, and this includes namedtuples 621 | if isinstance(a, (int, float, str, rx.Scheme)): 622 | return a 623 | 624 | with contextlib.suppress(AttributeError): 625 | a = a.tolist() 626 | 627 | try: 628 | return tuple(totuple(i) for i in a) 629 | except TypeError: 630 | return a 631 | 632 | 633 | def halton(num, dim=None, jump=1, cranley_patterson=True): 634 | """Calculate Halton low-discrepancy sequences. 635 | 636 | Those sequences are good performers for Quasi-Monte Carlo numerical 637 | integration for dimensions up to around 6. A Cranley-Patterson rotation is 638 | applied by default. The origin is also jumped over by default. 639 | 640 | Parameters 641 | ---------- 642 | num : int 643 | dim : int, optional 644 | jump : int, optional 645 | cranley_patterson : bool, optional 646 | 647 | Returns 648 | ------- 649 | array-like 650 | 651 | Examples 652 | -------- 653 | >>> halton(10, 3) # random # doctest: +SKIP 654 | array([[0.82232931, 0.38217312, 0.01170043], 655 | [0.57232931, 0.71550646, 0.21170043], 656 | [0.07232931, 0.1599509 , 0.41170043], 657 | [0.44732931, 0.49328423, 0.61170043], 658 | [0.94732931, 0.82661757, 0.85170043], 659 | [0.69732931, 0.27106201, 0.05170043], 660 | [0.19732931, 0.60439534, 0.25170043], 661 | [0.38482931, 0.93772868, 0.45170043], 662 | [0.88482931, 0.08587683, 0.65170043], 663 | [0.63482931, 0.41921016, 0.89170043]]) 664 | >>> halton(10, 3, cranley_patterson=False) 665 | array([[0.5 , 0.33333333, 0.2 ], 666 | [0.25 , 0.66666667, 0.4 ], 667 | [0.75 , 0.11111111, 0.6 ], 668 | [0.125 , 0.44444444, 0.8 ], 669 | [0.625 , 0.77777778, 0.04 ], 670 | [0.375 , 0.22222222, 0.24 ], 671 | [0.875 , 0.55555556, 0.44 ], 672 | [0.0625 , 0.88888889, 0.64 ], 673 | [0.5625 , 0.03703704, 0.84 ], 674 | [0.3125 , 0.37037037, 0.08 ]]) 675 | 676 | Cranley-Patterson rotations can improve Quasi-Monte Carlo integral 677 | estimates done with Halton sequences. Compare the following estimates of 678 | the integral of x between 0 and 1, which is exactly 0.5: 679 | 680 | >>> np.mean(halton(100, cranley_patterson=False)) 681 | 0.489921875 682 | >>> I = [np.mean(halton(100)) for i in range(1000)] 683 | >>> np.mean(I), np.var(I) < 0.00004 684 | (0.500, True) 685 | 686 | Now the integral of x**2 between 0 and 1, which is exactly 1/3: 687 | 688 | >>> np.mean(halton(100, cranley_patterson=False)**2) 689 | 0.3222149658203125 690 | >>> I = [np.mean(halton(100)**2) for i in range(1000)] 691 | >>> np.mean(I), np.var(I) < 0.00004 692 | (0.333, True) 693 | 694 | >>> x = halton(1500) 695 | >>> np.mean(x) # estimate of the integral of x between 0 and 1 696 | 0.50 697 | >>> np.mean(x**2) # estimate of the integral of x**2 between 0 and 1 698 | 0.33 699 | """ 700 | actual_dim = 1 if dim is None else dim 701 | 702 | res = np.array( 703 | [ 704 | [_vdc(i, b) for i in range(jump, jump + num)] 705 | for b in _first_primes(actual_dim) 706 | ], 707 | ) 708 | 709 | if cranley_patterson: 710 | res = (res + np.random.rand(actual_dim, 1)) % 1.0 711 | if dim is None: 712 | return res.reshape((num,)) 713 | return res.T 714 | 715 | 716 | def _first_primes(size): 717 | """Help haltonspace. 718 | 719 | Examples 720 | -------- 721 | >>> _first_primes(1) 722 | [2] 723 | >>> _first_primes(4) 724 | [2, 3, 5, 7] 725 | >>> _first_primes(10) 726 | [2, 3, 5, 7, 11, 13, 17, 19, 23, 29] 727 | """ 728 | 729 | def _is_prime(num): 730 | """Check if num is prime.""" 731 | return all(num % i != 0 for i in range(2, int(np.sqrt(num)) + 1)) 732 | 733 | primes = [2] 734 | p = 3 735 | while len(primes) < size: 736 | if _is_prime(p): 737 | primes.append(p) 738 | p += 2 739 | return primes 740 | 741 | 742 | @cache(maxsize=1000000) 743 | def _vdc(n, b=2): 744 | """Help haltonspace.""" 745 | res, denom = 0, 1 746 | while n: 747 | denom *= b 748 | n, remainder = divmod(n, b) 749 | res += remainder / denom 750 | return res 751 | -------------------------------------------------------------------------------- /overreact/rates.py: -------------------------------------------------------------------------------- 1 | """Module dedicated to the calculation of reaction rate constants.""" 2 | 3 | from __future__ import annotations 4 | 5 | __all__ = ["eyring"] 6 | 7 | 8 | import logging 9 | 10 | import numpy as np 11 | 12 | import overreact as rx 13 | from overreact import _constants as constants 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | @np.vectorize 19 | def liquid_viscosity(id, temperature=298.15, pressure=constants.atm): 20 | """Dynamic viscosity of a solvent. 21 | 22 | This function requires the `thermo` package for obtaining property values. 23 | 24 | Parameters 25 | ---------- 26 | id : str, 27 | temperature : array-like, optional 28 | Absolute temperature in Kelvin. 29 | pressure : array-like, optional 30 | Reference gas pressure. 31 | 32 | Returns 33 | ------- 34 | float 35 | Dynamic viscosity in SI units (Pa*s). 36 | 37 | Examples 38 | -------- 39 | >>> liquid_viscosity("water", temperature=299.26) 40 | 8.90e-4 41 | """ 42 | return rx._misc._get_chemical(id, temperature, pressure).mul 43 | 44 | 45 | # TODO(schneiderfelipe): log the calculated diffusional reaction rate limit. 46 | def smoluchowski( 47 | radii, 48 | viscosity=None, 49 | reactive_radius=None, 50 | temperature=298.15, 51 | pressure=constants.atm, 52 | mutual_diff_coef=None, 53 | ): 54 | r"""Calculate irreversible diffusion-controlled reaction rate constant. 55 | 56 | PRETTY MUCH EVERYTHING HERE IS OPTIONAL! 57 | 58 | Parameters 59 | ---------- 60 | radii : array-like, optional 61 | viscosity : float, str or callable, optional 62 | reactive_radius : float, optional 63 | temperature : array-like, optional 64 | Absolute temperature in Kelvin. 65 | pressure : array-like, optional 66 | Reference gas pressure. 67 | mutual_diff_coef : array-like, optional 68 | 69 | Returns 70 | ------- 71 | float 72 | 73 | Notes 74 | ----- 75 | This is a work in progress! 76 | 77 | TODO(schneiderfelipe): THERE ARE DOUBTS ABOUT HOW TO SELECT 78 | reactive_radius. doi:10.1002/jcc.23409 HELPS CLARIFY SOME ASPECTS BUT 79 | THERE'S STILL PROBLEMS. I BELIEVE THERE'S A RELATIONSHIP BETWEEN THE 80 | IMAGINARY FREQUENCY AND HOW FAR ATOMS MOVE CLOSE TO REACT, WHICH MIGHT 81 | GIVE SOME LIGHT. IN ANY CASE, I BELIEVE THAT THIS VALUE SHOULD BE LARGER 82 | THAN A CHARACTERISTIC DISTANCE IN THE TRANSITION STATE, SHOULD BE LARGER 83 | FOR LIGHTER GROUPS BEING TRANSFERRED (OR BETTER, ELECTRONS), BUT SHOULD BE 84 | SMALLER THAN A CHARACTERISTIC DISTANCE IN THE REACTIVE COMPLEX. THIS GIVES 85 | A RANGE TO START WORKING WITH. 86 | 87 | Below I delineate a temptive algorithm: 88 | 1. superpose reactant A onto TS and RC 89 | 2. superpose reactant B onto TS and RC 90 | 3. identify the closest atoms of A/B in TS 91 | 4. measure the distance of the closest atoms in RC 92 | 93 | Examples 94 | -------- 95 | >>> radii = np.array([2.59, 2.71]) * constants.angstrom 96 | >>> smoluchowski(radii, reactive_radius=2.6 * constants.angstrom, 97 | ... viscosity=8.91e-4) / constants.liter 98 | 3.6e9 99 | >>> smoluchowski(radii, "water", reactive_radius=2.6 * constants.angstrom) \ 100 | ... / constants.liter 101 | 3.6e9 102 | >>> smoluchowski(radii, viscosity=8.91e-4) / constants.liter 103 | 3.7e9 104 | """ 105 | radii = np.asarray(radii) 106 | temperature = np.asarray(temperature) 107 | 108 | if mutual_diff_coef is None: 109 | if callable(viscosity): 110 | viscosity = viscosity(temperature) 111 | elif isinstance(viscosity, str): 112 | viscosity = liquid_viscosity(viscosity, temperature, pressure) 113 | mutual_diff_coef = ( 114 | constants.k * temperature / (6.0 * np.pi * np.asarray(viscosity)) 115 | ) * np.sum(1.0 / radii) 116 | 117 | if reactive_radius is None: 118 | # NOTE(schneiderfelipe): not sure if I should divide by two here, but 119 | # it works. My guess is that there is some confusion between contact 120 | # distances (which are basically sums of two radii) and sums of pairs 121 | # of radii. 122 | reactive_radius = np.sum(radii) / 2 123 | 124 | return 4.0 * np.pi * mutual_diff_coef * reactive_radius * constants.N_A 125 | 126 | 127 | def collins_kimball(k_tst, k_diff): 128 | """Calculate reaction rate constant inclusing diffusion effects. 129 | 130 | This implementation is based on doi:10.1016/0095-8522(49)90023-9. 131 | 132 | Examples 133 | -------- 134 | >>> collins_kimball(2.3e7, 3.6e9) 135 | 2.3e7 136 | """ 137 | return k_tst * k_diff / (k_tst + k_diff) 138 | 139 | 140 | def convert_rate_constant( 141 | val, 142 | new_scale, 143 | old_scale="l mol-1 s-1", 144 | molecularity=1, 145 | temperature=298.15, 146 | pressure=constants.atm, 147 | ): 148 | r"""Convert a reaction rate constant between common units. 149 | 150 | The reference paper used for developing this function is doi:10.1021/ed046p54. 151 | 152 | Parameters 153 | ---------- 154 | val : array-like 155 | Rate constant to convert. 156 | new_scale : str 157 | New units. Possible values are "cm3 mol-1 s-1", "l mol-1 s-1", 158 | "m3 mol-1 s-1", "cm3 particle-1 s-1", "mmHg-1 s-1" and "atm-1 s-1". 159 | old_scale : str, optional 160 | Old units. Possible values are the same as for `new_scale`. 161 | molecularity : array-like, optional 162 | Reaction order, i.e., number of molecules that come together to react. 163 | temperature : array-like, optional 164 | Absolute temperature in Kelvin. 165 | pressure : array-like, optional 166 | Reference gas pressure. 167 | 168 | Returns 169 | ------- 170 | array-like 171 | 172 | Raises 173 | ------ 174 | ValueError 175 | If either `old_scale` or `new_scale` are not recognized. 176 | 177 | Notes 178 | ----- 179 | Some symbols are accepted as alternatives in "new_scale" and "old_scale": 180 | "M-1", "ml" and "torr-1" are understood as "l mol-1", "cm3" and "mmHg-1", 181 | respectively. 182 | 183 | Examples 184 | -------- 185 | >>> convert_rate_constant(1.0, "cm3 particle-1 s-1", "m3 mol-1 s-1", 186 | ... molecularity=2) 187 | 0.1660e-17 188 | 189 | If `old_scale` is not given, it defaults to ``"l mol-1 s-1"``: 190 | 191 | >>> convert_rate_constant(1.0, "m3 mol-1 s-1", molecularity=2) 192 | 0.001 193 | 194 | There are many options for `old_scale` and `new_scale`: 195 | 196 | >>> convert_rate_constant(1.0, "m3 mol-1 s-1", "atm-1 s-1", 197 | ... molecularity=2, temperature=1.0) 198 | 8.21e-5 199 | >>> convert_rate_constant(1.0, "cm3 particle-1 s-1", "atm-1 s-1", 200 | ... molecularity=2, temperature=1.0) 201 | 13.63e-23 202 | >>> convert_rate_constant(1e3, "l mol-1 s-1", "atm-1 s-1", 203 | ... molecularity=2, temperature=273.15) 204 | 22414. 205 | 206 | If `old_scale` is the same as `new_scale`, or if the molecularity is one, 207 | the received value is returned: 208 | 209 | >>> convert_rate_constant(12345, "atm-1 s-1", "atm-1 s-1", molecularity=2) 210 | 12345 211 | >>> convert_rate_constant(67890, "l mol-1 s-1", "atm-1 s-1", molecularity=1) 212 | 67890 213 | 214 | Below are some examples regarding some accepted alternative symbols: 215 | 216 | >>> convert_rate_constant(1.0, "M-1 s-1", molecularity=2) \ 217 | ... == convert_rate_constant(1.0, "l mol-1 s-1", molecularity=2) 218 | True 219 | >>> convert_rate_constant(1.0, "ml mol-1 s-1", molecularity=2) \ 220 | ... == convert_rate_constant(1.0, "cm3 mol-1 s-1", molecularity=2) 221 | True 222 | >>> convert_rate_constant(1.0, "torr-1 s-1", molecularity=2) \ 223 | ... == convert_rate_constant(1.0, "mmHg-1 s-1", molecularity=2) 224 | True 225 | """ 226 | for alt, ref in [("M-1", "l mol-1"), ("ml", "cm3"), ("torr-1", "mmHg-1")]: 227 | new_scale, old_scale = new_scale.replace(alt, ref), old_scale.replace(alt, ref) 228 | 229 | # no need to convert if same units or if molecularity is one 230 | if old_scale == new_scale or np.all(molecularity == 1): 231 | return val 232 | 233 | # we first convert to l mol-1 s-1 234 | if old_scale == "cm3 mol-1 s-1": 235 | factor = 1.0 / constants.kilo 236 | elif old_scale == "l mol-1 s-1": 237 | factor = 1.0 238 | elif old_scale == "m3 mol-1 s-1": 239 | factor = constants.kilo 240 | elif old_scale == "cm3 particle-1 s-1": 241 | factor = constants.N_A / constants.kilo 242 | elif old_scale == "mmHg-1 s-1": 243 | factor = ( 244 | rx.thermo.molar_volume(temperature, pressure) 245 | * pressure 246 | * constants.kilo 247 | / constants.torr 248 | ) 249 | elif old_scale == "atm-1 s-1": 250 | factor = rx.thermo.molar_volume(temperature, pressure) * constants.kilo 251 | else: 252 | msg = f"old unit not recognized: {old_scale}" 253 | raise ValueError(msg) 254 | 255 | # now we convert l mol-1 s-1 to what we need 256 | if new_scale == "cm3 mol-1 s-1": 257 | factor *= constants.kilo 258 | elif new_scale == "l mol-1 s-1": 259 | factor *= 1.0 260 | elif new_scale == "m3 mol-1 s-1": 261 | factor *= 1.0 / constants.kilo 262 | elif new_scale == "cm3 particle-1 s-1": 263 | factor *= constants.kilo / constants.N_A 264 | elif new_scale == "mmHg-1 s-1": 265 | factor *= constants.torr / ( 266 | rx.thermo.molar_volume(temperature, pressure) * pressure * constants.kilo 267 | ) 268 | elif new_scale == "atm-1 s-1": 269 | factor *= 1.0 / (rx.thermo.molar_volume(temperature, pressure) * constants.kilo) 270 | else: 271 | msg = f"new unit not recognized: {new_scale}" 272 | raise ValueError(msg) 273 | 274 | factor **= molecularity - 1 275 | logger.info( 276 | f"conversion factor ({old_scale} to {new_scale}) = {factor}", 277 | ) 278 | return val * factor 279 | 280 | 281 | def eyring( 282 | delta_freeenergy: float | np.ndarray, 283 | molecularity: int | None = None, 284 | temperature: float | np.ndarray = 298.15, 285 | pressure: float = constants.atm, 286 | volume: float | None = None, 287 | ): 288 | r"""Calculate a reaction rate constant. 289 | 290 | This function uses the `Eyring-Evans-Polanyi equation 291 | `_ from `transition state 292 | theory `_: 293 | 294 | .. math:: 295 | k(T) = \frac{k_\text{B} T}{h} K^\ddagger 296 | = \frac{k_\text{B} T}{h} 297 | \exp\left(-\frac{\Delta^\ddagger G^\circ}{R T}\right) 298 | 299 | where :math:`h` is Planck's constant, :math:`k_\text{B}` is Boltzmann's 300 | constant and :math:`T` is the absolute temperature. 301 | 302 | Parameters 303 | ---------- 304 | delta_freeenergy : array-like 305 | Delta Gibbs activation free energies. **This assumes values were already 306 | corrected for a one molar reference state (if applicable).** 307 | molecularity : array-like, optional 308 | Reaction order, i.e., number of molecules that come together to react. 309 | If set, this is used to calculate `delta_moles` for 310 | `equilibrium_constant`, which effectively calculates a solution 311 | equilibrium constant between reactants and the transition state for 312 | gas phase data. **You should set this to `None` if your free energies 313 | were already adjusted for solution Gibbs free energies.** 314 | temperature : array-like, optional 315 | Absolute temperature in Kelvin. 316 | pressure : array-like, optional 317 | Reference gas pressure. 318 | volume : float, optional 319 | Molar volume. This is passed on to `equilibrium_constant`. 320 | 321 | Returns 322 | ------- 323 | k : array-like 324 | Reaction rate constant(s). By giving energies in one molar reference 325 | state, returned units are then accordingly given, e.g. "l mol-1 s-1" 326 | if second-order, etc. 327 | 328 | Notes 329 | ----- 330 | This function uses `equilibrium_constant` internally to calculate the 331 | equilibrium constant between reactants and the transition state. 332 | 333 | Examples 334 | -------- 335 | The following are examples from 336 | [Thermochemistry in Gaussian](https://gaussian.com/thermo/), in which the 337 | kinetic isotope effect of a bimolecular reaction is analyzed: 338 | 339 | >>> eyring(17.26 * constants.kcal) 340 | array([1.38]) 341 | >>> eyring(18.86 * constants.kcal) 342 | array([0.093]) 343 | 344 | It is well known that, at room temperature, if you "decrease" a reaction 345 | barrier by 1.4 kcal/mol, the reaction becomes around ten times faster: 346 | 347 | >>> dG = np.random.uniform(1.0, 100.0) * constants.kcal 348 | >>> eyring(dG - 1.4 * constants.kcal) / eyring(dG) 349 | array([10.]) 350 | 351 | A similar relationship is found for a twofold increase in speed and a 352 | 0.4 kcal/mol decrease in the reaction barrier (again, at room 353 | temperature): 354 | 355 | >>> eyring(dG - 0.4 * constants.kcal) / eyring(dG) 356 | array([2.0]) 357 | 358 | """ 359 | temperature = np.asarray(temperature) 360 | delta_freeenergy = np.asarray(delta_freeenergy) 361 | 362 | delta_moles = None 363 | if molecularity is not None: 364 | delta_moles = 1 - np.asarray(molecularity) 365 | 366 | return ( 367 | rx.thermo.equilibrium_constant( 368 | delta_freeenergy, 369 | delta_moles, 370 | temperature, 371 | pressure, 372 | volume, 373 | ) 374 | * constants.k 375 | * temperature 376 | / constants.h 377 | ) 378 | -------------------------------------------------------------------------------- /overreact/simulate.py: -------------------------------------------------------------------------------- 1 | """Module dedicated to the time simulation of reaction models. 2 | 3 | Here are functions that calculate reaction rates as well, which is needed for 4 | the time simulations. 5 | """ 6 | 7 | # TODO(schneiderfelipe): type this module. 8 | from __future__ import annotations 9 | 10 | __all__ = ["get_y", "get_dydt", "get_fixed_scheme"] 11 | 12 | 13 | import logging 14 | 15 | import numpy as np 16 | from scipy.integrate import solve_ivp 17 | from scipy.optimize import minimize_scalar 18 | 19 | import overreact as rx 20 | from overreact import _constants as constants 21 | from overreact._misc import _found_jax 22 | 23 | # Energetic advantage given to half-equilibrium reactions. 24 | # 25 | # Basically, the higher this value, the faster the equilibria will relax, 26 | # but the system will be less stable due to stiffness, so it will require 27 | # tighter convergence thresholds for mass conservation to be satisfied. 28 | # 29 | # **The value below was chosen after some experimentation 30 | # with a rather complex model.** This gives rise to a speedup of over a factor 31 | # of eight to equilibria, which seems reasonable. **This choice was made 32 | # using the standard ODE parameters (rtol=1e-3, atol=1e-6).** 33 | # 34 | # TODO(schneiderfelipe): this should probably be exposed to the user and use the actual simulation temperature. 35 | EF = np.exp(1.25 * constants.kcal / (constants.R * 298.15)) 36 | 37 | 38 | logger = logging.getLogger(__name__) 39 | 40 | 41 | if _found_jax: 42 | import jax.numpy as jnp 43 | from jax import jacfwd, jit 44 | from jax import config 45 | 46 | config.update("jax_enable_x64", True) 47 | else: 48 | logger.warning( 49 | "Install JAX to have just-in-time compilation: " 50 | 'pip install jax (or pip install "overreact[fast]")', 51 | ) 52 | jnp = np 53 | 54 | 55 | # TODO(schneiderfelipe): allow y0 to be a dict-like object. 56 | def get_y( 57 | dydt, 58 | y0, 59 | t_span=None, 60 | method="RK23", 61 | max_step=np.inf, 62 | first_step=np.finfo(np.float64).eps, 63 | rtol=1e-3, 64 | atol=1e-6, 65 | max_time=1 * 60 * 60, 66 | ): 67 | """Simulate a reaction scheme from its rate function. 68 | 69 | This function provides two functions that calculate the concentrations and 70 | the rates of formation at any point in time for any compound. It does that 71 | by solving an initial value problem (IVP) through scipy's ``solve_ivp`` 72 | under the hood. 73 | 74 | Parameters 75 | ---------- 76 | dydt : callable 77 | Right-hand side of the system. 78 | y0 : array-like 79 | Initial state. 80 | t_span : array-like, optional 81 | Interval of integration (t0, tf). The solver starts with t=t0 and 82 | integrates until it reaches t=tf. If not given, a conservative value 83 | is chosen based on the system at hand (the method of choice works for 84 | any zeroth-, first- or second-order reactions). 85 | method : str, optional 86 | Integration method to use. See `scipy.integrate.solve_ivp` for details. 87 | Kinetics problems are very often stiff and, as such, "RK23" and "RK45" may be 88 | unsuited. "LSODA", "BDF", and "Radau" are worth a try if things go bad. 89 | max_step : float, optional 90 | Maximum step to be performed by the integrator. 91 | Defaults to half the total time span. 92 | first_step : float, optional 93 | First step size. 94 | Defaults to half the maximum step, or `np.finfo(np.float64).eps`, 95 | whichever is smallest. 96 | rtol, atol : array-like, optional 97 | See `scipy.integrate.solve_ivp` for details. 98 | max_time : float, optional 99 | If `t_span` is not given, an interval will be estimated, but it can't 100 | be larger than this parameter. 101 | 102 | Returns 103 | ------- 104 | y, r : callable 105 | Concentrations and reaction rates as functions of time. The y object 106 | is an OdeSolution and stores attributes t_min and t_max. 107 | 108 | 109 | Examples 110 | -------- 111 | >>> import numpy as np 112 | >>> import overreact as rx 113 | 114 | A toy simulation can be performed in just two lines: 115 | 116 | >>> scheme = rx.parse_reactions("A <=> B") 117 | >>> y, r = get_y(get_dydt(scheme, np.array([1, 1])), y0=[1, 0]) 118 | 119 | The `y` object stores information about the simulation time, which can be 120 | used to produce a suitable vector of timepoints for, e.g., plotting: 121 | 122 | >>> y.t_min, y.t_max 123 | (0.0, 3.0) 124 | >>> t = np.linspace(y.t_min, y.t_max) 125 | >>> t 126 | array([0. , 0.06122449, ..., 2.93877551, 3. ]) 127 | 128 | Both `y` and `r` can be used to check concentrations and rates in any 129 | point in time. In particular, both are vectorized: 130 | 131 | >>> y(t) 132 | array([[1. , ...], 133 | [0. , ...]]) 134 | >>> r(t) 135 | array([[-1. , ...], 136 | [ 1. , ...]]) 137 | """ 138 | # TODO(schneiderfelipe): raise a meaningful error when y0 has the wrong shape. 139 | y0 = np.asarray(y0) 140 | 141 | if t_span is None: 142 | # We defined alpha such that 1.0 - alpha is an (under)estimate of the extend 143 | # to which the reaction is simulated. And then we apply the Pareto principle. 144 | alpha = 0.2 145 | n_halflives = np.ceil(-np.log(alpha) / np.log(2)) 146 | 147 | halflife_estimate = 1.0 148 | if hasattr(dydt, "k"): 149 | halflife_estimate = np.max( 150 | [ 151 | np.max(y0) / 2.0, # zeroth-order halflife 152 | np.log(2.0), # first-order halflife 153 | 1.0 / np.min(y0[np.nonzero(y0)]), # second-order halflife 154 | ], 155 | ) / np.min(dydt.k) 156 | logger.info(f"largest halflife guess = {halflife_estimate} s") 157 | 158 | t_span = [0.0, min(n_halflives * halflife_estimate, max_time)] 159 | logger.info(f"simulation time span = {t_span} s") 160 | 161 | max_step = np.min([max_step, (t_span[1] - t_span[0]) / 2.0]) 162 | logger.warning(f"max step = {max_step} s") 163 | 164 | first_step = np.min([first_step, max_step / 2.0]) 165 | logger.warning(f"first step = {first_step} s") 166 | 167 | jac = None 168 | if hasattr(dydt, "jac"): 169 | jac = dydt.jac # noqa: F841 170 | 171 | logger.warning(f"@t = \x1b[94m{0:10.3f} \x1b[ms\x1b[K") 172 | res = solve_ivp( 173 | dydt, 174 | t_span, 175 | y0, 176 | method=method, 177 | dense_output=True, 178 | max_step=max_step, 179 | first_step=first_step, 180 | rtol=rtol, 181 | atol=atol, 182 | # jac=jac, # noqa: ERA001 183 | ) 184 | logger.warning(res) 185 | y = res.sol 186 | 187 | def r(t): 188 | # TODO(schneiderfelipe): this is probably not the best way to 189 | # vectorize a function! 190 | try: 191 | return np.array([dydt(_t, _y) for _t, _y in zip(t, y(t).T)]).T 192 | except TypeError: 193 | return dydt(t, y(t)) 194 | 195 | return y, r 196 | 197 | 198 | def get_dydt(scheme, k, ef=EF): 199 | """Generate a rate function that models a reaction scheme. 200 | 201 | Parameters 202 | ---------- 203 | scheme : Scheme 204 | A descriptor of the reaction scheme. 205 | Mostly likely, this comes from a parsed model input file. 206 | See `overreact.io.parse_model`. 207 | k : array-like 208 | Reaction rate constant(s). Units match the concentration units given to 209 | the returned function ``dydt``. 210 | ef : float, optional 211 | Equilibrium factor. This is a parameter that can be used to scale the 212 | reaction rates associated to half-equilibrium reactions such that they 213 | are faster than the other reactions. 214 | 215 | Returns 216 | ------- 217 | dydt : callable 218 | Reaction rate function. The actual reaction rate constants employed 219 | are stored in the attribute `k` of the returned function. If JAX is 220 | available, the attribute `jac` will hold the Jacobian function of 221 | `dydt`. 222 | 223 | Notes 224 | ----- 225 | The returned function is suited to be used by ODE solvers such as 226 | `scipy.integrate.solve_ivp` or the older `scipy.integrate.ode` (see 227 | examples below). This is actually what the function `get_y` from the 228 | current module does. 229 | 230 | Examples 231 | -------- 232 | >>> import numpy as np 233 | >>> import overreact as rx 234 | 235 | >>> scheme = rx.parse_reactions("A <=> B") 236 | >>> dydt = get_dydt(scheme, np.array([1, 1])) 237 | >>> dydt(0.0, np.array([1., 1.])) 238 | Array([0., 0.], ...) 239 | 240 | If available, JAX is used for JIT compilation. This will make `dydt` 241 | complain if given lists instead of numpy arrays. So stick to the safer, 242 | faster side as above. 243 | 244 | The actually used reaction rate constants can be inspected with the `k` 245 | attribute of `dydt`: 246 | 247 | >>> dydt.k 248 | Array([1., 1.], ...) 249 | 250 | If JAX is available, the Jacobian function will be available as 251 | `dydt.jac`: 252 | 253 | >>> dydt.jac(0.0, np.array([1., 1.])) 254 | Array([[-1., 1.], 255 | [ 1., -1.]], ...) 256 | 257 | """ 258 | scheme = rx.core._check_scheme(scheme) 259 | A = jnp.asarray(scheme.A) 260 | M = jnp.where(A > 0, 0, -A).T 261 | k_adj = _adjust_k(scheme, k, ef=ef) 262 | 263 | def _dydt(_t, y): 264 | r = k_adj * jnp.prod(jnp.power(y, M), axis=1) 265 | return jnp.dot(A, r) 266 | 267 | if _found_jax: 268 | # Using JAX for JIT compilation is much faster. 269 | _dydt = jit(_dydt) 270 | 271 | # NOTE(schneiderfelipe): the following function is defined 272 | # such that _jac(t, y)[i, j] == d f_i / d y_j, 273 | # with shape of (n_compounds, n_compounds). 274 | def _jac(t, y): 275 | logger.warning(f"\x1b[A@t = \x1b[94m{t:10.3f} \x1b[ms\x1b[K") 276 | return jacfwd(lambda _y: _dydt(t, _y))(y) 277 | 278 | _dydt.jac = _jac 279 | 280 | _dydt.k = k_adj 281 | return _dydt 282 | 283 | 284 | def _adjust_k(scheme, k, ef=EF): 285 | """Adjust reaction rate constants so that equilibria are equilibria. 286 | 287 | Parameters 288 | ---------- 289 | scheme : Scheme 290 | A descriptor of the reaction scheme. 291 | Mostly likely, this comes from a parsed model input file. 292 | See `overreact.io.parse_model`. 293 | k : array-like 294 | Reaction rate constant(s). Units match the concentration units given to 295 | the returned function ``dydt``. 296 | ef : float, optional 297 | Equilibrium factor. This is a parameter that can be used to scale the 298 | reaction rates associated to half-equilibrium reactions such that they 299 | are faster than the other reactions. 300 | 301 | Returns 302 | ------- 303 | k : array-like 304 | Adjusted constants. 305 | 306 | Examples 307 | -------- 308 | >>> import overreact as rx 309 | 310 | >>> scheme = rx.parse_reactions("A <=> B") 311 | >>> _adjust_k(scheme, [1, 1]) 312 | Array([1., 1.], ...) 313 | 314 | >>> model = rx.parse_model("data/ethane/B97-3c/model.k") 315 | >>> _adjust_k(model.scheme, 316 | ... rx.get_k(model.scheme, model.compounds)) 317 | Array([8.16880917e+10], ...) 318 | 319 | >>> model = rx.parse_model("data/acetate/Orca4/model.k") 320 | >>> _adjust_k(model.scheme, 321 | ... rx.get_k(model.scheme, model.compounds)) 322 | Array([1.00000000e+00, 5.74491548e+04, 1.61152010e+07, 323 | 1.00000000e+00, 1.55695112e+56, 1.00000000e+00], ...) 324 | 325 | >>> model = rx.parse_model( 326 | ... "data/perez-soto2020/RI/BLYP-D4/def2-TZVP/model.k" 327 | ... ) 328 | >>> _adjust_k(model.scheme, 329 | ... rx.get_k(model.scheme, model.compounds)) 330 | Array([...], ...) 331 | 332 | """ 333 | scheme = rx.core._check_scheme(scheme) 334 | is_half_equilibrium = np.asarray(scheme.is_half_equilibrium) 335 | k = np.asarray(k, dtype=np.float64).copy() 336 | 337 | if np.any(is_half_equilibrium): 338 | # at least one equilibrium 339 | if np.any(~is_half_equilibrium): 340 | # at least one true reaction 341 | 342 | k_slowest_equil = k[is_half_equilibrium].min() 343 | k_fastest_react = k[~is_half_equilibrium].max() 344 | adjustment = ef * (k_fastest_react / k_slowest_equil) 345 | 346 | k[is_half_equilibrium] *= adjustment 347 | logger.warning(f"equilibria adjustment = {adjustment}") 348 | 349 | k_slowest_equil = k[is_half_equilibrium].min() 350 | k_fastest_react = k[~is_half_equilibrium].max() 351 | logger.warning( 352 | f"slow eq. / fast r. = {k_slowest_equil / k_fastest_react}", 353 | ) 354 | else: 355 | # only equilibria 356 | 357 | # set the smallest one to be equal to one 358 | k = k / k.min() 359 | 360 | return jnp.asarray(k) 361 | 362 | 363 | def get_fixed_scheme(scheme, k, fixed_y0): 364 | """Generate an alternative scheme with some concentrations fixed. 365 | 366 | This function returns data that allow the microkinetic simulation of a 367 | reaction network under constraints, namely when some compounds have fixed 368 | concentrations. This works by 1. removing all references to the fixed 369 | compounds and by 2. properly multiplying the reaction rate constants by 370 | the respective concentrations. 371 | 372 | Parameters 373 | ---------- 374 | scheme : Scheme 375 | A descriptor of the reaction scheme. 376 | Mostly likely, this comes from a parsed model input file. 377 | See `overreact.io.parse_model`. 378 | k : array-like 379 | Reaction rate constant(s). Units match the concentration units given to 380 | the returned function ``dydt``. 381 | fixed_y0 : dict-like 382 | Fixed initial state. Units match the concentration units given to 383 | the returned function ``dydt``. 384 | 385 | Returns 386 | ------- 387 | scheme : Scheme 388 | Associated reaction scheme with all references to fixed compounds 389 | removed. 390 | k : array-like 391 | Associated (effective) reaction rate constants that model the fixed 392 | concentrations. 393 | 394 | Notes 395 | ----- 396 | Keep in mind that when a compound get its concentration fixed, the 397 | reaction scheme no longer conserves matter. You can think of it as 398 | reacting close to an infinite source of the compound, but it accumulates 399 | in the milleu at the given concentration. 400 | 401 | Examples 402 | -------- 403 | >>> import numpy as np 404 | >>> import overreact as rx 405 | 406 | Equilibria under a specific pH can be easily modeled: 407 | 408 | >>> pH = 7 409 | >>> scheme = rx.parse_reactions("AH <=> A- + H+") 410 | >>> k = np.array([1, 1]) 411 | >>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH}) 412 | >>> scheme 413 | Scheme(compounds=('AH', 'A-'), 414 | reactions=('AH -> A-', 415 | 'A- -> AH'), 416 | is_half_equilibrium=(True, True), 417 | A=((-1.0, 1.0), 418 | (1.0, -1.0)), 419 | B=((-1.0, 0.0), 420 | (1.0, 0.0))) 421 | >>> k 422 | array([1.e+00, 1.e-07]) 423 | 424 | It is also possible to model the fixed activity of a solvent, for 425 | instance: 426 | 427 | >>> scheme = rx.parse_reactions("A + 2H2O -> B") 428 | >>> k = np.array([1.0]) 429 | >>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H2O": 55.6}) 430 | >>> scheme 431 | Scheme(compounds=('A', 'B'), 432 | reactions=('A -> B',), 433 | is_half_equilibrium=(False,), 434 | A=((-1.0,), 435 | (1.0,)), 436 | B=((-1.0,), 437 | (1.0,))) 438 | >>> k 439 | array([3091.36]) 440 | 441 | Multiple reactions work fine, see both examples below: 442 | 443 | >>> pH = 12 444 | >>> scheme = rx.parse_reactions("B <- AH <=> A- + H+") 445 | >>> k = np.array([10.0, 1, 1]) 446 | >>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH}) 447 | >>> scheme 448 | Scheme(compounds=('AH', 'B', 'A-'), 449 | reactions=('AH -> B', 450 | 'AH -> A-', 451 | 'A- -> AH'), 452 | is_half_equilibrium=(False, True, True), 453 | A=((-1.0, -1.0, 1.0), 454 | (1.0, 0.0, 0.0), 455 | (0.0, 1.0, -1.0)), 456 | B=((-1.0, -1.0, 0.0), 457 | (1.0, 0.0, 0.0), 458 | (0.0, 1.0, 0.0))) 459 | >>> k 460 | array([1.e+01, 1.e+00, 1.e-12]) 461 | 462 | >>> pH = 2 463 | >>> scheme = rx.parse_reactions(["AH <=> A- + H+", "B- + H+ <=> BH"]) 464 | >>> k = np.array([1, 1, 2, 2]) 465 | >>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH}) 466 | >>> scheme 467 | Scheme(compounds=('AH', 'A-', 'B-', 'BH'), 468 | reactions=('AH -> A-', 469 | 'A- -> AH', 470 | 'B- -> BH', 471 | 'BH -> B-'), 472 | is_half_equilibrium=(True, True, True, True), 473 | A=((-1.0, 1.0, 0.0, 0.0), 474 | (1.0, -1.0, 0.0, 0.0), 475 | (0.0, 0.0, -1.0, 1.0), 476 | (0.0, 0.0, 1.0, -1.0)), 477 | B=((-1.0, 0.0, 0.0, 0.0), 478 | (1.0, 0.0, 0.0, 0.0), 479 | (0.0, 0.0, -1.0, 0.0), 480 | (0.0, 0.0, 1.0, 0.0))) 481 | >>> k 482 | array([1. , 0.01, 0.02, 2. ]) 483 | 484 | Multiple fixed compounds also work fine: 485 | 486 | >>> pH = 6 487 | >>> scheme = rx.parse_reactions("A + H2O -> B <=> B- + H+") 488 | >>> k = np.array([1.0, 100.0, 2.0]) 489 | >>> scheme, k = rx.get_fixed_scheme(scheme, k, {"H+": 10**-pH, "H2O": 55.6}) 490 | >>> scheme 491 | Scheme(compounds=('A', 'B', 'B-'), 492 | reactions=('A -> B', 493 | 'B -> B-', 494 | 'B- -> B'), 495 | is_half_equilibrium=(False, True, True), 496 | A=((-1.0, 0.0, 0.0), 497 | (1.0, -1.0, 1.0), 498 | (0.0, 1.0, -1.0)), 499 | B=((-1.0, 0.0, 0.0), 500 | (1.0, -1.0, 0.0), 501 | (0.0, 1.0, 0.0))) 502 | >>> k 503 | array([5.56e+01, 1.00e+02, 2.00e-06]) 504 | 505 | This function is a no-op if `fixed_y0` is empty, which is very important 506 | for overall code consistency: 507 | 508 | >>> scheme = rx.parse_reactions(["AH <=> A- + H+", "B- + H+ <=> BH"]) 509 | >>> k = np.array([1, 1, 2, 2]) 510 | >>> new_scheme, new_k = rx.get_fixed_scheme(scheme, k, {}) 511 | >>> new_scheme == scheme 512 | True 513 | >>> np.allclose(new_k, k) 514 | True 515 | 516 | """ 517 | new_k = np.asarray(k, dtype=np.float64).copy() 518 | new_reactions = [] 519 | for i, (reaction, is_half_equilibrium) in enumerate( 520 | zip(scheme.reactions, scheme.is_half_equilibrium), 521 | ): 522 | for reactants, products, _ in rx.core._parse_reactions( 523 | reaction, 524 | ): 525 | new_reactants = tuple( 526 | (coeff, compound) 527 | for (coeff, compound) in reactants 528 | if compound not in fixed_y0 529 | ) 530 | new_products = tuple( 531 | (coeff, compound) 532 | for (coeff, compound) in products 533 | if compound not in fixed_y0 534 | ) 535 | 536 | for fixed_compound in fixed_y0: 537 | for coeff, compound in reactants: 538 | if fixed_compound == compound: 539 | new_k[i] *= fixed_y0[fixed_compound] ** coeff 540 | 541 | new_reactions.append((new_reactants, new_products, is_half_equilibrium)) 542 | 543 | new_reactions = tuple(r for r in rx.core._unparse_reactions(new_reactions)) 544 | new_is_half_equilibrium = scheme.is_half_equilibrium 545 | 546 | new_A = [] 547 | new_B = [] 548 | new_compounds = [] 549 | for compound, row_A, row_B in zip( 550 | scheme.compounds, 551 | scheme.A, 552 | scheme.B, 553 | ): 554 | if compound not in fixed_y0: 555 | new_compounds.append(compound) 556 | new_A.append(row_A) 557 | new_B.append(row_B) 558 | 559 | new_compounds = tuple(new_compounds) 560 | new_A = tuple(new_A) 561 | new_B = tuple(new_B) 562 | 563 | return ( 564 | rx.core.Scheme( 565 | compounds=new_compounds, 566 | reactions=new_reactions, 567 | is_half_equilibrium=new_is_half_equilibrium, 568 | A=new_A, 569 | B=new_B, 570 | ), 571 | new_k, 572 | ) 573 | 574 | 575 | # TODO(schneiderfelipe): this is probably not ready yet 576 | def get_bias( 577 | scheme, 578 | compounds, 579 | data, 580 | y0, 581 | tunneling="eckart", 582 | qrrho=True, 583 | temperature=298.15, 584 | pressure=constants.atm, 585 | method="RK23", 586 | rtol=1e-3, 587 | atol=1e-6, 588 | ): 589 | r"""Estimate a energy bias for a given set of reference data points. 590 | 591 | Parameters 592 | ---------- 593 | scheme : Scheme 594 | A descriptor of the reaction scheme. 595 | Mostly likely, this comes from a parsed model input file. 596 | See `overreact.io.parse_model`. 597 | compounds : dict-like 598 | A descriptor of the compounds. 599 | Mostly likely, this comes from a parsed model input file. 600 | See `overreact.io.parse_model`. 601 | data : dict-like of array-like 602 | y0: array-like 603 | tunneling : str or None, optional 604 | Choose between "eckart", "wigner" or None (or "none"). 605 | qrrho : bool or tuple-like, optional 606 | Apply both the quasi-rigid rotor harmonic oscillator (QRRHO) 607 | approximations of M. Head-Gordon and others (enthalpy correction, see 608 | [*J. Phys. Chem. C* **2015**, 119, 4, 1840-1850](http://dx.doi.org/10.1021/jp509921r)) and S. Grimme (entropy correction, see 609 | [*Theory. Chem. Eur. J.*, **2012**, 18: 9955-9964](https://doi.org/10.1002/chem.201200497)) on top of the classical RRHO. 610 | temperature : array-like, optional 611 | Absolute temperature in Kelvin. 612 | pressure : array-like, optional 613 | Reference gas pressure. 614 | delta_freeenergies : array-like, optional 615 | Use this instead of obtaining delta free energies from the compounds. 616 | molecularity : array-like, optional 617 | Reaction order, i.e., number of molecules that come together to react. 618 | If set, this is used to calculate `delta_moles` for 619 | `equilibrium_constant`, which effectively calculates a solution 620 | equilibrium constant between reactants and the transition state for 621 | gas phase data. You should set this to `None` if your free energies 622 | were already adjusted for solution Gibbs free energies. 623 | volume : float, optional 624 | Molar volume. 625 | 626 | Returns 627 | ------- 628 | array-like 629 | 630 | Examples 631 | -------- 632 | >>> model = rx.parse_model("data/tanaka1996/UMP2/cc-pVTZ/model.jk") 633 | 634 | The following are some estimates on actual atmospheric concentrations: 635 | 636 | >>> y0 = [4.8120675684099e-5, 637 | ... 2.8206357713029e-5, 638 | ... 0.0, 639 | ... 0.0, 640 | ... 2.7426565371219e-5] 641 | >>> data = {"t": [1.276472128376942246e-6, 642 | ... 1.446535794555581743e-4, 643 | ... 1.717069678525567564e-2], 644 | ... "CH3·": [9.694916853338366211e-9, 645 | ... 1.066033349343709026e-6, 646 | ... 2.632179124780495175e-5]} 647 | >>> get_bias(model.scheme, model.compounds, data, y0) / constants.kcal 648 | -1.4 649 | """ 650 | max_time = np.max(data["t"]) 651 | 652 | def f(bias): 653 | k = rx.get_k( 654 | scheme, 655 | compounds, 656 | bias=bias, 657 | tunneling=tunneling, 658 | qrrho=qrrho, 659 | temperature=temperature, 660 | pressure=pressure, 661 | ) 662 | 663 | # TODO(schneiderfelipe): support schemes with fixed concentrations 664 | dydt = rx.get_dydt(scheme, k) 665 | y, _ = rx.get_y( 666 | dydt, 667 | y0=y0, 668 | method=method, 669 | rtol=rtol, 670 | atol=atol, 671 | max_time=max_time, 672 | ) 673 | 674 | yhat = y(data["t"]) 675 | return np.sum( 676 | [ 677 | (yhat[i] - data[name]) ** 2 678 | for (i, name) in enumerate(compounds) 679 | if name in data 680 | ], 681 | ) 682 | 683 | res = minimize_scalar(f) 684 | return res.x 685 | -------------------------------------------------------------------------------- /overreact/thermo/_solv.py: -------------------------------------------------------------------------------- 1 | """Module dedicated to the calculation of thermodynamic properties in solvation.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | 7 | import numpy as np 8 | from scipy.misc import derivative 9 | 10 | import overreact as rx 11 | from overreact import _constants as constants 12 | from overreact import coords 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | # TODO(schneiderfelipe): C-PCM already includes the whole Gibbs free energy 18 | # associated with this (see doi:10.1021/jp9716997 and references therein, e.g., 19 | # doi:10.1016/0009-2614(96)00349-1, doi:10.1021/cr60304a002, 20 | # doi:10.1002/jcc.540100504). As such, including this in the total free energy 21 | # might overcount energy contributions. As an alternative, I might want to 22 | # a. remove this contribution, 23 | # b. keep this and remove the contribution from the cavity enthalpy, 24 | # either by 25 | # i. doing a similar calculation as here and removing from the enthalpy or 26 | # ii. by actually implementing the original method of C-PCM and excluding 27 | # it from the enthalpy. 28 | # c. implement something closer to the original and do one of the 29 | # cited in b. above. 30 | # 31 | # See doi:10.1021/cr60304a002. 32 | def calc_cav_entropy( 33 | atomnos, 34 | atomcoords, 35 | environment="water", 36 | temperature=298.15, 37 | pressure=constants.atm, 38 | dx=3e-5, 39 | order=3, 40 | ): 41 | r"""Calculate the cavity entropy from scaled particle theory. 42 | 43 | This implements the method due to A. Garza, see doi:10.1021/acs.jctc.9b00214. 44 | 45 | Parameters 46 | ---------- 47 | atomnos : array-like, optional 48 | atomcoords : array-like, optional 49 | Atomic coordinates. 50 | environment : str, optional 51 | temperature : array-like, optional 52 | Absolute temperature in Kelvin. 53 | pressure : array-like, optional 54 | Reference gas pressure. 55 | dx : float, optional 56 | Spacing. 57 | order : int, optional 58 | Number of points to use, must be odd. 59 | 60 | Returns 61 | ------- 62 | float 63 | Cavity entropy in J/mol·K. 64 | 65 | Examples 66 | -------- 67 | >>> from overreact import _datasets as datasets 68 | 69 | >>> data = datasets.logfiles["symmetries"]["dihydrogen"] 70 | >>> calc_cav_entropy(data.atomnos, data.atomcoords) 71 | -30. 72 | >>> data = datasets.logfiles["symmetries"]["water"] 73 | >>> calc_cav_entropy(data.atomnos, data.atomcoords) 74 | -40. 75 | >>> data = datasets.logfiles["tanaka1996"]["Cl·@UMP2/cc-pVTZ"] 76 | >>> calc_cav_entropy(data.atomnos, data.atomcoords) 77 | -43. 78 | >>> data = datasets.logfiles["symmetries"]["tetraphenylborate-"] 79 | >>> calc_cav_entropy(data.atomnos, data.atomcoords) 80 | -133.1 81 | """ 82 | temperature = np.asarray(temperature) 83 | 84 | if np.isclose(temperature, 0.0): 85 | logger.warning("assuming cavity entropy zero at zero temperature") 86 | return 0.0 87 | 88 | assert atomnos is not None, "atomnos must be provided" 89 | assert atomcoords is not None, "atomcoords must be provided" 90 | vdw_volume = coords.get_molecular_volume(atomnos, atomcoords) 91 | 92 | def func(temperature, solvent): 93 | # TODO(schneiderfelipe): allow passing a "solvent" object everywhere so 94 | # that we don't repeat ourselves? 95 | _, _, ratio = coords._garza( 96 | vdw_volume, 97 | solvent, 98 | full_output=True, 99 | temperature=temperature, 100 | pressure=pressure, 101 | ) 102 | 103 | solvent = rx._misc._get_chemical( 104 | environment, 105 | temperature, 106 | pressure, 107 | ) 108 | 109 | permittivity = solvent.permittivity 110 | y = 3.0 * ((permittivity - 1.0) / (permittivity + 2.0)) / (4.0 * np.pi) 111 | omy = 1.0 - y 112 | yoomy = y / omy 113 | 114 | gamma = ( 115 | -np.log(omy) 116 | # TODO(schneiderfelipe): the following term is probably wrong, the 117 | # next one is that actually shows up in the paper 118 | # + 3.0 * yoomy * ratio 119 | + 3.0 * ratio / omy 120 | + (3.0 + 4.5 * yoomy) * yoomy * ratio**2 121 | # TODO(schneiderfelipe): the following term shows up in an old 122 | # paper, but not in Garza's model 123 | + (y * solvent.Vm * pressure / (constants.R * temperature)) * ratio**3 124 | ) 125 | return -constants.R * temperature * gamma 126 | 127 | cavity_entropy = derivative( 128 | func, 129 | x0=temperature, 130 | dx=dx, 131 | n=1, 132 | order=order, 133 | args=(environment,), 134 | ) 135 | logger.info(f"cavity entropy = {cavity_entropy} J/mol·K") 136 | return cavity_entropy 137 | 138 | 139 | # TODO(schneiderfelipe): the concept of free volume in polymer and membrane 140 | # sciences are related to the difference between the specific volume (inverse 141 | # of density) and the van der Waals volume (oftentimes multiplied by a factor, 142 | # normally 1.3), see doi:10.1007/978-3-642-40872-4_279-5. This is very similar 143 | # to the thing done here with "izato". 144 | # 145 | # Further theoretical support is given for the exact equation 146 | # used in the work of Eyring (doi:10.1021/j150380a007), where it is also 147 | # suggested that that the self-solvation (solvent molecule solvated by itself) 148 | # outer volume (here called cavity volume) should match the specific volume of 149 | # the solvent at that temperature and pressure. 150 | # 151 | # Further evidence that the free volume should change with temperature can be 152 | # found in doi:10.1016/j.jct.2011.01.003. In fact, it is also shown there that 153 | # the rotational entropy is almost the same as for the ideal gas for molecules 154 | # that don't do hydrogen bonding at their boiling temperature. For molecules 155 | # that do hydrogen bonding, the rotational entropy gain should be taken into 156 | # account. All this can be used to improve this model. 157 | # 158 | # As such, I need to: 159 | # 1. use the specific volumes of pure liquids at various temperatures to come 160 | # up with an alpha that depends on temperature (and possibly pressure). 161 | # 2. I need to validate this by checking Trouton's and Hildebrand's laws for 162 | # apolar compounds, which should also give reasonable boiling temperatures and 163 | # free volumes of around 1 ų (see again doi:10.1016/j.jct.2011.01.003). 164 | # 3. Improve polar and hydrogen bonding molecules by adjusting their rotational 165 | # entropies. 166 | # 4. Some data evidence the possibility that the difference between gas and 167 | # pure liquid translational entropies depend solely on the 168 | # temperature/pressure, but not on the nature of the molecule. Changes in 169 | # solvation interaction and rotational entropy might account for the difference 170 | # need to validate Trouton's and Hildebrand's laws. This can be used as a 171 | # guide. 172 | # 173 | # The remarks above are valid for "izato". "garza" incorporates most of the 174 | # above in the usage of the mass density of the solvent. 175 | def molar_free_volume( 176 | atomnos, 177 | atomcoords, 178 | environment="water", 179 | method="garza", 180 | temperature=298.15, 181 | pressure=constants.atm, 182 | ): 183 | r"""Calculate the molar free volume of a solute. 184 | 185 | The current implementation uses simple Quasi-Monte Carlo volume estimates 186 | through `coords.get_molecular_volume`. 187 | 188 | Parameters 189 | ---------- 190 | atomnos : array-like 191 | atomcoords : array-like 192 | Atomic coordinates. 193 | method : str, optional 194 | This is a placeholder for future functionality. 195 | There are plans to implement more sophisticated methods for calculating 196 | entropies such as in 197 | [*Phys. Chem. Chem. Phys.*, **2019**, 21, 18920-18929](https://doi.org/10.1039/C9CP03226F) 198 | and 199 | [*J. Chem. Theory Comput.* **2019**, 15, 5, 3204-3214](https://doi.org/10.1021/acs.jctc.9b00214). 200 | Head over to the 201 | [discussions](https://github.com/geem-lab/overreact/discussions) if 202 | you're interested and would like to contribute. 203 | Leave this as "standard" for now. 204 | environment : str, optional 205 | temperature : array-like, optional 206 | Absolute temperature in Kelvin. 207 | pressure : array-like, optional 208 | Reference gas pressure. 209 | 210 | Returns 211 | ------- 212 | float 213 | Molar free volume in cubic meters per mole. 214 | 215 | Raises 216 | ------ 217 | ValueError 218 | If `method` is not recognized. 219 | 220 | Notes 221 | ----- 222 | For "izato", see equation 3 of doi:10.1039/C9CP03226F for the conceptual 223 | details. There is theoretical support for the equation in the work of 224 | Eyring (doi:10.1021/j150380a007). 225 | 226 | Examples 227 | -------- 228 | >>> from overreact import _datasets as datasets 229 | 230 | >>> data = datasets.logfiles["symmetries"]["dihydrogen"] 231 | >>> molar_free_volume(data.atomnos, data.atomcoords, method="izato") \ 232 | ... / (constants.angstrom ** 3 * constants.N_A) 233 | 0.05 234 | >>> molar_free_volume(data.atomnos, data.atomcoords) \ 235 | ... / (constants.angstrom ** 3 * constants.N_A) 236 | 61. 237 | >>> molar_free_volume(data.atomnos, data.atomcoords, environment="benzene") \ 238 | ... / (constants.angstrom ** 3 * constants.N_A) 239 | 7.7e2 240 | 241 | >>> data = datasets.logfiles["symmetries"]["water"] 242 | >>> molar_free_volume(data.atomnos, data.atomcoords, method="izato") \ 243 | ... / (constants.angstrom ** 3 * constants.N_A) 244 | 0.09 245 | >>> molar_free_volume(data.atomnos, data.atomcoords) \ 246 | ... / (constants.angstrom ** 3 * constants.N_A) 247 | 92. 248 | >>> molar_free_volume(data.atomnos, data.atomcoords, environment="benzene") \ 249 | ... / (constants.angstrom ** 3 * constants.N_A) 250 | 90e1 251 | 252 | >>> data = datasets.logfiles["symmetries"]["benzene"] 253 | >>> molar_free_volume(data.atomnos, data.atomcoords, method="izato") \ 254 | ... / (constants.angstrom ** 3 * constants.N_A) 255 | 0.17 256 | >>> molar_free_volume(data.atomnos, data.atomcoords) \ 257 | ... / (constants.angstrom ** 3 * constants.N_A) 258 | 240. 259 | >>> molar_free_volume(data.atomnos, data.atomcoords, environment="benzene") \ 260 | ... / (constants.angstrom ** 3 * constants.N_A) 261 | 593. 262 | """ 263 | if method == "izato": 264 | vdw_volume, cav_volume, _ = coords.get_molecular_volume( 265 | atomnos, 266 | atomcoords, 267 | method="izato", 268 | full_output=True, 269 | ) 270 | r_M, r_cav = np.cbrt(vdw_volume), np.cbrt(cav_volume) 271 | molar_free_volume = (r_cav - r_M) ** 3 * constants.angstrom**3 * constants.N_A 272 | elif method == "garza": 273 | # TODO(schneiderfelipe): test for the following solvents: water, 274 | # pentane, hexane, heptane and octane. 275 | cav_volume, N_cav, _ = coords._garza( 276 | coords.get_molecular_volume(atomnos, atomcoords), 277 | environment, 278 | full_output=True, 279 | temperature=temperature, 280 | pressure=pressure, 281 | ) 282 | molar_free_volume = N_cav * cav_volume * constants.angstrom**3 * constants.N_A 283 | else: 284 | msg = f"unrecognized method: '{method}'" 285 | raise ValueError(msg) 286 | logger.debug(f"molar free volume = {molar_free_volume} ų") 287 | return molar_free_volume 288 | -------------------------------------------------------------------------------- /overreact/tunnel.py: -------------------------------------------------------------------------------- 1 | """Module dedicated to quantum tunneling approximations.""" 2 | 3 | from __future__ import annotations 4 | 5 | __all__ = ["eckart", "wigner"] 6 | 7 | 8 | import logging 9 | 10 | import numpy as np 11 | from scipy.integrate import fixed_quad 12 | from scipy.special import roots_laguerre 13 | 14 | from overreact import _constants as constants 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | def _check_nu(vibfreq: float) -> float: 20 | """Convert vibrational frequencies in cm$^{-1}$ to s-1. 21 | 22 | Parameters 23 | ---------- 24 | vibfreq : array-like 25 | Magnitude of the imaginary frequency in cm$^{-1}$. Only the absolute value 26 | is used. 27 | 28 | Returns 29 | ------- 30 | nu : array-like 31 | 32 | Raises 33 | ------ 34 | ValueError 35 | If `vibfreq` is zero. 36 | 37 | Examples 38 | -------- 39 | >>> vibfreq = 1000.0 40 | >>> _check_nu(vibfreq) 41 | 2.99792458e13 42 | >>> _check_nu(2.0 * vibfreq) / _check_nu(vibfreq) 43 | 2.0 44 | >>> _check_nu(vibfreq) == _check_nu(-vibfreq) 45 | True 46 | """ 47 | if np.isclose(vibfreq, 0.0).any(): 48 | msg = f"vibfreq should not be zero for tunneling: {vibfreq}" 49 | raise ValueError(msg) 50 | return np.abs(vibfreq) * constants.c / constants.centi 51 | 52 | 53 | def wigner( 54 | vibfreq: float, 55 | temperature: float | np.ndarray = 298.15, 56 | ) -> float: 57 | """Calculate the Wigner correction to quantum tunneling. 58 | 59 | Parameters 60 | ---------- 61 | vibfreq : array-like 62 | Magnitude of the imaginary frequency in cm$^{-1}$. Only the absolute value 63 | is used. 64 | temperature : array-like, optional 65 | Absolute temperature in Kelvin. 66 | 67 | Returns 68 | ------- 69 | kappa : array-like 70 | The quantum tunneling correction. 71 | 72 | Raises 73 | ------ 74 | ValueError 75 | If `vibfreq` is zero. 76 | 77 | Examples 78 | -------- 79 | >>> wigner(1821.0777) 80 | 4.218 81 | >>> wigner(262.38) 82 | 1.06680 83 | >>> wigner(190.5927) 84 | 1.03525 85 | >>> wigner(169.14) 86 | 1.02776 87 | >>> wigner(113.87) 88 | 1.01258 89 | 90 | """ 91 | temperature = np.asarray(temperature) 92 | 93 | nu = _check_nu(vibfreq) 94 | u = constants.h * nu / (constants.k * temperature) 95 | 96 | kappa = 1.0 + (u**2) / 24.0 97 | logger.info(f"Wigner tunneling coefficient: {kappa}") 98 | return kappa 99 | 100 | 101 | def eckart( 102 | vibfreq: float, 103 | delta_forward: float, 104 | delta_backward: float | None = None, 105 | temperature: float | np.ndarray = 298.15, 106 | ) -> float | np.ndarray: 107 | """Calculate the Eckart correction to quantum tunneling. 108 | 109 | References are 110 | [*J. Phys. Chem.* **1962**, 66, 3, 532-533](https://doi.org/10.1021/j100809a040) 111 | and 112 | [*J. Res. Natl. Inst. Stand. Technol.*, **1981**, 86, 357](https://doi.org/10.6028/jres.086.014). 113 | 114 | Parameters 115 | ---------- 116 | vibfreq : array-like 117 | Magnitude of the imaginary frequency in cm$^{-1}$. **Only the absolute value 118 | is used**. 119 | delta_forward : array-like 120 | Activation enthalpy at 0 K for the forward reaction. 121 | delta_backward : array-like, optional 122 | Activation enthalpy at 0 K for the reverse reaction. If delta_backward 123 | is not given, the "symmetrical" Eckart model is used (i.e., 124 | ``delta_backward == delta_forward`` is assumed). 125 | temperature : array-like, optional 126 | Absolute temperature in Kelvin. 127 | 128 | Returns 129 | ------- 130 | kappa : array-like 131 | The quantum tunneling correction. 132 | 133 | Raises 134 | ------ 135 | ValueError 136 | If `vibfreq` is zero. 137 | 138 | Examples 139 | -------- 140 | >>> eckart(1218, 13672.624, 24527.729644, temperature=300) 141 | array(3.9) 142 | >>> eckart(1218, 13672.624, 24527.729644, temperature=[200, 298.15]) 143 | array([17.1, 4.0]) 144 | >>> eckart([1218, 200], 13672.624, 24527.729644, temperature=400) 145 | array([2.3, 1.0]) 146 | 147 | If no backward barrier is given, a symmetric Eckart potential is assumed: 148 | 149 | >>> eckart(414.45, 394.54) 150 | array(1.16) 151 | >>> eckart(414.45, 789.08) 152 | array(1.3) 153 | >>> eckart(3315.6, 3156.31) 154 | array(3.3) 155 | 156 | And if either the forward or backward barrier is non-positive, we fall back 157 | to the Wigner correction, but a warning is issued: 158 | 159 | >>> eckart(190.5927, 109920.73434972763, -154.0231580734253) 160 | 1.03525 161 | >>> eckart(190.5927, -154.0231580734253, 109920.73434972763) 162 | 1.03525 163 | >>> eckart(190.5927, -154.0231580734253) 164 | 1.03525 165 | 166 | """ 167 | temperature = np.asarray(temperature) 168 | 169 | nu = _check_nu(vibfreq) 170 | u = constants.h * nu / (constants.k * temperature) 171 | 172 | if delta_backward is None: 173 | delta_backward = delta_forward 174 | 175 | logger.debug(f"forward potential barrier: {delta_forward} J/mol") 176 | logger.debug(f"backward potential barrier: {delta_backward} J/mol") 177 | 178 | if delta_forward <= 0 or delta_backward <= 0: 179 | logger.warning( 180 | "forward or backward barrier is non-positive, falling back to Wigner correction", 181 | ) 182 | return wigner(vibfreq, temperature) 183 | 184 | assert np.all(delta_forward > 0), "forward barrier should be positive" 185 | assert np.all(delta_backward > 0), "backward barrier should be positive" 186 | 187 | # convert energies in joules per mole to joules 188 | delta_forward = delta_forward / constants.N_A 189 | delta_backward = delta_backward / constants.N_A 190 | 191 | assert delta_backward is not None, "delta_backward should be given" 192 | two_pi = 2.0 * np.pi 193 | alpha1 = two_pi * delta_forward / (constants.h * nu) 194 | alpha2 = two_pi * delta_backward / (constants.h * nu) 195 | 196 | kappa = _eckart(u, alpha1, alpha2) 197 | logger.info(f"Eckart tunneling coefficient: {kappa}") 198 | return kappa 199 | 200 | 201 | @np.vectorize 202 | def _eckart( 203 | u: float, 204 | alpha1: float, 205 | alpha2: float | None = None, 206 | ) -> float: 207 | """Implement of the (unsymmetrical) Eckart tunneling approximation. 208 | 209 | This is based on doi:10.1021/j100809a040 and doi:10.6028/jres.086.014. 210 | 211 | Parameters 212 | ---------- 213 | u : array-like 214 | u = h * nu / (k * T). 215 | alpha1 : array-like 216 | alpha1 = 2 * pi * delta_forward / (h * nu). 217 | alpha2 : array-like, optional 218 | alpha2 = 2 * pi * delta_backward / (h * nu). If not set, the 219 | symmetrical Eckart potential is employed. 220 | 221 | Returns 222 | ------- 223 | float 224 | 225 | Notes 226 | ----- 227 | This function integrates the Eckart transmission function over a Boltzmann 228 | distribution using a mixed set of quadratures (Gauss quadrature for values 229 | below zero and Laguerre quadrature for values from zero to infinity). The 230 | orders for both quadratures are fixed and are the smallest numbers that 231 | allow us to reproduce values from the literature (doi:10.1021/j100809a040). 232 | 233 | Both alpha1 and alpha2 should be non-negative. 234 | 235 | Examples 236 | -------- 237 | Symmetrical barrier: 238 | 239 | >>> _eckart(10, 20) 240 | 1150. 241 | 242 | Unsymmetrical barrier: 243 | 244 | >>> _eckart(2, 0.5, 1.0) 245 | 1.125 246 | >>> _eckart(2, 1.0, 0.5) 247 | 1.125 248 | 249 | """ 250 | # minimum orders that pass tests (with same precision as order=100) 251 | gauss_n = 18 252 | laguerre_n = 11 253 | 254 | # symmetrical potential? 255 | if alpha2 is None: 256 | alpha2 = alpha1 257 | 258 | two_pi = 2.0 * np.pi 259 | v1 = alpha1 * u / (two_pi) 260 | v2 = alpha2 * u / (two_pi) 261 | 262 | d = 4.0 * alpha1 * alpha2 - np.pi**2 263 | D = np.cosh(np.sqrt(d)) if d > 0 else np.cos(np.sqrt(np.abs(d))) 264 | 265 | sqrt_alpha1 = np.sqrt(alpha1) 266 | sqrt_alpha2 = np.sqrt(alpha2) 267 | F = ( 268 | np.sqrt(2.0) 269 | * sqrt_alpha1 270 | * sqrt_alpha2 271 | / (np.sqrt(np.pi) * (sqrt_alpha1 + sqrt_alpha2)) 272 | ) 273 | 274 | def f(eps, with_exp=True): 275 | """Transmission function multiplied or not by the Boltzmann weight.""" 276 | a1 = F * np.sqrt((eps + v1) / u) 277 | a2 = F * np.sqrt((eps + v2) / u) 278 | 279 | qplus = np.cosh(two_pi * (a1 + a2)) 280 | qminus = np.cosh(two_pi * (a1 - a2)) 281 | 282 | p = (qplus - qminus) / (D + qplus) 283 | if with_exp: 284 | return p * np.exp(-eps) 285 | return p 286 | 287 | # integral from -min(v1, v2) to zero using Gauss quadrature 288 | integ1 = fixed_quad(f, -min(v1, v2), 0, n=gauss_n)[0] 289 | 290 | # integral from 0 to infinity using Laguerre quadrature 291 | x, w = roots_laguerre(n=laguerre_n) 292 | integ2 = w @ f(x, with_exp=False) 293 | 294 | return integ1 + integ2 295 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "overreact" 3 | version = "1.2.0" 4 | description = "⚛️📈 Create and analyze chemical microkinetic models built from computational chemistry data" 5 | license = "MIT" 6 | 7 | authors = [ 8 | "Felipe S. S. Schneider ", 9 | "Giovanni F. Caramori ", 10 | ] 11 | 12 | readme = "README.md" 13 | 14 | homepage = "https://geem-lab.github.io/overreact-guide/" 15 | documentation = "https://geem-lab.github.io/overreact-guide/" 16 | repository = "https://github.com/geem-lab/overreact" 17 | 18 | keywords = [ 19 | "catalysis", 20 | "chemical-kinetics", 21 | "chemical-reactions", 22 | "chemistry", 23 | "compchem", 24 | "computational-chemistry", 25 | "density-functional-theory", 26 | "gaussian", 27 | "hartree-fock", 28 | "microkinetics", 29 | "orca", 30 | "quantum-tunneling", 31 | "reactions", 32 | "temperature", 33 | "thermochemistry", 34 | "thermodynamics", 35 | "vibrational-entropies", 36 | ] 37 | 38 | classifiers = [ 39 | "Development Status :: 5 - Production/Stable", 40 | "Environment :: Console", 41 | "Intended Audience :: Developers", 42 | "Intended Audience :: Education", 43 | "Intended Audience :: Science/Research", 44 | "Operating System :: OS Independent", 45 | "Topic :: Education", 46 | "Topic :: Scientific/Engineering :: Chemistry", 47 | "Topic :: Software Development :: Libraries :: Python Modules", 48 | ] 49 | 50 | [tool.poetry.urls] 51 | "API documentation" = "https://geem-lab.github.io/overreact/" 52 | 53 | "PyPI" = "https://pypi.org/project/overreact/" 54 | 55 | "Bug Tracker" = "https://github.com/geem-lab/overreact/issues" 56 | "Discussions" = "https://github.com/geem-lab/overreact/discussions" 57 | 58 | "Citation" = "https://doi.org/10.1002/jcc.26861" 59 | 60 | [tool.poetry.scripts] 61 | overreact = 'overreact._cli:main' 62 | 63 | [tool.poetry.dependencies] 64 | python = ">=3.8,<3.11" 65 | 66 | cclib = "^1" 67 | scipy = "^1.10" 68 | 69 | jax = { version = "^0.4", optional = true } 70 | jaxlib = { version = "^0.4", optional = true } 71 | rich = { version = "^13", optional = true } 72 | thermo = { version = ">=0.2,<0.5", optional = true } 73 | 74 | [tool.poetry.extras] 75 | cli = ["rich"] 76 | fast = ["jax", "jaxlib"] 77 | solvents = ["thermo"] 78 | 79 | [tool.poetry.group.dev.dependencies] 80 | black = { version = ">=23.3,<25.0", extras = ["jupyter"] } 81 | debugpy = "^1" 82 | flynt = ">=0.77,<1.1" 83 | ipython = "^8" 84 | jupyter = "^1.0.0" 85 | matplotlib = "^3" 86 | mypy = ">=0.991,<1.15" 87 | pdoc = ">=12,<15" 88 | perflint = ">=0.7.1,<0.9.0" 89 | pytest = ">=7.2,<9.0" 90 | pytest-cov = ">=4,<6" 91 | ruff = { version = ">=0.0.210,<0.9.3", allow-prereleases = true } 92 | seaborn = ">=0.12,<0.14" 93 | types-setuptools = ">=65,<76" 94 | 95 | [build-system] 96 | build-backend = "poetry.core.masonry.api" 97 | requires = ["poetry-core>=1.0.0"] 98 | 99 | [tool.ruff] 100 | target-version = "py38" 101 | 102 | [tool.ruff.lint] 103 | select = ["ALL"] 104 | # TODO(schneiderfelipe): make this list shorter 105 | ignore = [ 106 | "A002", 107 | "A003", 108 | "ANN001", # MissingTypeFunctionArgument 109 | "ANN002", 110 | "ANN003", 111 | "ANN101", 112 | "ANN201", # MissingReturnTypePublicFunction 113 | "ANN202", # MissingReturnTypePrivateFunction 114 | "ANN204", 115 | "B008", 116 | "B023", 117 | "B026", 118 | "C901", 119 | "E501", 120 | "FBT001", 121 | "FBT002", 122 | "FBT003", 123 | "FIX002", 124 | "G004", 125 | "N803", 126 | "N806", 127 | "NPY002", 128 | "PLC0208", 129 | "PLR0912", 130 | "PLR0913", 131 | "PLR0915", 132 | "PLR2004", 133 | "PLW2901", 134 | "PTH112", 135 | "PTH113", 136 | "PTH118", 137 | "PTH120", 138 | "PTH122", 139 | "PTH123", 140 | "RET505", 141 | "RET507", 142 | "RUF001", 143 | "S101", # AssertUsed 144 | "SLF001", 145 | "T201", 146 | "TD003", 147 | ] 148 | 149 | [tool.ruff.lint.pydocstyle] 150 | convention = "numpy" 151 | 152 | [tool.coverage.run] 153 | include = ["overreact/*"] 154 | 155 | [tool.coverage.report] 156 | show_missing = true 157 | skip_covered = true 158 | fail_under = 90 159 | sort = "Miss" 160 | 161 | [tool.pytest.ini_options] 162 | addopts = "--doctest-modules --doctest-glob=\"*.rst\"" 163 | doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS NUMBER" 164 | norecursedirs = ["_build", "examples"] 165 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests.""" 2 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Tests for the application programming interface (API).""" 2 | 3 | from __future__ import annotations 4 | 5 | import numpy as np 6 | import pytest 7 | 8 | import overreact as rx 9 | from overreact import _constants as constants 10 | from overreact import coords 11 | 12 | 13 | def test_get_enthalpies() -> None: 14 | """Ensure we can retrieve enthalpies.""" 15 | model = rx.parse_model("data/hickel1992/UM06-2X/6-311++G(d,p)/model.k") 16 | assert rx.get_delta( 17 | model.scheme.B, 18 | rx.get_enthalpies(model.compounds, qrrho=False), 19 | )[0] / (constants.hartree * constants.N_A) == pytest.approx( 20 | -132.23510843 - (-56.51424787 + -75.72409969), 21 | 2e-2, 22 | ) 23 | 24 | 25 | def test_get_entropies() -> None: 26 | """Ensure get_entropies match some logfiles. 27 | 28 | It is worth mentioning that, currently, ORCA uses QRRHO in entropy 29 | calculations, but not for enthalpies. 30 | """ 31 | model = rx.parse_model("data/ethane/B97-3c/model.k") 32 | assert 298.15 * rx.get_delta( 33 | model.scheme.B, 34 | rx.get_entropies(model.compounds, environment="gas"), 35 | )[0] / (constants.hartree * constants.N_A) == pytest.approx( 36 | 0.02685478 - 0.00942732 + 0.00773558 - (0.02753672 - 0.00941496 + 0.00772322), 37 | 4e-4, 38 | ) 39 | 40 | model = rx.parse_model("data/tanaka1996/UMP2/cc-pVTZ/model.k") 41 | assert 298.15 * rx.get_delta( 42 | model.scheme.B, 43 | rx.get_entropies(model.compounds, environment="gas"), 44 | )[0] / (constants.hartree * constants.N_A) == pytest.approx( 45 | 0.03025523 - 0.01030794 + 0.00927065 - (0.02110620 + 0.00065446 + 0.01740262), 46 | 3e-5, 47 | ) 48 | 49 | model = rx.parse_model("data/hickel1992/UM06-2X/6-311++G(d,p)/model.k") 50 | sym_correction = 298.15 * rx.change_reference_state(3, 1) 51 | assert 298.15 * rx.get_delta( 52 | model.scheme.B, 53 | rx.get_entropies(model.compounds, environment="gas"), 54 | )[0] / (constants.hartree * constants.N_A) == pytest.approx( 55 | 0.03070499 56 | - ((0.02288418 - 0.00647387 + 0.00543658) + 0.02022750) 57 | - sym_correction / (constants.hartree * constants.N_A), 58 | 2e-4, 59 | ) 60 | 61 | 62 | def test_get_freeenergies() -> None: 63 | """Ensure we can retrieve free energies.""" 64 | model = rx.parse_model("data/hickel1992/UM06-2X/6-311++G(d,p)/model.k") 65 | sym_correction = 298.15 * rx.change_reference_state(3, 1) 66 | 67 | # TODO(schneiderfelipe): should qrrho=(False, True) be default? 68 | assert rx.get_delta( 69 | model.scheme.B, 70 | rx.get_freeenergies(model.compounds, environment="gas", qrrho=(False, True)), 71 | )[0] / (constants.hartree * constants.N_A) == pytest.approx( 72 | -132.26581342 73 | - ((-56.53713205 + 0.00647387 - 0.00543658) + -75.74432719) 74 | + sym_correction / (constants.hartree * constants.N_A), 75 | 3e-3, 76 | ) 77 | 78 | 79 | def test_compare_calc_star_with_get_star() -> None: 80 | """Ensure the calc_* functions match the get_* functions.""" 81 | model = rx.parse_model("data/hickel1992/UM06-2X/6-311++G(d,p)/model.k") 82 | sym_correction = 298.15 * rx.change_reference_state(3, 1) 83 | 84 | freeenergies_ref = [ 85 | -56.53713205 86 | + 0.00647387 87 | - 0.00543658, # NH3(w) + correct rot. entropy (ORCA didn't get C3v) 88 | -75.74432719, 89 | -132.26581342 + sym_correction / (constants.hartree * constants.N_A), 90 | -55.87195207 91 | + 0.00580418 92 | - 0.00514973, # NH2·(w) + correct rot. entropy (ORCA didn't get C2v) 93 | -76.43172074 94 | + 0.00562807 95 | - 0.00497361, # H2O(w) + correct rot. entropy (ORCA didn't get C2v) 96 | -56.98250956 97 | + 0.00696968 98 | - 0.00462348, # NH4+(w) + correct rot. entropy (ORCA didn't get C2v) 99 | ] # ORCA logfiles, Eh 100 | 101 | for bias in np.array([-1, 0, 1]) * constants.kcal: 102 | for environment in ["gas", "solvent"]: 103 | for qrrho in [True, False, (False, True)]: 104 | qrrho_enthalpy, qrrho_entropy = rx.api._check_qrrho( 105 | qrrho, 106 | ) 107 | for temperature in [200, 298.15, 400]: 108 | for pressure in [ 109 | constants.bar / 2, 110 | constants.atm, 111 | 2 * constants.bar, 112 | ]: 113 | freeenergies_get = rx.get_freeenergies( 114 | model.compounds, 115 | bias=bias, 116 | environment=environment, 117 | qrrho=qrrho, 118 | temperature=temperature, 119 | pressure=pressure, 120 | ) 121 | for i, (compound, data) in enumerate(model.compounds.items()): 122 | moments, _, _ = coords.inertia( 123 | data.atommasses, 124 | data.atomcoords, 125 | ) 126 | symmetry_number = coords.symmetry_number( 127 | coords.find_point_group( 128 | data.atommasses, 129 | data.atomcoords, 130 | ), 131 | ) 132 | 133 | enthalpy_calc = rx.thermo.calc_enthalpy( 134 | energy=data.energy, 135 | degeneracy=data.mult, 136 | moments=moments, 137 | vibfreqs=data.vibfreqs, 138 | qrrho=qrrho_enthalpy, 139 | temperature=temperature, 140 | ) 141 | entropy_calc = rx.thermo.calc_entropy( 142 | atommasses=data.atommasses, 143 | energy=data.energy, 144 | degeneracy=data.mult, 145 | moments=moments, 146 | symmetry_number=symmetry_number, 147 | vibfreqs=data.vibfreqs, 148 | environment=environment, 149 | qrrho=qrrho_entropy, 150 | temperature=temperature, 151 | pressure=pressure, 152 | ) 153 | if data.symmetry is not None: 154 | entropy_calc -= rx.change_reference_state( 155 | data.symmetry, 156 | 1, 157 | temperature=temperature, 158 | pressure=pressure, 159 | ) 160 | 161 | freeenergy_calc = ( 162 | enthalpy_calc - temperature * entropy_calc + bias 163 | ) 164 | 165 | assert freeenergies_get[i] == pytest.approx(freeenergy_calc) 166 | 167 | if ( 168 | bias == 0 169 | and environment == "gas" 170 | and qrrho == (False, True) 171 | and temperature == 298.15 172 | and pressure == constants.atm 173 | # TODO(schneiderfelipe): do a test for H+(w) 174 | and compound != "H+(w)" 175 | ): 176 | assert freeenergies_get[i] / ( 177 | constants.hartree * constants.N_A 178 | ) == pytest.approx( 179 | freeenergies_ref[i], 180 | 7e-7, 181 | ) # ORCA logfile 182 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Tests for the command-line interface.""" 2 | 3 | from __future__ import annotations 4 | 5 | from overreact import _cli as cli 6 | 7 | 8 | def test_cli_compiles_source_file(monkeypatch) -> None: 9 | """Ensure the command-line interface can compile a source file (`.k`).""" 10 | params = ["overreact", "--compile", "data/ethane/B97-3c/model.k"] 11 | monkeypatch.setattr("sys.argv", params) 12 | cli.main() 13 | 14 | 15 | def test_cli_describes_source_file(monkeypatch) -> None: 16 | """Ensure the command-line interface can describe a source file (`.k`).""" 17 | params = ["overreact", "data/ethane/B97-3c/model.k"] 18 | monkeypatch.setattr("sys.argv", params) 19 | cli.main() 20 | 21 | 22 | def test_cli_describes_model_file(monkeypatch) -> None: 23 | """Ensure the command-line interface can describe a model file (`.jk`).""" 24 | params = ["overreact", "data/ethane/B97-3c/model.jk"] 25 | monkeypatch.setattr("sys.argv", params) 26 | cli.main() 27 | 28 | 29 | def test_cli_accepts_gaussian_logfiles(monkeypatch) -> None: 30 | """Ensure the command-line interface is OK with Gaussian logfiles.""" 31 | params = ["overreact", "data/acetate/Gaussian09/wB97XD/6-311++G**/model.k"] 32 | monkeypatch.setattr("sys.argv", params) 33 | cli.main() 34 | -------------------------------------------------------------------------------- /tests/test_constants.py: -------------------------------------------------------------------------------- 1 | """Tests for constants module.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | 7 | import overreact as rx 8 | from overreact import _constants as constants 9 | 10 | 11 | def test_reference_raw_constants() -> None: 12 | """Ensure raw constants are close to values commonly used by the community. 13 | 14 | Reference values were taken from the ones used by Gaussian 16 15 | (http://gaussian.com/constants/). 16 | """ 17 | assert constants.bohr / constants.angstrom == pytest.approx(0.52917721092) 18 | assert constants.atomic_mass == pytest.approx(1.660538921e-27) 19 | assert constants.h == pytest.approx(6.62606957e-34) 20 | assert pytest.approx(6.02214129e23) == constants.N_A 21 | assert constants.kcal == pytest.approx(4184.0) 22 | assert constants.hartree == pytest.approx(4.35974434e-18) 23 | assert constants.c / constants.centi == pytest.approx(2.99792458e10) 24 | assert constants.k == pytest.approx(1.3806488e-23) 25 | assert rx.thermo.molar_volume( 26 | temperature=273.15, 27 | pressure=constants.bar, 28 | ) == pytest.approx(0.022710953) 29 | 30 | 31 | def test_reference_conversion_factors() -> None: 32 | """Ensure conversion factors are close to values commonly used by the community. 33 | 34 | Reference values were taken from the ones used by Gaussian 16 35 | (http://gaussian.com/constants/). 36 | """ 37 | assert constants.eV == pytest.approx(1.602176565e-19) 38 | assert constants.eV * constants.N_A / constants.kcal == pytest.approx(23.06, 3e-5) 39 | assert constants.hartree * constants.N_A / constants.kcal == pytest.approx(627.5095) 40 | assert constants.hartree / constants.eV == pytest.approx(27.2114) 41 | assert constants.hartree * constants.centi / ( 42 | constants.h * constants.c 43 | ) == pytest.approx(219474.63) 44 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | """Tests for core module.""" 2 | 3 | from __future__ import annotations 4 | 5 | import numpy as np 6 | 7 | import overreact as rx 8 | 9 | 10 | def test_parse_works() -> None: 11 | """Test parsing of reactions.""" 12 | scheme = rx.parse_reactions("A -> B // a direct reaction") 13 | assert scheme[0] == ("A", "B") 14 | assert scheme[1] == ("A -> B",) 15 | assert scheme[2] == (False,) 16 | assert np.all(scheme[3] == scheme[4]) 17 | assert np.all(np.array([[-1], [1]]) == scheme[3]) 18 | 19 | scheme = rx.parse_reactions("B <- A // reverse reaction of the above") 20 | assert scheme[0] == ("A", "B") 21 | assert scheme[1] == ("A -> B",) 22 | assert scheme[2] == (False,) 23 | assert np.all(scheme[3] == scheme[4]) 24 | assert np.all(np.array([[-1], [1]]) == scheme[3]) 25 | 26 | scheme = rx.parse_reactions("A <=> B // an equilibrium") 27 | assert scheme[0] == ("A", "B") 28 | assert scheme[1] == ("A -> B", "B -> A") 29 | assert np.all(np.array([True, True]) == scheme[2]) 30 | assert np.all(np.array([[-1.0, 1.0], [1.0, -1.0]]) == scheme[3]) 31 | assert np.all(np.array([[-1.0, 0.0], [1.0, 0.0]]) == scheme[4]) 32 | 33 | scheme = rx.parse_reactions( 34 | """A <=> B -> A // a lot of 35 | A -> B <=> A // repeated 36 | A -> B <- A // reactions 37 | B <- A -> B""", 38 | ) 39 | assert scheme[0] == ("A", "B") 40 | assert scheme[1] == ("A -> B", "B -> A") 41 | assert np.all(np.array([True, True]) == scheme[2]) 42 | assert np.all(np.array([[-1.0, 1.0], [1.0, -1.0]]) == scheme[3]) 43 | assert np.all(np.array([[-1.0, 0.0], [1.0, 0.0]]) == scheme[4]) 44 | 45 | scheme = rx.parse_reactions("A -> A‡ -> B // a transition state") 46 | assert scheme[0] == ("A", "A‡", "B") 47 | assert scheme[1] == ("A -> B",) 48 | assert scheme[2] == (False,) 49 | assert np.all(np.array([[-1.0], [0.0], [1.0]]) == scheme[3]) 50 | assert np.all(np.array([[-1.0], [1.0], [0.0]]) == scheme[4]) 51 | 52 | scheme = rx.parse_reactions("A -> A‡ -> B <- A‡ <- A // (should be) same as above") 53 | assert scheme[0] == ("A", "A‡", "B") 54 | assert scheme[1] == ("A -> B",) 55 | assert scheme[2] == (False,) 56 | assert np.all(np.array([[-1.0], [0.0], [1.0]]) == scheme[3]) 57 | assert np.all(np.array([[-1.0], [1.0], [0.0]]) == scheme[4]) 58 | 59 | scheme = rx.parse_reactions( 60 | """ 61 | B -> B‡ -> C // chained reactions and transition states 62 | B‡ -> D // this is a bifurcation 63 | B -> B'‡ -> E // this is a classical competitive reaction 64 | A -> B‡ 65 | """, 66 | ) 67 | assert scheme[0] == ("B", "B‡", "C", "D", "B'‡", "E", "A") 68 | assert scheme[1] == ("B -> C", "B -> D", "B -> E", "A -> C", "A -> D") 69 | assert np.all(np.array([False, False, False, False, False]) == scheme[2]) 70 | assert np.all( 71 | np.array( 72 | [ 73 | [-1.0, -1.0, -1.0, 0.0, 0.0], 74 | [0.0, 0.0, 0.0, 0.0, 0.0], 75 | [1.0, 0.0, 0.0, 1.0, 0.0], 76 | [0.0, 1.0, 0.0, 0.0, 1.0], 77 | [0.0, 0.0, 0.0, 0.0, 0.0], 78 | [0.0, 0.0, 1.0, 0.0, 0.0], 79 | [0.0, 0.0, 0.0, -1.0, -1.0], 80 | ], 81 | ) 82 | == scheme[3], 83 | ) 84 | assert np.all( 85 | np.array( 86 | [ 87 | [-1.0, -1.0, -1.0, 0.0, 0.0], 88 | [1.0, 1.0, 0.0, 1.0, 1.0], 89 | [0.0, 0.0, 0.0, 0.0, 0.0], 90 | [0.0, 0.0, 0.0, 0.0, 0.0], 91 | [0.0, 0.0, 1.0, 0.0, 0.0], 92 | [0.0, 0.0, 0.0, 0.0, 0.0], 93 | [0.0, 0.0, 0.0, -1.0, -1.0], 94 | ], 95 | ) 96 | == scheme[4], 97 | ) 98 | 99 | scheme = rx.parse_reactions( 100 | """// when in doubt, reactions should be considered distinct 101 | A -> A‡ -> B // this is a tricky example 102 | A -> B // but it's better to be explicit""", 103 | ) 104 | assert scheme[0] == ("A", "A‡", "B") 105 | assert scheme[1] == ("A -> B", "A -> B") 106 | assert np.all(np.array([False, False]) == scheme[2]) 107 | assert np.all(np.array([[-1.0, -1.0], [0.0, 0.0], [1.0, 1.0]]) == scheme[3]) 108 | assert np.all(np.array([[-1.0, -1.0], [1.0, 0.0], [0.0, 1.0]]) == scheme[4]) 109 | 110 | scheme = rx.parse_reactions( 111 | """ 112 | // the policy is to not chain transition states 113 | A -> A‡ -> A'‡ -> B // this is weird""", 114 | ) 115 | assert scheme[0] == ("A", "A‡", "A'‡", "B") 116 | assert scheme[1] == ("A -> A'‡",) 117 | assert scheme[2] == (False,) 118 | assert np.all(np.array([[-1.0], [0.0], [1.0], [0.0]]) == scheme[3]) 119 | assert np.all(np.array([[-1.0], [1.0], [0.0], [0.0]]) == scheme[4]) 120 | 121 | 122 | def test_private_functions_work() -> None: 123 | """Ensure private functions work as expected.""" 124 | assert list(rx.core._parse_side("A")) == [(1, "A")] 125 | assert list(rx.core._parse_side("A")) == list( 126 | rx.core._parse_side("1 A"), 127 | ) 128 | assert list(rx.core._parse_side("A")) == list( 129 | rx.core._parse_side("1A"), 130 | ) 131 | assert list(rx.core._parse_side("500 A")) == [(500, "A")] 132 | assert list(rx.core._parse_side("A + 2 B + 500 D")) == [ 133 | (1, "A"), 134 | (2, "B"), 135 | (500, "D"), 136 | ] 137 | 138 | assert rx.core._unparse_side([(1, "A")]) == "A" 139 | assert rx.core._unparse_side([(500, "A")]) == "500 A" 140 | assert rx.core._unparse_side([(1, "A"), (2, "B"), (500, "D")]) == "A + 2 B + 500 D" 141 | 142 | assert ( 143 | rx.core._unparse_side( 144 | rx.core._parse_side( 145 | " 2 *A*1* + 40B1 + chlorophyll", 146 | ), 147 | ) 148 | == "2 *A*1* + 40 B1 + chlorophyll" 149 | ) 150 | 151 | assert list(rx.core._parse_reactions("A -> B")) == [ 152 | (((1, "A"),), ((1, "B"),), False), 153 | ] 154 | assert list(rx.core._parse_reactions("A <=> B")) == [ 155 | (((1, "A"),), ((1, "B"),), True), 156 | (((1, "B"),), ((1, "A"),), True), 157 | ] 158 | assert list(rx.core._parse_reactions("2 A -> B\nA -> 20B")) == [ 159 | (((2, "A"),), ((1, "B"),), False), 160 | (((1, "A"),), ((20, "B"),), False), 161 | ] 162 | assert list( 163 | rx.core._parse_reactions("E + S <=> ES -> ES‡ -> E + P"), 164 | ) == [ 165 | (((1, "E"), (1, "S")), ((1, "ES"),), True), 166 | (((1, "ES"),), ((1, "E"), (1, "S")), True), 167 | (((1, "ES"),), ((1, "ES‡"),), False), 168 | (((1, "ES‡"),), ((1, "E"), (1, "P")), False), 169 | ] 170 | 171 | assert list( 172 | rx.core._unparse_reactions([(((1, "A"),), ((1, "B"),), True)]), 173 | ) == [ 174 | "A -> B", 175 | ] 176 | assert list( 177 | rx.core._unparse_reactions( 178 | [ 179 | (((2, "A"),), ((3, "B"),), True), 180 | (((1, "A"),), ((2, "C"),), True), 181 | (((50, "A"),), ((1, "D"),), True), 182 | ], 183 | ), 184 | ) == ["2 A -> 3 B", "A -> 2 C", "50 A -> D"] 185 | assert list( 186 | rx.core._unparse_reactions( 187 | [ 188 | (((1, "E"), (1, "S")), ((1, "ES"),), True), 189 | (((1, "ES"),), ((1, "E"), (1, "S")), True), 190 | (((1, "ES"),), ((1, "ES‡"),), False), 191 | (((1, "ES‡"),), ((1, "E"), (1, "P")), False), 192 | ], 193 | ), 194 | ) == ["E + S -> ES", "ES -> E + S", "ES -> ES‡", "ES‡ -> E + P"] 195 | 196 | assert list( 197 | rx.core._unparse_reactions( 198 | rx.core._parse_reactions( 199 | "1 A -> 2 B <- C <=> 40 D <- E\nA -> 2 B <=> C", 200 | ), 201 | ), 202 | ) == [ 203 | "A -> 2 B", 204 | "C -> 2 B", 205 | "C -> 40 D", 206 | "40 D -> C", 207 | "E -> 40 D", 208 | "A -> 2 B", 209 | "2 B -> C", 210 | "C -> 2 B", 211 | ] 212 | -------------------------------------------------------------------------------- /tests/test_datasets.py: -------------------------------------------------------------------------------- 1 | """Tests for module datasets.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | 7 | import numpy as np 8 | import pytest 9 | 10 | import overreact as rx 11 | from overreact import _datasets as datasets 12 | 13 | 14 | def test_logfile_retrieval() -> None: 15 | """Ensure logfiles are properly lazily evaluated.""" 16 | data1 = rx.io.read_logfile( 17 | os.path.join( 18 | datasets.data_path, 19 | "tanaka1996", 20 | "UMP2/6-311G(2df,2pd)", 21 | "Cl·.out", 22 | ), 23 | ) 24 | data2 = datasets.logfiles["tanaka1996"]["Cl·@UMP2/6-311G(2df,2pd)"] 25 | for key in set(data1).union(data2): 26 | if isinstance(data1[key], str): 27 | assert data1[key] == data2[key] 28 | else: 29 | assert np.asarray(data1[key]) == pytest.approx(np.asarray(data2[key])) 30 | 31 | data1 = rx.io.read_logfile( 32 | os.path.join( 33 | datasets.data_path, 34 | "symmetries", 35 | "ferrocene-staggered.out", 36 | ), 37 | ) 38 | data2 = datasets.logfiles["symmetries"]["ferrocene-staggered"] 39 | for key in set(data1).union(data2): 40 | if isinstance(data1[key], str): 41 | assert data1[key] == data2[key] 42 | else: 43 | assert np.asarray(data1[key]) == pytest.approx(np.asarray(data2[key])) 44 | -------------------------------------------------------------------------------- /tests/test_io.py: -------------------------------------------------------------------------------- 1 | """Tests for module io.""" 2 | 3 | from __future__ import annotations 4 | 5 | import numpy as np 6 | import pytest 7 | 8 | import overreact as rx 9 | from overreact import _constants as constants 10 | from overreact import coords 11 | 12 | 13 | def test_parse_model_raises_filenotfounderror() -> None: 14 | """Ensure parse_model raises FileNotFoundError when appropriate.""" 15 | with pytest.raises(FileNotFoundError): 16 | rx.io.parse_model("not/available") 17 | 18 | with pytest.raises(FileNotFoundError): 19 | rx.io.parse_model("this/model/does/not/exist.k") 20 | 21 | with pytest.raises(FileNotFoundError): 22 | rx.io.parse_model("unreachable.jk") 23 | 24 | 25 | def test_sanity_for_absolute_thermochemistry() -> None: 26 | """Ensure we have decent quality for (absolute) thermochemical analysis. 27 | 28 | This partially ensures we do similar analysis as Gaussian, see 29 | https://gaussian.com/thermo/. Values from ORCA logfiles are tested as well. 30 | """ 31 | temperature = 298.15 32 | vibtemps = np.array( 33 | [ 34 | 602.31, 35 | 1607.07, 36 | 1607.45, 37 | 1683.83, 38 | 1978.85, 39 | 1978.87, 40 | 2303.03, 41 | 2389.95, 42 | 2389.96, 43 | 2404.55, 44 | 2417.29, 45 | 2417.30, 46 | 4202.52, 47 | 4227.44, 48 | 4244.32, 49 | 4244.93, 50 | 4291.74, 51 | 4292.31, 52 | ], 53 | ) 54 | vibfreqs = vibtemps * constants.k * constants.centi / (constants.h * constants.c) 55 | 56 | # ethane eclipsed 57 | data = rx.io.read_logfile("data/ethane/B97-3c/eclipsed.out") 58 | assert data.atommasses == pytest.approx( 59 | [12.0, 12.0, 1.00783, 1.00783, 1.00783, 1.00783, 1.00783, 1.00783], 60 | 1e-3, 61 | ) 62 | assert np.sum(data.atommasses) == pytest.approx(30.04695, 8e-4) 63 | moments, axes, atomcoords = coords.inertia(data.atommasses, data.atomcoords) 64 | assert moments * constants.angstrom**2 / constants.bohr**2 == pytest.approx( 65 | [23.57594, 88.34097, 88.34208], 66 | 7e-2, 67 | ) 68 | assert data.vibfreqs == pytest.approx(vibfreqs, 1.8) # just for sanity 69 | zpe = rx.thermo._gas.calc_vib_energy(data.vibfreqs, temperature=0.0) 70 | assert zpe == pytest.approx(204885.0, 6e-2) 71 | assert zpe / constants.kcal == pytest.approx(48.96870, 6e-2) 72 | assert zpe / (constants.hartree * constants.N_A) == pytest.approx(0.078037, 6e-2) 73 | thermal_correction = ( 74 | rx.thermo.calc_internal_energy( 75 | energy=data.energy, 76 | degeneracy=data.mult, 77 | moments=moments, 78 | vibfreqs=data.vibfreqs, 79 | ) 80 | - data.energy 81 | ) 82 | assert thermal_correction / (constants.hartree * constants.N_A) == pytest.approx( 83 | 0.081258, 84 | 6e-2, 85 | ) 86 | enthalpy_correction = ( 87 | rx.thermo.calc_enthalpy( 88 | energy=data.energy, 89 | degeneracy=data.mult, 90 | moments=moments, 91 | vibfreqs=data.vibfreqs, 92 | ) 93 | - data.energy 94 | ) 95 | assert enthalpy_correction / (constants.hartree * constants.N_A) == pytest.approx( 96 | 0.082202, 97 | 6e-2, 98 | ) 99 | assert enthalpy_correction - thermal_correction == pytest.approx( 100 | constants.R * temperature, 101 | ) 102 | entropy = rx.thermo.calc_entropy( 103 | atommasses=data.atommasses, 104 | energy=data.energy, 105 | degeneracy=data.mult, 106 | moments=moments, 107 | symmetry_number=1, 108 | vibfreqs=data.vibfreqs, 109 | ) 110 | freeenergy_correction = enthalpy_correction - temperature * entropy 111 | assert freeenergy_correction / (constants.hartree * constants.N_A) == pytest.approx( 112 | 0.055064, 113 | 8e-2, 114 | ) 115 | assert freeenergy_correction / (constants.hartree * constants.N_A) == pytest.approx( 116 | 0.05082405, 117 | 1e-4, 118 | ) # ORCA logfile 119 | assert freeenergy_correction / constants.kcal == pytest.approx( 120 | 31.89, 121 | 3e-5, 122 | ) # ORCA logfile 123 | assert (data.energy + zpe) / (constants.hartree * constants.N_A) == pytest.approx( 124 | -79.140431, 125 | 8e-3, 126 | ) 127 | assert (data.energy + thermal_correction) / ( 128 | constants.hartree * constants.N_A 129 | ) == pytest.approx(-79.137210, 8e-3) 130 | assert (data.energy + enthalpy_correction) / ( 131 | constants.hartree * constants.N_A 132 | ) == pytest.approx(-79.136266, 8e-3) 133 | assert (data.energy + enthalpy_correction) / ( 134 | constants.hartree * constants.N_A 135 | ) == pytest.approx( 136 | -79.70621533, 137 | 7e-8, 138 | ) # ORCA logfile 139 | assert -temperature * entropy / ( 140 | constants.hartree * constants.N_A 141 | ) == pytest.approx( 142 | -0.02685478, 143 | 4e-6, 144 | ) # ORCA logfile 145 | assert -temperature * entropy / constants.kcal == pytest.approx( 146 | -16.85, 147 | 1e-4, 148 | ) # ORCA logfile 149 | assert (data.energy + freeenergy_correction) / ( 150 | constants.hartree * constants.N_A 151 | ) == pytest.approx(-79.163404, 8e-3) 152 | assert (data.energy + freeenergy_correction) / ( 153 | constants.hartree * constants.N_A 154 | ) == pytest.approx( 155 | -79.73307012, 156 | 7e-8, 157 | ) # ORCA logfile 158 | 159 | # ethane staggered 160 | data = rx.io.read_logfile("data/ethane/B97-3c/staggered.out") 161 | assert data.atommasses == pytest.approx( 162 | [12.0, 12.0, 1.00783, 1.00783, 1.00783, 1.00783, 1.00783, 1.00783], 163 | 1e-3, 164 | ) 165 | assert np.sum(data.atommasses) == pytest.approx(30.04695, 8e-4) 166 | moments, axes, atomcoords = coords.inertia(data.atommasses, data.atomcoords) 167 | assert moments * constants.angstrom**2 / constants.bohr**2 == pytest.approx( 168 | [23.57594, 88.34097, 88.34208], 169 | 6e-2, 170 | ) 171 | assert data.vibfreqs == pytest.approx(vibfreqs, 3e-1) 172 | zpe = rx.thermo._gas.calc_vib_energy(data.vibfreqs, temperature=0.0) 173 | assert zpe == pytest.approx(204885.0, 6e-2) 174 | assert zpe / constants.kcal == pytest.approx(48.96870, 6e-2) 175 | assert zpe / (constants.hartree * constants.N_A) == pytest.approx(0.078037, 6e-2) 176 | thermal_correction = ( 177 | rx.thermo.calc_internal_energy( 178 | energy=data.energy, 179 | degeneracy=data.mult, 180 | moments=moments, 181 | vibfreqs=data.vibfreqs, 182 | ) 183 | - data.energy 184 | ) 185 | assert thermal_correction / (constants.hartree * constants.N_A) == pytest.approx( 186 | 0.081258, 187 | 5e-2, 188 | ) 189 | enthalpy_correction = ( 190 | rx.thermo.calc_enthalpy( 191 | energy=data.energy, 192 | degeneracy=data.mult, 193 | moments=moments, 194 | vibfreqs=data.vibfreqs, 195 | ) 196 | - data.energy 197 | ) 198 | assert enthalpy_correction / (constants.hartree * constants.N_A) == pytest.approx( 199 | 0.082202, 200 | 5e-2, 201 | ) 202 | assert enthalpy_correction - thermal_correction == pytest.approx( 203 | constants.R * temperature, 204 | ) 205 | entropy = rx.thermo.calc_entropy( 206 | atommasses=data.atommasses, 207 | energy=data.energy, 208 | degeneracy=data.mult, 209 | moments=moments, 210 | symmetry_number=1, 211 | vibfreqs=data.vibfreqs, 212 | ) 213 | freeenergy_correction = enthalpy_correction - temperature * entropy 214 | assert freeenergy_correction / (constants.hartree * constants.N_A) == pytest.approx( 215 | 0.055064, 216 | 8e-2, 217 | ) 218 | assert freeenergy_correction / (constants.hartree * constants.N_A) == pytest.approx( 219 | 0.05092087, 220 | 3e-4, 221 | ) # ORCA logfile 222 | assert freeenergy_correction / constants.kcal == pytest.approx( 223 | 31.95, 224 | 2e-4, 225 | ) # ORCA logfile 226 | assert (data.energy + zpe) / (constants.hartree * constants.N_A) == pytest.approx( 227 | -79.140431, 228 | 8e-3, 229 | ) 230 | assert (data.energy + thermal_correction) / ( 231 | constants.hartree * constants.N_A 232 | ) == pytest.approx(-79.137210, 8e-3) 233 | assert (data.energy + enthalpy_correction) / ( 234 | constants.hartree * constants.N_A 235 | ) == pytest.approx(-79.136266, 8e-3) 236 | assert (data.energy + enthalpy_correction) / ( 237 | constants.hartree * constants.N_A 238 | ) == pytest.approx( 239 | -79.70971287, 240 | 2e-7, 241 | ) # ORCA logfile 242 | assert -temperature * entropy / ( 243 | constants.hartree * constants.N_A 244 | ) == pytest.approx( 245 | -0.02753672, 246 | 8e-5, 247 | ) # ORCA logfile 248 | assert -temperature * entropy / constants.kcal == pytest.approx( 249 | -17.28, 250 | 9e-4, 251 | ) # ORCA logfile 252 | assert (data.energy + freeenergy_correction) / ( 253 | constants.hartree * constants.N_A 254 | ) == pytest.approx(-79.163404, 8e-3) 255 | assert (data.energy + freeenergy_correction) / ( 256 | constants.hartree * constants.N_A 257 | ) == pytest.approx( 258 | -79.73724959, 259 | 2e-7, 260 | ) # ORCA logfile 261 | 262 | 263 | def test_compare_rrho_with_orca_logfile() -> None: 264 | """Ensure we have decent quality for RRHO thermochemical analysis. 265 | 266 | Values from ORCA logfiles are tested. 267 | """ 268 | temperature = 298.15 269 | 270 | # benzene 271 | data = rx.io.read_logfile("data/symmetries/benzene.out") 272 | assert np.sum(data.atommasses) == pytest.approx(78.11, 6e-5) 273 | moments, axes, atomcoords = coords.inertia(data.atommasses, data.atomcoords) 274 | symmetry_number = coords.symmetry_number( 275 | coords.find_point_group(data.atommasses, data.atomcoords), 276 | ) 277 | assert symmetry_number == 12 # ORCA fails to find D6h symmetry! 278 | internal_energy = rx.thermo.calc_internal_energy( 279 | energy=data.energy, 280 | degeneracy=data.mult, 281 | moments=moments, 282 | vibfreqs=data.vibfreqs, 283 | ) 284 | zpe = rx.thermo._gas.calc_vib_energy( 285 | vibfreqs=data.vibfreqs, 286 | temperature=0.0, 287 | ) 288 | elec_energy = rx.thermo._gas.calc_elec_energy( 289 | energy=data.energy, 290 | degeneracy=data.mult, 291 | ) 292 | vib_energy = rx.thermo._gas.calc_vib_energy(vibfreqs=data.vibfreqs) 293 | rot_energy = rx.thermo._gas.calc_rot_energy(moments=moments) 294 | trans_energy = rx.thermo._gas.calc_trans_energy() 295 | enthalpy = rx.thermo.calc_enthalpy( 296 | energy=data.energy, 297 | degeneracy=data.mult, 298 | moments=moments, 299 | vibfreqs=data.vibfreqs, 300 | ) 301 | entropy = rx.thermo.calc_entropy( 302 | atommasses=data.atommasses, 303 | energy=data.energy, 304 | degeneracy=data.mult, 305 | moments=moments, 306 | symmetry_number=symmetry_number, 307 | vibfreqs=data.vibfreqs, 308 | ) 309 | elec_entropy = rx.thermo._gas.calc_elec_entropy( 310 | energy=data.energy, 311 | degeneracy=data.mult, 312 | ) 313 | vib_entropy = rx.thermo._gas.calc_vib_entropy( 314 | vibfreqs=data.vibfreqs, 315 | ) 316 | rot_entropy = rx.thermo._gas.calc_rot_entropy( 317 | moments=moments, 318 | symmetry_number=symmetry_number, 319 | ) 320 | trans_entropy = rx.thermo.calc_trans_entropy(atommasses=data.atommasses) 321 | freeenergy = enthalpy - temperature * entropy 322 | assert elec_energy / (constants.hartree * constants.N_A) == pytest.approx( 323 | -232.02314787, 324 | ) # ORCA logfile 325 | assert (vib_energy - zpe) / (constants.hartree * constants.N_A) == pytest.approx( 326 | 0.00168358, 327 | 7e-4, 328 | ) # ORCA logfile 329 | assert (vib_energy - zpe) / constants.kcal == pytest.approx( 330 | 1.06, 331 | 4e-3, 332 | ) # ORCA logfile 333 | assert rot_energy / (constants.hartree * constants.N_A) == pytest.approx( 334 | 0.00141627, 335 | 3e-4, 336 | ) # ORCA logfile 337 | assert rot_energy / constants.kcal == pytest.approx(0.89, 2e-3) # ORCA logfile 338 | assert trans_energy / (constants.hartree * constants.N_A) == pytest.approx( 339 | 0.00141627, 340 | 6e-6, 341 | ) # ORCA logfile 342 | assert trans_energy / constants.kcal == pytest.approx(0.89, 2e-3) # ORCA logfile 343 | assert (internal_energy - data.energy - zpe) / ( 344 | constants.hartree * constants.N_A 345 | ) == pytest.approx( 346 | 0.00451613, 347 | 2e-4, 348 | ) # ORCA logfile 349 | assert (internal_energy - data.energy - zpe) / constants.kcal == pytest.approx( 350 | 2.83, 351 | 2e-3, 352 | ) # ORCA logfile 353 | assert zpe / (constants.hartree * constants.N_A) == pytest.approx( 354 | 0.09744605, 355 | 2e-4, 356 | ) # ORCA logfile 357 | assert zpe / constants.kcal == pytest.approx(61.15, 2e-4) # ORCA logfile 358 | assert (internal_energy - data.energy) / ( 359 | constants.hartree * constants.N_A 360 | ) == pytest.approx( 361 | 0.10196218, 362 | 2e-4, 363 | ) # ORCA logfile 364 | assert (internal_energy - data.energy) / constants.kcal == pytest.approx( 365 | 63.98, 366 | 2e-4, 367 | ) # ORCA logfile 368 | assert internal_energy / (constants.hartree * constants.N_A) == pytest.approx( 369 | -231.92118569, 370 | ) # ORCA logfile 371 | assert (enthalpy - internal_energy) / ( 372 | constants.hartree * constants.N_A 373 | ) == pytest.approx( 374 | 0.00094421, 375 | 3e-5, 376 | ) # ORCA logfile 377 | assert (enthalpy - internal_energy) / constants.kcal == pytest.approx( 378 | 0.59, 379 | 5e-3, 380 | ) # ORCA logfile 381 | assert temperature * elec_entropy / ( 382 | constants.hartree * constants.N_A 383 | ) == pytest.approx( 384 | 0.0, 385 | ) # ORCA logfile 386 | assert temperature * elec_entropy / constants.kcal == pytest.approx( 387 | 0.0, 388 | ) # ORCA logfile 389 | assert temperature * vib_entropy / ( 390 | constants.hartree * constants.N_A 391 | ) == pytest.approx( 392 | 0.00226991, 393 | 2e-3, 394 | ) # ORCA logfile 395 | assert temperature * vib_entropy / constants.kcal == pytest.approx( 396 | 1.42, 397 | 4e-3, 398 | ) # ORCA logfile 399 | assert temperature * rot_entropy / ( 400 | constants.hartree * constants.N_A 401 | ) == pytest.approx( 402 | 0.00987646, 403 | 4e-6, 404 | ) # ORCA logfile 405 | assert temperature * rot_entropy / constants.kcal == pytest.approx( 406 | 6.20, 407 | 4e-4, 408 | ) # ORCA logfile 409 | assert temperature * trans_entropy / ( 410 | constants.hartree * constants.N_A 411 | ) == pytest.approx( 412 | 0.01852142, 413 | 4e-6, 414 | ) # ORCA logfile 415 | assert temperature * trans_entropy / constants.kcal == pytest.approx( 416 | 11.62, 417 | 3e-4, 418 | ) # ORCA logfile 419 | assert enthalpy / (constants.hartree * constants.N_A) == pytest.approx( 420 | -231.92024148, 421 | ) # ORCA logfile 422 | assert -temperature * entropy / ( 423 | constants.hartree * constants.N_A 424 | ) == pytest.approx( 425 | -0.03066779, 426 | 1e-4, 427 | ) # ORCA logfile 428 | assert -temperature * entropy / constants.kcal == pytest.approx( 429 | -19.25, 430 | 4e-4, 431 | ) # ORCA logfile 432 | assert freeenergy / (constants.hartree * constants.N_A) == pytest.approx( 433 | -231.95090927, 434 | ) # ORCA logfile 435 | assert (freeenergy - data.energy) / ( 436 | constants.hartree * constants.N_A 437 | ) == pytest.approx( 438 | 0.07223860, 439 | 3e-4, 440 | ) # ORCA logfile 441 | assert (freeenergy - data.energy) / constants.kcal == pytest.approx( 442 | 45.33, 443 | 3e-4, 444 | ) # ORCA logfile 445 | 446 | 447 | def test_compare_qrrho_with_orca_logfile() -> None: 448 | """Ensure we have decent quality for QRRHO thermochemical analysis. 449 | 450 | Values from ORCA logfiles are tested. 451 | """ 452 | temperature = 298.15 453 | 454 | # triphenylphosphine 455 | data = rx.io.read_logfile("data/symmetries/triphenylphosphine.out") 456 | assert np.sum(data.atommasses) == pytest.approx(262.29, 8e-6) 457 | moments, axes, atomcoords = coords.inertia(data.atommasses, data.atomcoords) 458 | symmetry_number = coords.symmetry_number( 459 | coords.find_point_group(data.atommasses, data.atomcoords), 460 | ) 461 | assert symmetry_number == 3 462 | internal_energy = rx.thermo.calc_internal_energy( 463 | energy=data.energy, 464 | degeneracy=data.mult, 465 | moments=moments, 466 | vibfreqs=data.vibfreqs, 467 | qrrho=False, 468 | ) 469 | zpe = rx.thermo._gas.calc_vib_energy( 470 | vibfreqs=data.vibfreqs, 471 | qrrho=False, 472 | temperature=0.0, 473 | ) 474 | elec_energy = rx.thermo._gas.calc_elec_energy( 475 | energy=data.energy, 476 | degeneracy=data.mult, 477 | ) 478 | vib_energy = rx.thermo._gas.calc_vib_energy( 479 | vibfreqs=data.vibfreqs, 480 | qrrho=False, 481 | ) 482 | rot_energy = rx.thermo._gas.calc_rot_energy(moments=moments) 483 | trans_energy = rx.thermo._gas.calc_trans_energy() 484 | enthalpy = rx.thermo.calc_enthalpy( 485 | energy=data.energy, 486 | degeneracy=data.mult, 487 | moments=moments, 488 | vibfreqs=data.vibfreqs, 489 | qrrho=False, 490 | ) 491 | entropy = rx.thermo.calc_entropy( 492 | atommasses=data.atommasses, 493 | energy=data.energy, 494 | degeneracy=data.mult, 495 | moments=moments, 496 | symmetry_number=symmetry_number, 497 | vibfreqs=data.vibfreqs, 498 | ) 499 | elec_entropy = rx.thermo._gas.calc_elec_entropy( 500 | energy=data.energy, 501 | degeneracy=data.mult, 502 | ) 503 | vib_entropy = rx.thermo._gas.calc_vib_entropy( 504 | vibfreqs=data.vibfreqs, 505 | ) 506 | rot_entropy = rx.thermo._gas.calc_rot_entropy( 507 | moments=moments, 508 | symmetry_number=symmetry_number, 509 | ) 510 | trans_entropy = rx.thermo.calc_trans_entropy(atommasses=data.atommasses) 511 | freeenergy = enthalpy - temperature * entropy 512 | assert elec_energy / (constants.hartree * constants.N_A) == pytest.approx( 513 | -1035.902509903170, 514 | ) # ORCA logfile 515 | assert (vib_energy - zpe) / (constants.hartree * constants.N_A) == pytest.approx( 516 | 0.01332826, 517 | 6e-5, 518 | ) # ORCA logfile 519 | assert (vib_energy - zpe) / constants.kcal == pytest.approx( 520 | 8.36, 521 | 4e-4, 522 | ) # ORCA logfile 523 | assert rot_energy / (constants.hartree * constants.N_A) == pytest.approx( 524 | 0.00141627, 525 | 2e-5, 526 | ) # ORCA logfile 527 | assert rot_energy / constants.kcal == pytest.approx(0.89, 2e-3) # ORCA logfile 528 | assert trans_energy / (constants.hartree * constants.N_A) == pytest.approx( 529 | 0.00141627, 530 | 6e-6, 531 | ) # ORCA logfile 532 | assert trans_energy / constants.kcal == pytest.approx(0.89, 2e-3) # ORCA logfile 533 | assert (internal_energy - data.energy - zpe) / ( 534 | constants.hartree * constants.N_A 535 | ) == pytest.approx( 536 | 0.01616080, 537 | 5e-5, 538 | ) # ORCA logfile 539 | assert (internal_energy - data.energy - zpe) / constants.kcal == pytest.approx( 540 | 10.14, 541 | 6e-5, 542 | ) # ORCA logfile 543 | assert zpe / (constants.hartree * constants.N_A) == pytest.approx( 544 | 0.26902318, 545 | ) # ORCA logfile 546 | assert zpe / constants.kcal == pytest.approx(168.81, 3e-5) # ORCA logfile 547 | assert (internal_energy - data.energy) / ( 548 | constants.hartree * constants.N_A 549 | ) == pytest.approx( 550 | 0.28518398, 551 | 3e-6, 552 | ) # ORCA logfile 553 | assert (internal_energy - data.energy) / constants.kcal == pytest.approx( 554 | 178.96, 555 | 3e-5, 556 | ) # ORCA logfile 557 | assert internal_energy / (constants.hartree * constants.N_A) == pytest.approx( 558 | -1035.61732592, 559 | ) # ORCA logfile 560 | assert (enthalpy - internal_energy) / ( 561 | constants.hartree * constants.N_A 562 | ) == pytest.approx( 563 | 0.00094421, 564 | 3e-5, 565 | ) # ORCA logfile 566 | assert (enthalpy - internal_energy) / constants.kcal == pytest.approx( 567 | 0.59, 568 | 5e-3, 569 | ) # ORCA logfile 570 | assert temperature * elec_entropy / ( 571 | constants.hartree * constants.N_A 572 | ) == pytest.approx( 573 | 0.0, 574 | ) # ORCA logfile 575 | assert temperature * elec_entropy / constants.kcal == pytest.approx( 576 | 0.0, 577 | ) # ORCA logfile 578 | assert temperature * vib_entropy / ( 579 | constants.hartree * constants.N_A 580 | ) == pytest.approx( 581 | 0.02326220, 582 | 2e-3, 583 | ) # ORCA logfile 584 | assert temperature * vib_entropy / constants.kcal == pytest.approx( 585 | 14.60, 586 | 2e-3, 587 | ) # ORCA logfile 588 | assert temperature * rot_entropy / ( 589 | constants.hartree * constants.N_A 590 | ) == pytest.approx( 591 | 0.01498023, 592 | 4e-6, 593 | ) # ORCA logfile 594 | assert temperature * rot_entropy / constants.kcal == pytest.approx( 595 | 9.40, 596 | 3e-5, 597 | ) # ORCA logfile 598 | assert temperature * trans_entropy / ( 599 | constants.hartree * constants.N_A 600 | ) == pytest.approx( 601 | 0.02023694, 602 | 4e-6, 603 | ) # ORCA logfile 604 | assert temperature * trans_entropy / constants.kcal == pytest.approx( 605 | 12.70, 606 | 9e-5, 607 | ) # ORCA logfile 608 | assert enthalpy / (constants.hartree * constants.N_A) == pytest.approx( 609 | -1035.61638171, 610 | ) # ORCA logfile 611 | assert -temperature * entropy / ( 612 | constants.hartree * constants.N_A 613 | ) == pytest.approx( 614 | -0.058479359999999994, 615 | 6e-4, 616 | ) # ORCA logfile 617 | assert -temperature * entropy / constants.kcal == pytest.approx( 618 | -36.70, 619 | 7e-4, 620 | ) # ORCA logfile 621 | assert freeenergy / (constants.hartree * constants.N_A) == pytest.approx( 622 | -1035.6748610799998, 623 | ) # ORCA logfile 624 | assert (freeenergy - data.energy) / ( 625 | constants.hartree * constants.N_A 626 | ) == pytest.approx( 627 | 0.22764882, 628 | 2e-4, 629 | ) # ORCA logfile 630 | assert (freeenergy - data.energy) / constants.kcal == pytest.approx( 631 | 142.85, 632 | 2e-4, 633 | ) # ORCA logfile 634 | 635 | 636 | def test_read_logfile() -> None: 637 | """Ensure read_logfile returns correct data.""" 638 | fields = { 639 | "logfile", 640 | "energy", 641 | "mult", 642 | "atomnos", 643 | "atommasses", 644 | "atomcoords", 645 | "vibdisps", 646 | "vibfreqs", 647 | "hessian", 648 | } 649 | 650 | data = rx.io.read_logfile("data/tanaka1996/UMP2/6-311G(2df,2pd)/Cl·.out") 651 | assert set(data) == fields 652 | assert data.logfile == "data/tanaka1996/UMP2/6-311G(2df,2pd)/Cl·.out" 653 | assert data.energy == pytest.approx(-1206891740.7180765, 3e-5) 654 | assert data.mult == 2 655 | assert data.atomnos == pytest.approx(np.array([17])) 656 | assert data.atommasses == pytest.approx(np.array([35.453])) 657 | assert data.atomcoords == pytest.approx(np.array([[0.0, 0.0, 0.0]])) 658 | 659 | fields.remove("hessian") 660 | data = rx.io.read_logfile("data/symmetries/chlorobromofluoromethane.out") 661 | assert set(data) == fields 662 | assert data.logfile == "data/symmetries/chlorobromofluoromethane.out" 663 | assert data.energy == -8327995636.7634325 664 | assert data.mult == 1 665 | assert data.atomnos == pytest.approx(np.array([6, 35, 17, 9, 1])) 666 | assert data.atommasses == pytest.approx( 667 | np.array([12.011, 79.9, 35.453, 18.998, 1.008]), 668 | ) 669 | assert data.atomcoords == pytest.approx( 670 | np.array( 671 | [ 672 | [0.045, 0.146641, -0.010545], 673 | [-1.101907, -0.97695, 1.173387], 674 | [1.103319, -0.870505, -1.041381], 675 | [-0.737102, 0.941032, -0.780045], 676 | [0.69069, 0.759782, 0.658585], 677 | ], 678 | ), 679 | ) 680 | assert len(data.vibfreqs) == 9 681 | assert data.vibfreqs == pytest.approx( 682 | np.array( 683 | [ 684 | 209.49, 685 | 290.47, 686 | 408.01, 687 | 612.13, 688 | 715.01, 689 | 1077.37, 690 | 1158.79, 691 | 1277.35, 692 | 3016.45, 693 | ], 694 | ), 695 | ) 696 | 697 | 698 | def test_read_logfile_from_orca_xtb() -> None: 699 | """Ensure read_logfile correctly reads an XTB2 ORCA logfile.""" 700 | fields = { 701 | "logfile", 702 | "energy", 703 | "mult", 704 | "atomnos", 705 | "atommasses", 706 | "atomcoords", 707 | "vibfreqs", 708 | } 709 | 710 | data = rx.io.read_logfile("data/symmetries/Xe.out") 711 | assert set(data) == fields 712 | assert data.logfile == "data/symmetries/Xe.out" 713 | assert data.energy == -10194122.6419248 714 | assert data.mult == 1 715 | assert data.atomnos == pytest.approx(np.array([54])) 716 | assert data.atommasses == pytest.approx(np.array([131.293])) 717 | assert data.atomcoords == pytest.approx(np.array([[0.0, 0.0, 0.0]])) 718 | assert len(data.vibfreqs) == 0 719 | assert data.vibfreqs == pytest.approx(np.array([])) 720 | 721 | data = rx.io.read_logfile("data/symmetries/N-methyl-maleamic-acid.out") 722 | assert set(data).issubset(fields) 723 | assert data.logfile == "data/symmetries/N-methyl-maleamic-acid.out" 724 | assert data.energy == pytest.approx(-77186778.47357602) 725 | assert data.mult == 1 726 | assert data.atomnos == pytest.approx( 727 | np.array([6, 6, 6, 6, 8, 8, 8, 7, 6, 1, 1, 1, 1, 1, 1, 1]), 728 | ) 729 | assert data.atommasses == pytest.approx( 730 | np.array( 731 | [ 732 | 12.011, 733 | 12.011, 734 | 12.011, 735 | 12.011, 736 | 15.999, 737 | 15.999, 738 | 15.999, 739 | 14.007, 740 | 12.011, 741 | 1.008, 742 | 1.008, 743 | 1.008, 744 | 1.008, 745 | 1.008, 746 | 1.008, 747 | 1.008, 748 | ], 749 | ), 750 | ) 751 | assert data.atomcoords == pytest.approx( 752 | np.array( 753 | [ 754 | [-1.06389997780294, 0.10248761131916, -0.00416378746087], 755 | [-0.04944623568470, 1.17586574558391, 0.10368017025102], 756 | [1.28503975399447, 1.09120288104392, 0.06034577486787], 757 | [2.20926920197202, -0.06832416861567, -0.10617866981935], 758 | [1.73198710638407, -1.28122618787304, -0.24203736856330], 759 | [3.41087559081078, 0.14023927997443, -0.11002435534482], 760 | [-0.79219370406139, -1.09826426021838, -0.16397895569778], 761 | [-2.32310796978358, 0.53074548200415, 0.08639380290049], 762 | [-3.46974241527303, -0.34303095787263, 0.00740932800451], 763 | [-0.46597227757871, 2.16615081446255, 0.23685714714238], 764 | [1.85285605798893, 2.00795050069988, 0.15981019507090], 765 | [0.70285753578241, -1.31471691088694, -0.22226384014426], 766 | [-3.11702504917192, -1.36335069364200, -0.12910355473485], 767 | [-4.09785973779508, -0.05953555624245, -0.83679648129593], 768 | [-4.05175012271585, -0.27936126957015, 0.92652687537148], 769 | [-2.51093775706549, 1.51470768983332, 0.21446371945248], 770 | ], 771 | ), 772 | abs=1e-2, 773 | ) 774 | assert len(data.vibfreqs) == 42 775 | assert data.vibfreqs == pytest.approx( 776 | np.array( 777 | [ 778 | 26.8, 779 | 85.95, 780 | 109.07, 781 | 163.28, 782 | 179.43, 783 | 291.63, 784 | 301.61, 785 | 347.92, 786 | 388.08, 787 | 515.51, 788 | 580.7, 789 | 586.41, 790 | 621.38, 791 | 717.99, 792 | 753.83, 793 | 831.9, 794 | 871.1, 795 | 932.0, 796 | 941.24, 797 | 947.76, 798 | 1072.81, 799 | 1099.9, 800 | 1118.57, 801 | 1194.22, 802 | 1219.35, 803 | 1262.62, 804 | 1306.58, 805 | 1353.12, 806 | 1394.68, 807 | 1423.79, 808 | 1449.12, 809 | 1465.74, 810 | 1597.11, 811 | 1652.92, 812 | 1662.95, 813 | 2303.95, 814 | 3001.39, 815 | 3012.59, 816 | 3048.14, 817 | 3049.84, 818 | 3070.26, 819 | 3427.02, 820 | ], 821 | ), 822 | ) 823 | -------------------------------------------------------------------------------- /tests/test_misc.py: -------------------------------------------------------------------------------- 1 | """Tests for misc module.""" 2 | 3 | from __future__ import annotations 4 | 5 | import numpy as np 6 | import pytest 7 | 8 | import overreact as rx 9 | 10 | 11 | def test_broaden_spectrum_works() -> None: 12 | """Ensure we can broad a simple spectrum.""" 13 | x = np.linspace(50, 200, num=15) 14 | s = rx._misc.broaden_spectrum(x, [150, 100], [2, 1], scale=20.0) 15 | assert s == pytest.approx( 16 | [ 17 | 0.04316864, 18 | 0.1427911, 19 | 0.35495966, 20 | 0.66562891, 21 | 0.95481719, 22 | 1.09957335, 23 | 1.16005667, 24 | 1.34925384, 25 | 1.72176729, 26 | 2.0, 27 | 1.85988866, 28 | 1.32193171, 29 | 0.70860709, 30 | 0.28544362, 31 | 0.0863263, 32 | ], 33 | ) 34 | -------------------------------------------------------------------------------- /tests/test_regressions.py: -------------------------------------------------------------------------------- 1 | """Regressions against experimental/reference values. 2 | 3 | This also tests the high-level application programming interface. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import numpy as np 9 | import pytest 10 | from scipy import stats 11 | 12 | import overreact as rx 13 | from overreact import _constants as constants 14 | from overreact import _datasets as datasets 15 | 16 | # TODO(schneiderfelipe): transfer all comparisons with experimental/reference 17 | # values to this file. 18 | 19 | 20 | def test_basic_example_for_solvation_equilibria() -> None: 21 | """Reproduce literature data for AcOH(g) <=> AcOH(aq). 22 | 23 | Data is as cited in doi:10.1021/jp810292n and doi:10.1063/1.1416902, and 24 | is experimental except when otherwise indicated in the comments. 25 | """ 26 | model = rx.parse_model("data/acetate/Orca4/model.k") 27 | temperature = 298.15 28 | p_k = 4.756 # doi:10.1063/1.1416902 29 | 30 | acid_energy = -constants.R * temperature * np.log(10**-p_k) / constants.kcal 31 | solv_energy = ( 32 | -229.04018997 33 | - -229.075245654407 34 | + -228.764256345282 35 | - (-229.02825429 - -229.064152538732 + -228.749485597775) 36 | ) * (constants.hartree * constants.N_A / constants.kcal) 37 | charged_solv_energy = ( 38 | -228.59481510 39 | - -228.617274320359 40 | + -228.292486796947 41 | - (-228.47794098 - -228.500117698893 + -228.169992151890) 42 | ) * (constants.hartree * constants.N_A / constants.kcal) 43 | delta_freeenergies_ref = [ 44 | acid_energy, 45 | -acid_energy, 46 | solv_energy, 47 | -solv_energy, 48 | charged_solv_energy, 49 | -charged_solv_energy, 50 | ] 51 | 52 | concentration_correction = -temperature * rx.change_reference_state( 53 | temperature=temperature, 54 | ) 55 | 56 | for qrrho in [False, (False, True), True]: 57 | # TODO(schneiderfelipe): log the contribution of reaction symmetry 58 | delta_freeenergies = rx.get_delta( 59 | model.scheme.A, 60 | rx.get_freeenergies(model.compounds, temperature=temperature, qrrho=qrrho), 61 | ) - temperature * rx.get_reaction_entropies( 62 | model.scheme.A, 63 | temperature=temperature, 64 | ) 65 | assert delta_freeenergies / constants.kcal == pytest.approx( 66 | delta_freeenergies_ref, 67 | 7e-3, 68 | ) 69 | 70 | # the following tests the solvation free energy from doi:10.1021/jp810292n 71 | assert delta_freeenergies[2] / constants.kcal == pytest.approx( 72 | -6.70 + concentration_correction / constants.kcal, 73 | 1.5e-1, 74 | ) 75 | 76 | # the following tests the reaction free energy from doi:10.1063/1.1416902 77 | assert delta_freeenergies[0] == pytest.approx(27.147 * constants.kilo, 7e-3) 78 | assert delta_freeenergies[0] == pytest.approx( 79 | -constants.R * temperature * np.log(10**-p_k), 80 | 7e-3, 81 | ) 82 | 83 | k = rx.get_k( 84 | model.scheme, 85 | model.compounds, 86 | temperature=temperature, 87 | qrrho=qrrho, 88 | ) 89 | assert -np.log10(k[0] / k[1]) == pytest.approx(p_k, 7e-3) 90 | 91 | 92 | def test_basic_example_for_solvation_phase_kinetics() -> None: 93 | """Reproduce literature data for NH3(w) + OH·(w) -> NH2·(w) + H2O(w). 94 | 95 | This uses raw data from from doi:10.1002/qua.25686 and no calls from 96 | overreact.api. 97 | """ 98 | temperatures = np.array([298.15, 300, 310, 320, 330, 340, 350]) 99 | delta_freeenergies = np.array([10.5, 10.5, 10.8, 11.1, 11.4, 11.7, 11.9]) 100 | 101 | # 3-fold symmetry TS 102 | sym_correction = ( 103 | -temperatures 104 | * rx.change_reference_state(3, 1, temperature=temperatures) 105 | / constants.kcal 106 | ) 107 | assert delta_freeenergies + sym_correction == pytest.approx( 108 | [9.8, 9.9, 10.1, 10.4, 10.6, 10.9, 11.2], 109 | 8e-3, 110 | ) 111 | 112 | delta_freeenergies -= ( 113 | temperatures 114 | * rx.change_reference_state(temperature=temperatures) 115 | / constants.kcal 116 | ) # 1 atm to 1 M 117 | assert delta_freeenergies == pytest.approx( 118 | [8.6, 8.6, 8.8, 9.0, 9.2, 9.4, 9.6], 119 | 6e-3, 120 | ) 121 | 122 | # only concentration correction, no symmetry and no tunneling 123 | k = rx.rates.eyring(delta_freeenergies * constants.kcal, temperature=temperatures) 124 | assert k == pytest.approx([3.3e6, 3.4e6, 4.0e6, 4.7e6, 5.5e6, 6.4e6, 7.3e6], 8e-2) 125 | assert np.log10(k) == pytest.approx( 126 | np.log10([3.3e6, 3.4e6, 4.0e6, 4.7e6, 5.5e6, 6.4e6, 7.3e6]), 127 | 6e-3, 128 | ) 129 | 130 | # only concentration correction and symmetry, no tunneling 131 | delta_freeenergies += sym_correction 132 | assert delta_freeenergies == pytest.approx( 133 | [7.9, 7.9, 8.1, 8.3, 8.5, 8.7, 8.8], 134 | 7e-3, 135 | ) 136 | k = rx.rates.eyring(delta_freeenergies * constants.kcal, temperature=temperatures) 137 | assert k == pytest.approx([9.8e6, 1.0e7, 1.2e7, 1.4e7, 1.7e7, 1.9e7, 2.2e7], 8e-2) 138 | assert np.log10(k) == pytest.approx( 139 | np.log10([9.8e6, 1.0e7, 1.2e7, 1.4e7, 1.7e7, 1.9e7, 2.2e7]), 140 | 5e-3, 141 | ) 142 | 143 | # concentration correction, symmetry and tunneling included 144 | kappa = rx.tunnel.eckart( 145 | 986.79, 146 | 3.3 * constants.kcal, 147 | 16.4 * constants.kcal, 148 | temperature=temperatures, 149 | ) 150 | assert kappa == pytest.approx([2.3, 2.3, 2.2, 2.1, 2.0, 1.9, 1.9], 9e-2) 151 | 152 | k *= kappa 153 | assert k == pytest.approx([2.3e7, 2.4e7, 2.7e7, 3.0e7, 3.3e7, 3.7e7, 4.1e7], 1.1e-1) 154 | assert np.log10(k) == pytest.approx( 155 | np.log10([2.3e7, 2.4e7, 2.7e7, 3.0e7, 3.3e7, 3.7e7, 4.1e7]), 156 | 6e-3, 157 | ) 158 | 159 | 160 | def test_basic_example_for_gas_phase_kinetics() -> None: 161 | """Reproduce literature data for CH4 + Cl⋅ -> CH3· + HCl. 162 | 163 | This uses raw data from from doi:10.1002/qua.25686 and no calls from 164 | overreact.api. 165 | """ 166 | temperatures = np.array([200, 298.15, 300, 400]) 167 | delta_freeenergies = np.array([8.0, 10.3, 10.3, 12.6]) 168 | 169 | # 4-fold symmetry TS 170 | sym_correction = ( 171 | -temperatures 172 | * rx.change_reference_state(4, 1, temperature=temperatures) 173 | / constants.kcal 174 | ) 175 | assert delta_freeenergies + sym_correction == pytest.approx( 176 | [7.4, 9.4, 9.5, 11.5], 177 | 9e-3, 178 | ) 179 | 180 | delta_freeenergies -= ( 181 | temperatures 182 | * rx.change_reference_state(temperature=temperatures) 183 | / constants.kcal 184 | ) # 1 atm to 1 M 185 | assert delta_freeenergies == pytest.approx([6.9, 8.4, 8.4, 9.9], 8e-3) 186 | 187 | # only concentration correction, no symmetry and no tunneling 188 | k = rx.rates.eyring(delta_freeenergies * constants.kcal, temperature=temperatures) 189 | k = rx.rates.convert_rate_constant(k, "cm3 particle-1 s-1", molecularity=2) 190 | assert 1e16 * k == pytest.approx( 191 | 1e16 * np.array([2.2e-16, 7.5e-15, 7.9e-15, 5.6e-14]), 192 | 7e-2, 193 | ) 194 | assert np.log10(k) == pytest.approx( 195 | np.log10([2.2e-16, 7.5e-15, 7.9e-15, 5.6e-14]), 196 | 2e-3, 197 | ) 198 | 199 | # only concentration correction and symmetry, no tunneling 200 | delta_freeenergies += sym_correction 201 | assert delta_freeenergies == pytest.approx([6.3, 7.6, 7.6, 8.8], 9e-3) 202 | k = rx.rates.eyring(delta_freeenergies * constants.kcal, temperature=temperatures) 203 | k = rx.rates.convert_rate_constant(k, "cm3 particle-1 s-1", molecularity=2) 204 | assert 1e16 * k == pytest.approx( 205 | 1e16 * np.array([8.8e-16, 3.0e-14, 3.1e-14, 2.2e-13]), 206 | 8e-2, 207 | ) 208 | assert np.log10(k) == pytest.approx( 209 | np.log10([8.8e-16, 3.0e-14, 3.1e-14, 2.2e-13]), 210 | 3e-3, 211 | ) 212 | 213 | # concentration correction, symmetry and tunneling included 214 | kappa = rx.tunnel.wigner(1218, temperature=temperatures) 215 | assert kappa[0] == pytest.approx(4.2, 3e-4) 216 | assert kappa[2] == pytest.approx(2.4, 1e-2) 217 | 218 | kappa = rx.tunnel.eckart( 219 | 1218, 220 | 4.1 * constants.kcal, 221 | 3.4 * constants.kcal, 222 | temperature=temperatures, 223 | ) 224 | assert kappa == pytest.approx([17.1, 4.0, 3.9, 2.3], 2.1e-2) 225 | 226 | k *= kappa 227 | assert 1e16 * k == pytest.approx( 228 | 1e16 * np.array([1.5e-14, 1.2e-13, 1.2e-13, 5.1e-13]), 229 | 7e-2, 230 | ) 231 | assert np.log10(k) == pytest.approx( 232 | np.log10([1.5e-14, 1.2e-13, 1.2e-13, 5.1e-13]), 233 | 3e-3, 234 | ) 235 | 236 | 237 | def test_rate_constants_for_hickel1992() -> None: 238 | """Reproduce literature data for NH3(w) + OH·(w) -> NH2·(w) + H2O(w). 239 | 240 | Data is as cited in doi:10.1002/qua.25686 and is experimental except when 241 | otherwise indicated in the comments. 242 | 243 | Those tests check for consistency with the literature in terms of 244 | reaction rate constants. 245 | """ 246 | theory = "UM06-2X" 247 | basisset = "6-311++G(d,p)" 248 | model = rx.parse_model(f"data/hickel1992/{theory}/{basisset}/model.k") 249 | 250 | temperatures = np.array([298.15, 300, 310, 320, 330, 340, 350]) 251 | k_cla_ref = np.array([9.8e6, 1.0e7, 1.2e7, 1.4e7, 1.7e7, 1.9e7, 2.2e7]) 252 | k_eck_ref = np.array([2.3e7, 2.4e7, 2.7e7, 3.0e7, 3.3e7, 3.7e7, 4.1e7]) 253 | 254 | k_cla = [] 255 | k_eck = [] 256 | for temperature in temperatures: 257 | k_cla.append( 258 | rx.get_k( 259 | model.scheme, 260 | model.compounds, 261 | tunneling=None, 262 | qrrho=(False, True), 263 | scale="M-1 s-1", 264 | temperature=temperature, 265 | )[0], 266 | ) 267 | k_eck.append( 268 | rx.get_k( 269 | model.scheme, 270 | model.compounds, 271 | qrrho=(False, True), 272 | scale="M-1 s-1", 273 | temperature=temperature, 274 | )[0], 275 | ) 276 | k_cla = np.asarray(k_cla).flatten() 277 | k_eck = np.asarray(k_eck).flatten() 278 | assert k_eck / k_cla == pytest.approx([2.3, 2.3, 2.2, 2.1, 2.0, 1.9, 1.9], 7e-2) 279 | 280 | assert k_cla == pytest.approx(k_cla_ref, 1.2e-1) 281 | assert k_eck == pytest.approx(k_eck_ref, 9e-2) 282 | assert np.log10(k_cla) == pytest.approx(np.log10(k_cla_ref), 8e-3) 283 | assert np.log10(k_eck) == pytest.approx(np.log10(k_eck_ref), 5e-3) 284 | 285 | for k, k_ref, tols in zip( 286 | [k_cla, k_eck], 287 | [k_cla_ref, k_eck_ref], 288 | [(1.0e-1, 0.62, 2e-3, 5e-8, 3e-2), (1.1e-1, 0.75, 2e-3, 3e-8, 2e-2)], 289 | ): 290 | linregress = stats.linregress(np.log10(k), np.log10(k_ref)) 291 | assert linregress.slope == pytest.approx(1.0, tols[0]) 292 | assert linregress.intercept == pytest.approx(0.0, abs=tols[1]) 293 | 294 | assert linregress.rvalue**2 == pytest.approx(1.0, tols[2]) 295 | assert linregress.pvalue == pytest.approx(0.0, abs=tols[3]) 296 | assert linregress.pvalue < 0.01 297 | assert linregress.stderr == pytest.approx(0.0, abs=tols[4]) 298 | 299 | 300 | def test_rate_constants_for_tanaka1996() -> None: 301 | """Reproduce literature data for CH4 + Cl⋅ -> CH3· + HCl. 302 | 303 | Data is as cited in doi:10.1007/BF00058703 and doi:10.1002/qua.25686 and 304 | is experimental except when otherwise indicated in the comments. 305 | 306 | Those tests check for consistency with the literature in terms of 307 | reaction rate constants. 308 | """ 309 | theory = "UMP2" 310 | basisset = "cc-pVTZ" # not the basis used in the ref., but close enough 311 | model = rx.parse_model(f"data/tanaka1996/{theory}/{basisset}/model.k") 312 | 313 | temperatures = np.array( 314 | [ 315 | 200.0, 316 | # 210.0, 317 | # 220.0, 318 | # 230.0, 319 | # 240.0, 320 | # 250.0, 321 | # 260.0, 322 | # 270.0, 323 | # 280.0, 324 | # 290.0, 325 | 298.15, 326 | 300.0, 327 | 400.0, 328 | ], 329 | ) 330 | k_cla_ref = np.array([8.8e-16, 3.0e-14, 3.1e-14, 2.2e-13]) 331 | k_eck_ref = np.array([1.5e-14, 1.2e-13, 1.2e-13, 5.1e-13]) 332 | k_exp = np.array( 333 | [ 334 | 1.0e-14, 335 | # 1.4e-14, 336 | # 1.9e-14, 337 | # 2.5e-14, 338 | # 3.22e-14, 339 | # 4.07e-14, 340 | # 5.05e-14, 341 | # 6.16e-14, 342 | # 7.41e-14, 343 | # 8.81e-14, 344 | 10.0e-14, 345 | 10.3e-14, 346 | # no data for 400K? 347 | ], 348 | ) 349 | 350 | k_cla = [] 351 | k_wig = [] 352 | k_eck = [] 353 | for temperature in temperatures: 354 | k_cla.append( 355 | rx.get_k( 356 | model.scheme, 357 | model.compounds, 358 | tunneling=None, 359 | qrrho=True, 360 | scale="cm3 particle-1 s-1", 361 | temperature=temperature, 362 | )[0], 363 | ) 364 | k_wig.append( 365 | rx.get_k( 366 | model.scheme, 367 | model.compounds, 368 | tunneling="wigner", 369 | qrrho=True, 370 | scale="cm3 particle-1 s-1", 371 | temperature=temperature, 372 | )[0], 373 | ) 374 | k_eck.append( 375 | rx.get_k( 376 | model.scheme, 377 | model.compounds, 378 | qrrho=True, 379 | scale="cm3 particle-1 s-1", 380 | temperature=temperature, 381 | )[0], 382 | ) 383 | k_cla = np.asarray(k_cla).flatten() 384 | k_wig = np.asarray(k_wig).flatten() 385 | k_eck = np.asarray(k_eck).flatten() 386 | assert k_eck / k_cla == pytest.approx([17.1, 4.0, 3.9, 2.3], 1.7e-1) 387 | 388 | assert 1e16 * k_cla == pytest.approx(1e16 * k_cla_ref, 1.9e-1) 389 | assert 1e16 * k_eck == pytest.approx(1e16 * k_eck_ref, 3.2e-1) 390 | assert 1e16 * k_eck[:-1] == pytest.approx(1e16 * k_exp, 8e-2) 391 | assert np.log10(k_cla) == pytest.approx(np.log10(k_cla_ref), 6e-3) 392 | assert np.log10(k_eck) == pytest.approx(np.log10(k_eck_ref), 2e-2) 393 | assert np.log10(k_eck[:-1]) == pytest.approx(np.log10(k_exp), 3e-3) 394 | 395 | for k, k_ref, tols in zip( 396 | [k_cla, k_eck, k_eck[:-1]], 397 | [k_cla_ref, k_eck_ref, k_exp], 398 | [ 399 | (2e-2, 0.08, 9e-6, 5e-6, 3e-3), 400 | (5e-2, 0.52, 4e-4, 2e-4, 2e-2), 401 | (5e-2, 0.60, 3e-6, 2e-3, 2e-3), 402 | ], 403 | ): 404 | linregress = stats.linregress(np.log10(k), np.log10(k_ref)) 405 | assert linregress.slope == pytest.approx(1.0, tols[0]) 406 | assert linregress.intercept == pytest.approx(0.0, abs=tols[1]) 407 | 408 | assert linregress.rvalue**2 == pytest.approx(1.0, tols[2]) 409 | assert linregress.pvalue == pytest.approx(0.0, abs=tols[3]) 410 | assert linregress.pvalue < 0.01 411 | assert linregress.stderr == pytest.approx(0.0, abs=tols[4]) 412 | 413 | 414 | def test_delta_energies_for_hickel1992() -> None: 415 | """Reproduce literature data for NH3(w) + OH·(w) -> NH2·(w) + H2O(w). 416 | 417 | Data is as cited in doi:10.1002/qua.25686 and is experimental except when 418 | otherwise indicated in the comments. 419 | 420 | Those tests check for consistency with the literature in terms of 421 | chemical kinetics and thermochemistry. 422 | """ 423 | theory = "UM06-2X" 424 | basisset = "6-311++G(d,p)" 425 | model = rx.parse_model(f"data/hickel1992/{theory}/{basisset}/model.k") 426 | 427 | temperatures = np.array([298.15, 300, 310, 320, 330, 340, 350]) 428 | delta_freeenergies_ref = [9.8, 9.9, 10.1, 10.4, 10.6, 10.9, 11.2] 429 | 430 | delta_freeenergies = [] 431 | for temperature in temperatures: 432 | freeenergies = rx.get_freeenergies( 433 | model.compounds, 434 | temperature=temperature, 435 | qrrho=(False, True), 436 | ) 437 | delta_freeenergy = ( 438 | rx.get_delta(model.scheme.B, freeenergies) 439 | - temperature 440 | * rx.get_reaction_entropies(model.scheme.B, temperature=temperature) 441 | )[0] 442 | 443 | delta_freeenergies.append(delta_freeenergy) 444 | delta_freeenergies = np.asarray(delta_freeenergies) 445 | 446 | assert delta_freeenergies / constants.kcal == pytest.approx( 447 | delta_freeenergies_ref 448 | - temperatures 449 | * rx.change_reference_state(temperature=temperatures) 450 | / constants.kcal, 451 | 2e-2, 452 | ) # M06-2X/6-311++G(d,p) from doi:10.1002/qua.25686 453 | 454 | # extra symmetry is required for this reaction since the transition state 455 | # is nonsymmetric 456 | assert model.compounds["NH3·OH#(w)"].symmetry == 3 457 | 458 | delta_freeenergies_ref = [7.9, 7.9, 8.1, 8.3, 8.5, 8.7, 8.8] 459 | assert delta_freeenergies / constants.kcal == pytest.approx( 460 | delta_freeenergies_ref, 461 | 2e-2, 462 | ) # M06-2X/6-311++G(d,p) from doi:10.1002/qua.25686 463 | 464 | 465 | def test_delta_energies_for_tanaka1996() -> None: 466 | """Reproduce literature data for CH4 + Cl⋅ -> CH3· + HCl. 467 | 468 | Data is as cited in doi:10.1007/BF00058703 and doi:10.1002/qua.25686 and 469 | is experimental except when otherwise indicated in the comments. 470 | 471 | Those tests check for consistency with the literature in terms of 472 | chemical kinetics and thermochemistry. 473 | """ 474 | theory = "UMP2" 475 | basisset = "6-311G(2d,p)" 476 | model = rx.parse_model(f"data/tanaka1996/{theory}/{basisset}/model.k") 477 | 478 | temperatures = [0.0] 479 | delta_freeenergies_ref = [5.98] 480 | 481 | delta_freeenergies = [] 482 | for temperature in temperatures: 483 | freeenergies = rx.get_freeenergies(model.compounds, temperature=temperature) 484 | delta_freeenergy = ( 485 | rx.get_delta(model.scheme.B, freeenergies) 486 | - temperature 487 | * rx.get_reaction_entropies(model.scheme.B, temperature=temperature)[0] 488 | )[0] 489 | 490 | delta_freeenergies.append(delta_freeenergy) 491 | delta_freeenergies = np.asarray(delta_freeenergies) 492 | 493 | assert delta_freeenergies / constants.kcal == pytest.approx( 494 | delta_freeenergies_ref, 495 | 4e-2, 496 | ) # UMP2/6-311G(2d,p) doi:10.1007/BF00058703 497 | 498 | # testing now another level of theory! 499 | basisset = "cc-pVTZ" # not the basis used in the ref., but close enough 500 | model = rx.parse_model(f"data/tanaka1996/{theory}/{basisset}/model.k") 501 | 502 | # no extra symmetry required for this reaction since the transition state 503 | # is symmetric 504 | assert model.compounds["H3CHCl‡"].symmetry is None 505 | 506 | temperatures = np.array([200, 298.15, 300, 400]) 507 | delta_freeenergies_ref = [7.4, 9.4, 9.5, 11.5] 508 | 509 | delta_freeenergies = [] 510 | for temperature in temperatures: 511 | freeenergies = rx.get_freeenergies(model.compounds, temperature=temperature) 512 | delta_freeenergy = ( 513 | rx.get_delta(model.scheme.B, freeenergies) 514 | - temperature 515 | * rx.get_reaction_entropies(model.scheme.B, temperature=temperature)[0] 516 | )[0] 517 | 518 | delta_freeenergies.append(delta_freeenergy) 519 | delta_freeenergies = np.asarray(delta_freeenergies) 520 | 521 | assert delta_freeenergies / constants.kcal == pytest.approx( 522 | delta_freeenergies_ref, 523 | 2e-2, 524 | ) # UMP2/6-311G(3d,2p) from doi:10.1002/qua.25686 525 | 526 | 527 | def test_logfiles_for_hickel1992() -> None: 528 | """Reproduce literature data for NH3(w) + OH·(w) -> NH2·(w) + H2O(w). 529 | 530 | Data is as cited in doi:10.1002/qua.25686 and is experimental except when 531 | otherwise indicated in the comments. 532 | 533 | Those tests check for details in the logfiles such as point group symmetry 534 | and frequencies. 535 | """ 536 | theory = "UM06-2X" 537 | basisset = "6-311++G(d,p)" 538 | 539 | data = datasets.logfiles["hickel1992"][f"NH3@{theory}/{basisset}"] 540 | point_group = rx.coords.find_point_group(data.atommasses, data.atomcoords) 541 | assert rx.coords.symmetry_number(point_group) == 3 542 | 543 | assert data.vibfreqs == pytest.approx( 544 | [1022, 1691, 1691, 3506, 3577, 3577], 545 | 5e-2, 546 | ) # doi:10.1002/qua.25686 547 | assert data.vibfreqs == pytest.approx( 548 | [1065.8, 1621.5, 1620.6, 3500.2, 3615.5, 3617.3], 549 | 4e-2, 550 | ) # M06-2X/6-311++G(d,p) from doi:10.1002/qua.25686 551 | 552 | data = datasets.logfiles["hickel1992"][f"OH·@{theory}/{basisset}"] 553 | point_group = rx.coords.find_point_group(data.atommasses, data.atomcoords) 554 | assert rx.coords.symmetry_number(point_group) == 1 555 | 556 | assert data.vibfreqs == pytest.approx([3737.8], 2e-2) # doi:10.1002/qua.25686 557 | assert data.vibfreqs == pytest.approx( 558 | [3724.3], 559 | 2e-2, 560 | ) # M06-2X/6-311++G(d,p) from doi:10.1002/qua.25686 561 | 562 | data = datasets.logfiles["hickel1992"][f"NH2·@{theory}/{basisset}"] 563 | point_group = rx.coords.find_point_group(data.atommasses, data.atomcoords) 564 | assert rx.coords.symmetry_number(point_group) == 2 565 | 566 | assert data.vibfreqs == pytest.approx( 567 | [1497.3, 3220.0, 3301.1], 568 | 7e-2, 569 | ) # doi:10.1002/qua.25686 570 | assert data.vibfreqs == pytest.approx( 571 | [1471.2, 3417.6, 3500.8], 572 | 9e-3, 573 | ) # M06-2X/6-311++G(d,p) from doi:10.1002/qua.25686 574 | 575 | data = datasets.logfiles["hickel1992"][f"H2O@{theory}/{basisset}"] 576 | point_group = rx.coords.find_point_group(data.atommasses, data.atomcoords) 577 | assert rx.coords.symmetry_number(point_group) == 2 578 | 579 | assert data.vibfreqs == pytest.approx( 580 | [1594.6, 3656.7, 3755.8], 581 | 6e-2, 582 | ) # doi:10.1002/qua.25686 583 | assert data.vibfreqs == pytest.approx( 584 | [1570.4, 3847.9, 3928.9], 585 | 6e-3, 586 | ) # M06-2X/6-311++G(d,p) from doi:10.1002/qua.25686 587 | 588 | data = datasets.logfiles["hickel1992"][f"NH3·OH@{theory}/{basisset}"] 589 | point_group = rx.coords.find_point_group(data.atommasses, data.atomcoords) 590 | assert rx.coords.symmetry_number(point_group) == 1 591 | 592 | # NOTE(schneiderfelipe): I couldn't find any frequency reference data for 593 | # this transition state. 594 | 595 | 596 | def test_logfiles_for_tanaka1996() -> None: 597 | """Reproduce literature data for CH4 + Cl⋅ -> CH3· + HCl. 598 | 599 | Data is as cited in doi:10.1007/BF00058703 and doi:10.1002/qua.25686 and 600 | is experimental except when otherwise indicated in the comments. 601 | 602 | Those tests check for details in the logfiles such as point group symmetry 603 | and frequencies. 604 | """ 605 | theory = "UMP2" 606 | basisset = "cc-pVTZ" # not the basis used in the ref., but close enough 607 | 608 | # CH4 609 | data = datasets.logfiles["tanaka1996"][f"methane@{theory}/{basisset}"] 610 | point_group = rx.coords.find_point_group(data.atommasses, data.atomcoords) 611 | assert rx.coords.symmetry_number(point_group) == 12 612 | 613 | assert data.vibfreqs == pytest.approx( 614 | [1306, 1306, 1306, 1534, 1534, 2917, 3019, 3019, 3019], 615 | 7e-2, 616 | ) # doi:10.1002/qua.25686 617 | assert data.vibfreqs == pytest.approx( 618 | [1367, 1367, 1367, 1598, 1598, 3070, 3203, 3203, 3205], 619 | 8e-3, 620 | ) # UMP2/6-311G(3d,2p) from doi:10.1002/qua.25686 621 | 622 | # CH3· 623 | data = datasets.logfiles["tanaka1996"][f"CH3·@{theory}/{basisset}"] 624 | point_group = rx.coords.find_point_group(data.atommasses, data.atomcoords) 625 | assert rx.coords.symmetry_number(point_group) == 6 626 | 627 | assert data.vibfreqs == pytest.approx( 628 | [580, 1383, 1383, 3002, 3184, 3184], 629 | 1.4e-1, 630 | ) # doi:10.1002/qua.25686 631 | assert data.vibfreqs == pytest.approx( 632 | [432, 1454, 1454, 3169, 3360, 3360], 633 | 1.7e-1, 634 | ) # UMP2/6-311G(3d,2p) from doi:10.1002/qua.25686 635 | 636 | # HCl 637 | data = datasets.logfiles["tanaka1996"][f"HCl@{theory}/{basisset}"] 638 | point_group = rx.coords.find_point_group(data.atommasses, data.atomcoords) 639 | assert rx.coords.symmetry_number(point_group) == 1 640 | 641 | assert data.vibfreqs == pytest.approx([2991], 3e-2) # doi:10.1002/qua.25686 642 | assert data.vibfreqs == pytest.approx( 643 | [3028], 644 | 9e-3, 645 | ) # UMP2/6-311G(3d,2p) from doi:10.1002/qua.25686 646 | 647 | # Cl· 648 | data = datasets.logfiles["tanaka1996"][f"Cl·@{theory}/{basisset}"] 649 | point_group = rx.coords.find_point_group(data.atommasses, data.atomcoords) 650 | assert rx.coords.symmetry_number(point_group) == 1 651 | 652 | # CH3-H-Cl 653 | data = datasets.logfiles["tanaka1996"][f"H3CHCl‡@{theory}/{basisset}"] 654 | point_group = rx.coords.find_point_group(data.atommasses, data.atomcoords) 655 | assert rx.coords.symmetry_number(point_group) == 3 656 | 657 | # NOTE(schneiderfelipe): vibrations are from an UMP2/6-311G(3d,2p) 658 | # calculation, see the reference in doi:10.1007/BF00058703 659 | assert data.vibfreqs == pytest.approx( 660 | [ 661 | -1214.38, 662 | 368.97, 663 | 369.97, 664 | 515.68, 665 | 962.03, 666 | 962.03, 667 | 1217.20, 668 | 1459.97, 669 | 1459.97, 670 | 3123.29, 671 | 3293.74, 672 | 3293.74, 673 | ], 674 | 6e-2, 675 | ) 676 | assert data.vibfreqs == pytest.approx( 677 | [-1218, 369, 369, 516, 962, 962, 1217, 1460, 1460, 3123, 3294, 3294], 678 | 7e-2, 679 | ) # UMP2/6-311G(3d,2p) from doi:10.1002/qua.25686 680 | -------------------------------------------------------------------------------- /tests/test_simulate.py: -------------------------------------------------------------------------------- 1 | """Tests for simulate module.""" 2 | 3 | from __future__ import annotations 4 | 5 | import numpy as np 6 | import pytest 7 | 8 | import overreact as rx 9 | from overreact import simulate 10 | 11 | 12 | def test_get_dydt_calculates_reaction_rate() -> None: 13 | """Ensure get_dydt gives correct reaction rates.""" 14 | scheme = rx.Scheme( 15 | compounds=["A", "B"], 16 | reactions=["A -> B"], 17 | is_half_equilibrium=np.array([False]), 18 | A=np.array([[-1.0], [1.0]]), 19 | B=np.array([[-1.0], [1.0]]), 20 | ) 21 | 22 | # with jitted dydt, we need to use np.ndarray 23 | k = np.array([2.0]) 24 | dydt = simulate.get_dydt(scheme, k) 25 | 26 | # if JAX is used, dydt won't accept lists, only np.ndarray 27 | assert dydt(0.0, np.array([1.0, 0.0])) == pytest.approx([-2.0, 2.0]) 28 | assert dydt(5.0, np.array([1.0, 0.0])) == pytest.approx([-2.0, 2.0]) 29 | assert dydt(0.0, np.array([1.0, 1.0])) == pytest.approx([-2.0, 2.0]) 30 | assert dydt(0.0, np.array([10.0, 0.0])) == pytest.approx([-20.0, 20.0]) 31 | 32 | 33 | def test_get_y_propagates_reaction_automatically() -> None: 34 | """Ensure get_y properly propagates reactions with automatic time span.""" 35 | scheme = rx.Scheme( 36 | compounds=["A", "B", "AB4"], 37 | reactions=["A + 4 B -> AB4", "AB4 -> A + 4 B"], 38 | is_half_equilibrium=np.array([True, True]), 39 | A=np.array([[-1.0, 1.0], [-4.0, 4.0], [1.0, -1.0]]), 40 | B=np.array([[-1.0, 0.0], [-4.0, 0.0], [1.0, 0.0]]), 41 | ) 42 | y0 = [2.00, 2.00, 0.01] 43 | 44 | # with jitted dydt, we need to use np.ndarray 45 | k = np.array([1.0, 1.0]) 46 | y, r = simulate.get_y(simulate.get_dydt(scheme, k), y0=y0) 47 | 48 | assert y.t_min == 0.0 49 | assert y.t_max >= 300.0 50 | assert y(y.t_min) == pytest.approx(y0) 51 | assert y(y.t_max) == pytest.approx( 52 | [1.668212890625, 0.6728515625, 0.341787109375], 53 | 4e-4, 54 | ) 55 | assert r(y.t_min) == pytest.approx([-31.99, -127.96, 31.99]) 56 | assert r(y.t_max) == pytest.approx([0.0, 0.0, 0.0], abs=3e-3) 57 | 58 | 59 | def test_get_y_propagates_reaction_with_fixed_time() -> None: 60 | """Ensure get_y properly propagates reactions when given time span.""" 61 | scheme = rx.Scheme( 62 | compounds=["A", "B", "AB4"], 63 | reactions=["A + 4 B -> AB4", "AB4 -> A + 4 B"], 64 | is_half_equilibrium=np.array([True, True]), 65 | A=np.array([[-1.0, 1.0], [-4.0, 4.0], [1.0, -1.0]]), 66 | B=np.array([[-1.0, 0.0], [-4.0, 0.0], [1.0, 0.0]]), 67 | ) 68 | y0 = [2.00, 2.00, 0.01] 69 | t_span = [0.0, 200.0] 70 | 71 | # with jitted dydt, we need to use np.ndarray 72 | k = np.array([1.0, 1.0]) 73 | y, r = simulate.get_y( 74 | simulate.get_dydt(scheme, k), 75 | y0=y0, 76 | t_span=t_span, 77 | ) 78 | 79 | assert y.t_min == t_span[0] 80 | assert y.t_max == t_span[-1] 81 | assert y(y.t_min) == pytest.approx(y0) 82 | assert y(y.t_max) == pytest.approx( 83 | [1.668212890625, 0.6728515625, 0.341787109375], 84 | 3e-3, 85 | ) 86 | assert r(y.t_min) == pytest.approx([-31.99, -127.96, 31.99]) 87 | assert r(y.t_max) == pytest.approx([0.0, 0.0, 0.0], abs=1.3e-2) 88 | 89 | 90 | def test_get_y_conservation_in_equilibria() -> None: 91 | """Ensure get_y properly conserves matter in a toy equilibrium.""" 92 | scheme = rx.parse_reactions("A <=> B") 93 | y0 = [1, 0] 94 | 95 | # with jitted dydt, we need to use np.ndarray 96 | k = np.array([1, 1]) 97 | y, r = simulate.get_y(simulate.get_dydt(scheme, k), y0=y0) 98 | t = np.linspace(y.t_min, y.t_max, num=100) 99 | 100 | assert y.t_min == 0.0 101 | assert y.t_max >= 3.0 102 | assert y(y.t_min) == pytest.approx(y0) 103 | assert y(y.t_max) == pytest.approx([0.5, 0.5], 3e-3) 104 | assert r(y.t_min) == pytest.approx([-1, 1]) 105 | assert r(y.t_max) == pytest.approx([0.0, 0.0], abs=3e-3) 106 | 107 | assert y.t_min == t[0] 108 | assert y.t_max == t[-1] 109 | assert np.allclose(y(t)[0] + y(t)[1], np.sum(y0)) 110 | assert np.allclose(r(t)[0] + r(t)[1], 0.0) 111 | 112 | 113 | @pytest.mark.parametrize( 114 | ("cat0", "sub0", "keq", "kcat", "method"), 115 | [ 116 | (cat0, sub0, keq, kcat, method) 117 | for cat0 in [0.3, 0.4] 118 | for sub0 in [0.01, 0.02] 119 | for keq in [1.0, 10.0, 100.0] 120 | for kcat in [1e-1, 1e1, 1e10, 1e11, 1e13] 121 | for method in ("RK23", "LSODA", "Radau", "BDF") 122 | ], 123 | ) 124 | def test_simple_michaelis_menten( 125 | cat0: float, 126 | sub0: float, 127 | keq: float, 128 | kcat: float, 129 | method: str, 130 | ) -> None: 131 | """ 132 | Test a simple Michaelis-Menten model. 133 | 134 | See . 135 | """ 136 | scheme = rx.parse_reactions( 137 | """ 138 | C + S <=> CS // Pre-equilibrium 139 | CS -> TS‡ -> C + P // Catalyst is released 140 | """, 141 | ) 142 | y0 = np.array([cat0, sub0, 0.0, 0.0, 0.0]) 143 | 144 | # with jitted dydt, we need to use np.ndarray 145 | k = np.array([keq, 1.0, kcat]) 146 | dydt = simulate.get_dydt(scheme, k) 147 | 148 | # equilibrium constant is kept 149 | assert np.allclose(dydt.k[0] / dydt.k[1], k[0] / k[1]) 150 | 151 | # actual reaction does not change 152 | assert np.allclose(dydt.k[2], k[2]) 153 | 154 | y, r = simulate.get_y(dydt, y0=y0, method=method) 155 | 156 | # catalyst is completely regenerated in the end 157 | # the substrate is completely consumed in the end 158 | assert np.allclose(y(y.t_max), [cat0, 0.0, 0.0, 0.0, sub0], atol=2e-5) 159 | 160 | 161 | @pytest.mark.parametrize( 162 | ("cat0", "sub0", "keq", "kcat", "method"), 163 | [ 164 | (cat0, sub0, keq, kcat, method) 165 | for cat0 in [0.3, 0.4] 166 | for sub0 in [0.01, 0.02] 167 | for keq in [1.0, 10.0, 100.0] 168 | for kcat in [1e-1, 1e1, 1e10, 1e11, 1e13] 169 | for method in ("RK23", "LSODA", "Radau", "BDF") 170 | ], 171 | ) 172 | def test_consuming_michaelis_menten( 173 | cat0: float, 174 | sub0: float, 175 | keq: float, 176 | kcat: float, 177 | method: str, 178 | ) -> None: 179 | """Test a faulty system as suggested by @bmounssefjr.""" 180 | scheme = rx.parse_reactions( 181 | """ 182 | C + S <=> CS // Pre-equilibrium 183 | CS -> TS‡ -> P // Catalyst is consumed instead of being released 184 | """, 185 | ) 186 | y0 = np.array([cat0, sub0, 0.0, 0.0, 0.0]) 187 | 188 | # with jitted dydt, we need to use np.ndarray 189 | k = np.array([keq, 1.0, kcat]) 190 | dydt = simulate.get_dydt(scheme, k) 191 | 192 | # equilibrium constant is kept 193 | assert np.allclose(dydt.k[0] / dydt.k[1], k[0] / k[1]) 194 | 195 | # actual reaction does not change 196 | assert np.allclose(dydt.k[2], k[2]) 197 | 198 | y, r = simulate.get_y(dydt, y0=y0, method=method) 199 | 200 | # catalyst is completely consumed in the end 201 | # the substrate is completely consumed in the end 202 | assert np.allclose(y(y.t_max), [cat0 - sub0, 0.0, 0.0, 0.0, sub0], atol=2e-5) 203 | -------------------------------------------------------------------------------- /tests/test_tunnel.py: -------------------------------------------------------------------------------- 1 | """Tests for tunnel module.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | 7 | import overreact as rx 8 | 9 | 10 | def test_wigner_tunneling_corrections_are_correct() -> None: 11 | """Ensure Wigner tunneling values are correct.""" 12 | with pytest.raises(ValueError, match="vibfreq should not be zero for tunneling"): 13 | rx.tunnel.wigner(0.0) 14 | 15 | assert rx.tunnel.wigner(0.1) == pytest.approx(1.0, 1e-8) 16 | 17 | # values below are Wigner corrections at the lower limit for which the 18 | # Eckart correction is computable 19 | assert rx.tunnel.wigner(59) == pytest.approx(1.0033776142915123) 20 | assert rx.tunnel.wigner(158) == pytest.approx(1.0242225691391296) 21 | 22 | assert rx.tunnel.wigner(1218, temperature=[200, 298.15, 300, 400]) == pytest.approx( 23 | [4.2, 2.4, 2.4, 1.8], 24 | 1.7e-2, 25 | ) 26 | 27 | 28 | def test_eckart_tunneling_corrections_are_correct() -> None: 29 | """Ensure Eckart tunneling values are correct.""" 30 | with pytest.raises(ValueError, match="vibfreq should not be zero for tunneling"): 31 | rx.tunnel.eckart(0.0, 15781.6) 32 | 33 | with pytest.raises(ValueError, match="vibfreq should not be zero for tunneling"): 34 | rx.tunnel.eckart(0.0, 56813.61, 94689.35) 35 | 36 | # values below are at the lower limit for which Eckart is computable 37 | assert rx.tunnel.eckart(59, 15781.6) == pytest.approx(1.01679385) 38 | assert rx.tunnel.eckart(158, 56813.61, 94689.35) == pytest.approx(1.02392807) 39 | 40 | # a selection for testing higher precision from doi:10.1021/j100809a040 41 | assert rx.tunnel.eckart(414.45, 15781.6, 15781.6) == pytest.approx(1.2, 9e-3) 42 | assert rx.tunnel.eckart(414.45, 1578.16, 1578.16) == pytest.approx(1.32, 2e-3) 43 | assert rx.tunnel.eckart(1243.35, 9468.94, 28406.81) == pytest.approx(3.39, 2e-4) 44 | assert rx.tunnel.eckart(3315.6, 12625.25, 126252.50) == pytest.approx(23.3, 3e-4) 45 | assert rx.tunnel.eckart(3315.6, 12625.25, 12625.25) == pytest.approx(34.0, 2e-4) 46 | assert rx.tunnel.eckart(2486.7, 56813.61, 94689.35) == pytest.approx(3920, 1e-4) 47 | 48 | 49 | def test_eckart_is_symmetric() -> None: 50 | """Ensure Eckart has symmetry under interchange of H1 and H2.""" 51 | vibfreq = 3e3 52 | h1 = 1e4 53 | h2 = 10 * h1 54 | assert rx.tunnel.eckart(vibfreq, h1, h2) == rx.tunnel.eckart(vibfreq, h2, h1) 55 | 56 | 57 | def test_low_level_eckart_against_johnston1962() -> None: 58 | """Reproduce all values from Table 1 of doi:10.1021/j100809a040.""" 59 | us = [2, 3, 4, 5, 6, 8, 10, 12, 16] # columns 60 | 61 | # The first examples have very low barriers and comprise the "imaginary 62 | # region" (4 * alpha1 * alpha2 - pi**2 < 0). The precision of our 63 | # implementation is a bit reduced in this region, in comparison to the 64 | # number of decimal places available to compare against. We are 65 | # nevertheless able to reproduce all decimal places for the majority of the 66 | # examples. 67 | 68 | gammas = [1.16, 1.25, 1.34, 1.44, 1.55, 1.80, 2.09, 2.42, 3.26] 69 | for u, gamma in zip(us, gammas): 70 | assert rx.tunnel._eckart(u, 0.5) == pytest.approx(gamma, 2e-2) 71 | 72 | gammas = [1.13, 1.21, 1.29, 1.38, 1.47, 1.68, 1.93, 2.22, 2.94] 73 | for u, gamma in zip(us, gammas): 74 | assert rx.tunnel._eckart(u, 0.5, 1) == pytest.approx( 75 | gamma, 76 | 2e-2, 77 | ) 78 | 79 | gammas = [1.09, 1.14, 1.20, 1.27, 1.34, 1.51, 1.71, 1.94, 2.53] 80 | for u, gamma in zip(us, gammas): 81 | assert rx.tunnel._eckart(u, 0.5, 2) == pytest.approx( 82 | gamma, 83 | 9e-3, 84 | ) 85 | 86 | gammas = [1.04, 1.07, 1.11, 1.16, 1.22, 1.35, 1.50, 1.69, 2.16] 87 | for u, gamma in zip(us, gammas): 88 | assert rx.tunnel._eckart(u, 0.5, 4) == pytest.approx( 89 | gamma, 90 | 1e-2, 91 | ) 92 | 93 | gammas = [0.99, 1.00, 1.03, 1.06, 1.11, 1.21, 1.34, 1.49, 1.88] 94 | for u, gamma in zip(us, gammas): 95 | assert rx.tunnel._eckart(u, 0.5, 8) == pytest.approx( 96 | gamma, 97 | 9e-3, 98 | ) 99 | 100 | gammas = [0.96, 0.97, 0.99, 1.02, 1.06, 1.15, 1.26, 1.40, 1.76] 101 | for u, gamma in zip(us, gammas): 102 | assert rx.tunnel._eckart(u, 0.5, 12) == pytest.approx( 103 | gamma, 104 | 8e-3, 105 | ) 106 | 107 | gammas = [0.94, 0.95, 0.97, 0.99, 1.02, 1.11, 1.22, 1.35, 1.68] 108 | for u, gamma in zip(us, gammas): 109 | assert rx.tunnel._eckart(u, 0.5, 16) == pytest.approx( 110 | gamma, 111 | 8e-3, 112 | ) 113 | 114 | gammas = [0.93, 0.94, 0.95, 0.97, 1.00, 1.08, 1.19, 1.31, 1.64] 115 | for u, gamma in zip(us, gammas): 116 | assert rx.tunnel._eckart(u, 0.5, 20) == pytest.approx( 117 | gamma, 118 | 1e-2, 119 | ) 120 | 121 | gammas = [1.27, 1.43, 1.62, 1.83, 2.09, 2.72, 3.56, 4.68, 8.19] 122 | for u, gamma in zip(us, gammas): 123 | assert rx.tunnel._eckart(u, 1) == pytest.approx(gamma, 2e-2) 124 | 125 | gammas = [1.21, 1.35, 1.51, 1.71, 1.93, 2.50, 3.26, 4.28, 7.48] 126 | for u, gamma in zip(us, gammas): 127 | assert rx.tunnel._eckart(u, 1, 2) == pytest.approx(gamma, 6e-3) 128 | 129 | gammas = [1.14, 1.24, 1.37, 1.53, 1.71, 2.16, 2.78, 3.60, 6.16] 130 | for u, gamma in zip(us, gammas): 131 | assert rx.tunnel._eckart(u, 1, 4) == pytest.approx(gamma, 5e-3) 132 | 133 | gammas = [1.08, 1.16, 1.26, 1.39, 1.54, 1.92, 2.43, 3.12, 5.25] 134 | for u, gamma in zip(us, gammas): 135 | assert rx.tunnel._eckart(u, 1, 8) == pytest.approx(gamma, 5e-3) 136 | 137 | gammas = [1.06, 1.12, 1.21, 1.33, 1.46, 1.81, 2.28, 2.91, 4.88] 138 | for u, gamma in zip(us, gammas): 139 | assert rx.tunnel._eckart(u, 1, 12) == pytest.approx(gamma, 9e-3) 140 | 141 | gammas = [1.04, 1.10, 1.18, 1.29, 1.42, 1.75, 2.20, 2.80, 4.66] 142 | for u, gamma in zip(us, gammas): 143 | assert rx.tunnel._eckart(u, 1, 16) == pytest.approx(gamma, 5e-3) 144 | 145 | gammas = [1.03, 1.08, 1.16, 1.26, 1.39, 1.70, 2.14, 2.72, 4.52] 146 | for u, gamma in zip(us, gammas): 147 | assert rx.tunnel._eckart(u, 1, 20) == pytest.approx(gamma, 7e-3) 148 | 149 | # The next examples are not in the imaginary region anymore and our 150 | # precision is improved. I believe that here are the most important cases 151 | # for chemistry. We get precisely all decimal places for all examples in 152 | # this section. 153 | 154 | gammas = [1.32, 1.58, 1.91, 2.34, 2.90, 4.55, 7.34, 12.1, 34.0] 155 | for u, gamma in zip(us, gammas): 156 | assert rx.tunnel._eckart(u, 2) == pytest.approx(gamma, 4e-3) 157 | 158 | gammas = [1.26, 1.47, 1.77, 2.16, 2.66, 4.20, 6.85, 11.4, 33.4] 159 | for u, gamma in zip(us, gammas): 160 | assert rx.tunnel._eckart(u, 2, 4) == pytest.approx(gamma, 5e-3) 161 | 162 | gammas = [1.19, 1.36, 1.61, 1.93, 2.36, 3.65, 5.87, 9.69, 28.0] 163 | for u, gamma in zip(us, gammas): 164 | assert rx.tunnel._eckart(u, 2, 8) == pytest.approx(gamma, 4e-3) 165 | 166 | gammas = [1.16, 1.32, 1.54, 1.84, 2.23, 3.41, 5.44, 8.94, 25.6] 167 | for u, gamma in zip(us, gammas): 168 | assert rx.tunnel._eckart(u, 2, 12) == pytest.approx(gamma, 5e-3) 169 | 170 | gammas = [1.14, 1.29, 1.50, 1.78, 2.15, 3.27, 5.20, 8.51, 24.2] 171 | for u, gamma in zip(us, gammas): 172 | assert rx.tunnel._eckart(u, 2, 16) == pytest.approx(gamma, 4e-3) 173 | 174 | gammas = [1.12, 1.27, 1.47, 1.74, 2.10, 3.18, 5.03, 8.22, 23.3] 175 | for u, gamma in zip(us, gammas): 176 | assert rx.tunnel._eckart(u, 2, 20) == pytest.approx(gamma, 4e-3) 177 | 178 | gammas = [1.30, 1.58, 2.02, 2.69, 3.69, 7.60, 17.3, 42.4, 304] 179 | for u, gamma in zip(us, gammas): 180 | assert rx.tunnel._eckart(u, 4, 4) == pytest.approx(gamma, 8e-3) 181 | 182 | gammas = [1.25, 1.51, 1.93, 2.56, 3.56, 7.57, 18.0, 46.7, 376] 183 | for u, gamma in zip(us, gammas): 184 | assert rx.tunnel._eckart(u, 4, 8) == pytest.approx(gamma, 5e-3) 185 | 186 | gammas = [1.22, 1.47, 1.86, 2.46, 3.39, 7.16, 17.0, 44.0, 354] 187 | for u, gamma in zip(us, gammas): 188 | assert rx.tunnel._eckart(u, 4, 12) == pytest.approx(gamma, 3e-3) 189 | 190 | gammas = [1.20, 1.44, 1.81, 2.39, 3.28, 6.88, 16.2, 41.9, 335] 191 | for u, gamma in zip(us, gammas): 192 | assert rx.tunnel._eckart(u, 4, 16) == pytest.approx(gamma, 3e-3) 193 | 194 | gammas = [1.19, 1.42, 1.78, 2.34, 3.20, 6.68, 15.7, 40.3, 321] 195 | for u, gamma in zip(us, gammas): 196 | assert rx.tunnel._eckart(u, 4, 20) == pytest.approx(gamma, 2e-3) 197 | 198 | # The examples below have larger tunneling corrections and the precision of 199 | # our implementation is again reduced, but we still reproduce all decimal 200 | # places for most of the examples here. 201 | 202 | gammas = [1.24, 1.56, 2.04, 2.94, 4.54, 13.8, 57.0, 307] 203 | for u, gamma in zip(us[:-1], gammas): 204 | assert rx.tunnel._eckart(u, 8, 8) == pytest.approx(gamma, 2e-2) 205 | 206 | gammas = [1.22, 1.54, 2.04, 2.96, 4.68, 15.4, 71.7, 445] 207 | for u, gamma in zip(us[:-1], gammas): 208 | assert rx.tunnel._eckart(u, 8, 12) == pytest.approx(gamma, 2e-2) 209 | 210 | gammas = [1.21, 1.53, 2.02, 2.93, 4.65, 15.6, 74.4, 473] 211 | for u, gamma in zip(us[:-1], gammas): 212 | assert rx.tunnel._eckart(u, 8, 16) == pytest.approx(gamma, 2e-2) 213 | 214 | gammas = [1.20, 1.51, 2.00, 2.90, 4.61, 15.5, 74.2, 474] 215 | for u, gamma in zip(us[:-1], gammas): 216 | assert rx.tunnel._eckart(u, 8, 20) == pytest.approx(gamma, 2e-2) 217 | 218 | gammas = [1.2, 1.5, 2.1, 3.1, 5.2, 22, 162, 1970] 219 | for u, gamma in zip(us[:-1], gammas): 220 | assert rx.tunnel._eckart(u, 12, 12) == pytest.approx( 221 | gamma, 222 | 2e-2, 223 | ) 224 | 225 | gammas = [1.2, 1.5, 2.1, 3.1, 5.4, 25, 220, 3300] 226 | for u, gamma in zip(us[:-1], gammas): 227 | assert rx.tunnel._eckart(u, 12, 16) == pytest.approx( 228 | gamma, 229 | 2e-2, 230 | ) 231 | 232 | gammas = [1.2, 1.5, 2.1, 3.1, 5.4, 26, 246, 3920] 233 | for u, gamma in zip(us[:-1], gammas): 234 | assert rx.tunnel._eckart(u, 12, 20) == pytest.approx( 235 | gamma, 236 | 2e-2, 237 | ) 238 | 239 | gammas = [1.2, 1.5, 2.1, 3.2, 5.7, 32, 437] 240 | for u, gamma in zip(us[:-2], gammas): 241 | assert rx.tunnel._eckart(u, 16, 16) == pytest.approx( 242 | gamma, 243 | 2e-2, 244 | ) 245 | 246 | gammas = [1.2, 1.5, 2.1, 3.2, 5.9, 37, 616] 247 | for u, gamma in zip(us[:-2], gammas): 248 | assert rx.tunnel._eckart(u, 16, 20) == pytest.approx( 249 | gamma, 250 | 2e-2, 251 | ) 252 | 253 | gammas = [1.2, 1.5, 2.1, 3.2, 6.1, 46, 1150] 254 | for u, gamma in zip(us[:-2], gammas): 255 | assert rx.tunnel._eckart(u, 20, 20) == pytest.approx( 256 | gamma, 257 | 4e-2, 258 | ) 259 | --------------------------------------------------------------------------------